예전에 SSD, HDD에 대한 글을 살펴본 것 처럼, I/O 처리 방식은 시스템 성능에 큰 영향을 미칩니다.
이전까지는 select, poll, epoll과 같은 방식을 통해 비동기 I/O를 처리하곤 했습니다.
다만, 최근에는 더욱 향상된 성능과 효율성을 제공하는 새로운 모델, IO_uring이 등장했습니다.
이 새로운 비동기 I/O 처리 모델인 IO_uring에 대해 알아봅시다 : )
TLDR
IO_uring은 리눅스 커널 5.1부터 도입된 새로운 비동기 I/O 처리 모델입니다. 기존의 비동기 I/O 모델들과 달리, IO_uring은 더욱 강력한 기능과 향상된 성능을 제공합니다.
기본적으로 IO_uring은 사용자 공간(user-space)과 커널 공간(kernel-space) 사이에 공유된 두 개의 링 버퍼(ring buffer)를 이용합니다. 하나는 submission queue로, 사용자 공간에서 생성된 I/O 요청들을 커널에 전달하기 위해 사용되며, 다른 하나는 completion queue로, 완료된 I/O 작업들을 커널에서 사용자 공간으로 전달하기 위해 사용됩니다. 이러한 구조는 시스템 콜의 횟수를 줄이고, context switch를 최소화하여 성능을 향상시킵니다.
IO_uring 의 기본 구조
IO_uring은 기본적으로 사용자 공간(user-space)과 커널 공간(kernel-space) 사이에 두 개의 링 버퍼를 유지합니다:
- Submission Queue (SQ) : 사용자 공간에서 발생하는 I/O 작업을 커널에 전달합니다.
- Completion Queue (CQ) : 완료된 I/O 작업의 결과를 커널에서 사용자 공간으로 반환합니다.
각 큐는 큐 헤드(queue head), 큐 테일(queue tail), 큐 링(queue ring), 그리고 배열(entries array)로 구성되어 있습니다. 사용자는 테일에 새로운 항목을 추가하고, 커널은 헤드를 통해 항목을 가져옵니다. 이러한 설계를 통해, 커널은 큐의 상태를 지속적으로 확인하지 않고도 요청을 처리할 수 있습니다.
IO_uring 의 작동 방식
- 먼저, 사용자 공간에서는 io_uring_setup 시스템 콜을 통해 IO_uring 인스턴스를 초기화하고, 필요한 큐 크기를 지정합니다.
- 다음으로, 사용자는 io_uring_register 시스템 콜을 통해 파일 설명자, 이벤트, 버퍼 등을 등록합니다.
- 이후에는 사용자가 io_uring_enter 시스템 콜을 통해 I/O 작업을 submit 합니다. 이때 submission queue entry (SQE)를 생성하여 해당 작업을 설명하며, 이 SQE는 submission queue에 추가됩니다.
- 커널은 이 SQE들을 검사하고 해당하는 I/O 작업을 수행합니다. 완료된 작업은 completion queue entry (CQE)로 변환되어 completion queue에 추가됩니다.
- 마지막으로, 사용자는 다시 io_uring_enter를 호출하여 completion queue에서 완료된 작업을 가져옵니다.
IO_uring의 장점
- 확장성 : IO_uring은 기존의 비동기 I/O 모델보다 더욱 높은 확장성을 제공합니다. 여러 I/O 작업을 하나의 시스템 콜로 submit 할 수 있으며, 이를 통해 시스템 콜의 오버헤드를 크게 줄일 수 있습니다.
- 효율성 : IO_uring은 사용자 공간과 커널 공간 사이의 context switch를 최소화하며, 이를 통해 성능 향상이 가능해집니다. 또한, 사용자 공간에서 직접 접근 가능한 submission queue와 completion queue를 제공함으로써, 불필요한 복사 작업을 줄입니다.
- 유연성 : IO_uring은 파일 I/O 뿐만 아니라, 소켓 I/O, 타이머, 이벤트 fd, 시그널 등 다양한 형태의 비동기 작업에 사용할 수 있습니다. 이는 IO_uring이 높은 수준의 유연성을 가지고 있음을 의미합니다.
자, 이제 어느 정도 IO_uring 이 이해됐으니, 예제를 한번 살펴볼까요?
const BUF_SIZE: usize = 1024;
#[tokio::main]
async fn main() -> io::Result<()> {
// Create a new io_uring instance.
let mut ring = IoUring::new(256).unwrap();
// Open the file for reading.
let file = File::open("testfile").unwrap();
let fd = file.as_raw_fd();
// Allocate a buffer for reading.
let mut buf = vec![0; BUF_SIZE];
// Prepare a ReadV operation.
let read_e = opcode::Readv::new(opcode::types::Fd(fd), &buf[..], 0)
.build()
.user_data(0x42);
// Submit the ReadV operation to the ring.
unsafe {
let mut queue = ring.submission().available();
queue.push(read_e).unwrap();
ring.submit().unwrap();
}
// Wait for the operation to complete.
let cqe = ring.completion().wait().unwrap();
assert_eq!(cqe.user_data(), 0x42);
// The operation completed successfully, print number of bytes read.
let bytes_read = cqe.result().unwrap() as usize;
println!("Read {} bytes", bytes_read);
Ok(())
}
- io_uring 인스턴스 생성 : IoUring::new(256).unwrap()을 호출하여 새로운 io_uring 인스턴스를 생성합니다. (이 인스턴스는 256개의 엔트리를 가진 큐를 설정)
- 파일 열기 : "testfile" 파일을 열고, FD를 가져옵니다.
- 버퍼 할당 : BUF_SIZE(1024) 크기를 가진 버퍼를 생성합니다. (파일 읽기 결과를 저장하기 위해 사용)
- ReadV prepare : opcode::Readv::new(opcode::types::Fd(fd), &buf[..], 0).build().user_data(0x42)을 호출해서 ReadV 작업을 준비합니다. 이 작업은 파일 디스크립터 fd에서 읽어오며, 읽어온 데이터는 buf에 저장됩니다.
- ReadV submit : ring.submission().available()를 호출하여 submit 큐를 가져온다음, queue.push(read_e).unwrap()을 호출하여 ReadV 작업을 submit 큐에 추가합니다. 그리고 ring.submit().unwrap()을 호출하여 큐에 있는 작업들을 submit 합니다.
- 마무리 : 이후 ReadV 작업이 완료되기를 기다렸다가, 작업이 완료되면 출력합니다.
마무리
IO_uring은 리눅스 커널 5.1 이상에서 사용할 수 있다는 제약조건이 있긴 하지만, 기존의 비동기 I/O 모델보다 더욱 강력하고 유연한 기능을 제공합니다. 다만, CVE-2021–20226, CVE-2022-29582 등과 같은 보안 이슈들이 존재했던것처럼(지금은 둘 다 해결됨) 보안에 대한 우려를 가지고 계신분들도 많으실 겁니다.(실제로 타당한 걱정일 수 있구요)
그래도 성능적으로 매력적인 선택지이기에 앞으로 지켜볼만한 선택지일것 같습니다 :)
+ 사진자료가 없어서 찾아보다가, 예전에 리트윗 해둔 0xor0one님의 트윗에서 잘 정리된 그림을 찾았고 추가할 수 있었다.(이 자리를 빌어 감사의 말씀을)
혹시 틀린 부분이 있다면, 언제든 편하게 지적해주세요!
이메일 ian.ilminmoon@gmail.com
'Research' 카테고리의 다른 글
PostgreSQL 15 살펴보기 (부제: Logical Decoding Message 의 이점) (0) | 2023.04.02 |
---|