io_uring은 사용자 공간과 커널이 공유 메모리 링 버퍼를 통해 I/O 요청과 완료 결과를 주고받는 리눅스의 비동기 I/O 인터페이스로, 요청을 커널에 밀어 넣고 결과를 받아 오는 과정에서 syscall 진입 횟수를 최소화하도록 설계되었습니다. 앞 장에서 다룬 것처럼 write/read 같은 전통적 syscall은 호출마다 모드 전환 비용을 수반하고 배치(batching)로 이를 줄이는 데는 한계가 있는데, io_uring은 여기서 한 걸음 더 나아가 “요청을 큐에 쌓아두고 커널이 스스로 처리한 뒤 완료를 큐에 쌓아둔다"는 모델로 syscall 자체를 구조적으로 줄입니다. 이 장에서는 io_uring이 등장한 배경과 SQ(Submission Queue)/CQ(Completion Queue) 링 버퍼의 동작 원리를 다지고, 최근 커널에 들어온 epoll 통합(Linux 6.15)·NAPI busy-poll·IORING_OP_URING_CMD(5.19) 기반 NVMe passthrough를 개관합니다.
이 장을 읽기 전에
선행 챕터: Syscall 비용과 최소화 기법(챕터 02)에서 syscall 진입·탈출 비용과 writev/sendmmsg 배치를 다뤘습니다. io_uring은 그 배치 아이디어의 논리적 극단으로, 개별 요청마다 syscall을 내는 대신 다건의 요청을 한 번의 io_uring_enter() 호출로 제출합니다. 커널 바이패스 개요(챕터 07)에서 커널을 완전히 우회하는 DPDK류 접근과 “커널을 거치되 오버헤드를 줄이는” io_uring의 위치 관계를 이미 정리했다면 이 장의 맥락을 잡기 쉽습니다.
전제 지식: user mode/kernel mode 구분과 syscall 진입 비용(챕터 02), mmap으로 파일이나 익명 메모리를 프로세스 주소 공간에 매핑한다는 개념, epoll 기반 이벤트 루프(epoll_wait로 준비된 fd를 통지받는 모델)를 한 번이라도 다뤄 본 경험이면 충분합니다.
이 장의 깊이: 중급입니다. SQ/CQ 링 버퍼의 구조, SQE(Submission Queue Entry)·CQE(Completion Queue Entry)가 오가는 방식, io_uring_setup/io_uring_enter/io_uring_register 세 syscall의 이름과 쓰임(링 생성, 제출·대기, 버퍼·파일·NAPI 등록), 그리고 최근 커널에 들어온 epoll 통합·NAPI busy-poll·NVMe passthrough 기능을 “무엇을 해결하는가” 수준까지 다룹니다.
다루지 않는 것: 이 장은 개요이며, 이 트랙(Tr.06) 안에서 완결된 내용을 제공하되 다음 두 영역은 별도 트랙의 몫으로 남겨 둡니다. 파일시스템·블록 I/O 경로에서 io_uring을 실전 배포하는 세부 튜닝(큐 깊이 산정, fixed buffer/file 등록, O_DIRECT와의 상호작용, 파일시스템별 차이)은 아직 집필되지 않은 I/O 최적화 트랙(Tr.09)의 심화 챕터가 다룰 범위입니다. 소켓 기반 네트워크 서버에서 io_uring과 epoll을 함께 쓰는 아키텍처, sendzc(send zero-copy) 같은 네트워크 전용 옵코드의 세부 사항은 아직 집필되지 않은 네트워크 최적화 트랙(Tr.10)의 몫입니다. 이 두 트랙이 아직 없으므로 여기서는 범위와 경계만 설명하고 존재하지 않는 링크는 걸지 않습니다. 또한 io_uring의 공격 표면·verifier 유사 보안 모델 자체는 이 장의 범위가 아니며, BPF 기반 커널 확장의 보안 모델은 17장: eBPF·커널 경계와 성능·안전에서 다룹니다.
당신의 수준에 맞는 경로
| 수준 | 읽을 부분 | 핵심 목표 |
|---|---|---|
| 입문 | “io_uring의 등장 배경” ~ “SQ/CQ 링 버퍼와 비동기 I/O 원리” | 전통적 syscall과 io_uring의 근본적 차이(공유 링 버퍼) 이해 |
| 중급자 | “제출 모드: 인터럽트 기반과 SQPOLL” ~ “최신 기능 개관” | io_uring_enter의 배치 제출과 최신 옵코드(epoll·NAPI·uring_cmd)가 겨냥하는 문제 파악 |
| 전문가 | “판단 기준” ~ “비판적 시각” | io_uring 도입 여부와 어떤 심화 트랙으로 넘어갈지 판단 |
io_uring의 등장 배경 (역사·배경)
리눅스는 2.5 커널부터 libaio 기반의 네이티브 비동기 I/O(AIO)를 제공해 왔지만, 오랫동안 “쓰기 어렵고 비효율적"이라는 평가를 받았습니다. libaio는 버퍼링된(buffered) 파일 I/O를 사실상 비동기로 처리하지 못해 특정 조건에서 블로킹으로 되돌아갔고, 소켓 I/O 지원도 일관되지 않았으며, 요청마다 별도의 syscall과 메모리 복사가 필요해 오버헤드가 컸습니다. 커널 개발자 Jens Axboe가 이런 한계를 해결하려고 설계한 새 인터페이스가 io_uring이며, 핵심 개선점은 더 효율적이라는 것과, 특히 데이터가 페이지 캐시에 이미 있을 때 컨텍스트 스위치 없이 비동기 버퍼 I/O를 수행할 수 있다는 점이었습니다. 이 설계는 리눅스 5.1부터 커널에 포함되었고, 이후 커널 버전을 거치며 고정 버퍼/파일 등록(io_uring_register), SQPOLL(커널 스레드가 SQ를 스스로 폴링), 멀티샷(multishot) 요청, 그리고 이 장 후반에서 다룰 epoll 통합·NAPI busy-poll·IORING_OP_URING_CMD 같은 확장이 꾸준히 추가되어 왔습니다.
SQ/CQ 링 버퍼와 비동기 I/O 원리 (핵심 메커니즘)
io_uring이라는 이름 자체가 사용자 공간과 커널 공간이 공유하는 링 버퍼(ring buffer)에서 왔습니다. 애플리케이션은 요청하려는 I/O 작업을 SQ(Submission Queue)에 올려두고, 커널은 그 작업들의 처리 결과를 CQ(Completion Queue)에 올려둡니다. 두 큐 모두 io_uring_setup() syscall이 만든 파일 디스크립터를 mmap(2)으로 사용자 프로세스 주소 공간에 매핑해 공유하므로, 큐를 오갈 때 커널·사용자 공간 사이에 별도의 데이터 복사가 필요 없습니다. 각 SQ 슬롯에 채워 넣는 구조체가 SQE(Submission Queue Entry)이고, 여기에는 어떤 파일 디스크립터에 어떤 연산(opcode)을 어떤 버퍼로 수행할지가 담깁니다. 커널은 처리를 마치면 그 결과를 CQE(Completion Queue Entry)로 CQ에 채워 넣습니다. 애플리케이션은 하나 이상의 SQE를 SQ에 채운 뒤 io_uring_enter() syscall로 커널에 “제출됐다"는 사실만 알리면 되므로, N개의 요청을 준비했다면 이론적으로 syscall 한 번으로 N개를 모두 커널에 넘길 수 있습니다.
flowchart LR prepareSqe["SQE 준비(fd, buf, len, opcode)"] --> sqRing["SQ 링(공유 메모리, 사용자→커널)"] sqRing -->|"io_uring_enter()"| kernelWork["커널: 비동기 I/O 처리(파일시스템·블록·네트워크)"] kernelWork --> cqRing["CQ 링(공유 메모리, 커널→사용자)"] cqRing -->|"io_uring_wait_cqe()"| collectCqe["완료 결과 수거"] collectCqe -->|"다음 라운드"| prepareSqe
man7.org의 io_uring(7) 문서는 이 관계를 “You place I/O requests you want to make on the SQ, while the kernel places the results of those operations on the CQ.“라고 설명하며, 공유 버퍼를 매핑하는 mmap(2) 호출이 io_uring 설정의 핵심 단계임을 명시합니다. 실제 코드에서는 이 저수준 프로토콜을 직접 다루기보다 liburing이 제공하는 io_uring_get_sqe/io_uring_prep_read/io_uring_submit/io_uring_wait_cqe 같은 헬퍼로 다루는 것이 일반적입니다.
| |
gcc -O2 bench_uring.c -luring -o bench_uring으로 빌드합니다(Linux, liburing-dev 설치 필요). 검증은 같은 작업을 개별 read() 호출 32번으로 처리하는 버전과 나란히 strace -c -e trace=read,io_uring_enter ./binary로 비교하는 것입니다 — io_uring 버전은 io_uring_enter 호출이 눈에 띄게 적게 잡히고 read syscall 자체는 아예 나타나지 않는 반면, 개별 read() 버전은 호출 횟수가 큐 깊이만큼 그대로 잡힙니다. 챕터 02에서 strace -c로 writev/sendmmsg 배치 효과를 확인했던 것과 같은 방법론이며, 정확한 배율은 큐 깊이·커널 버전·스토리지 종류에 따라 달라지므로 절대 수치보다 “syscall 횟수가 큐 깊이만큼 줄어든다"는 구조적 사실에 주목해야 합니다.
제출 모드: 인터럽트 기반과 SQPOLL
기본 모드에서는 애플리케이션이 SQE를 채운 뒤 명시적으로 io_uring_enter()를 호출해야 커널이 SQ를 확인합니다. 반대로 IORING_SETUP_SQPOLL 플래그로 링을 만들면 커널이 전용 커널 스레드를 띄워 SQ를 스스로 계속 폴링하므로, 애플리케이션은 SQE만 채워 넣고 별도의 io_uring_enter() 호출 없이도(대부분의 경우) 처리가 진행됩니다. man7.org의 io_uring_enter(2) 문서는 “If the ring has been created with IORING_SETUP_SQPOLL, then this flag asks the kernel to wakeup the SQ kernel thread"라고 밝혀, SQPOLL 모드에서도 커널 스레드가 유휴 상태로 잠들어 있을 때는 IORING_ENTER_SQ_WAKEUP 플래그로 깨워야 함을 명시합니다. SQPOLL은 syscall 자체를 거의 없애는 대신 전용 코어 하나를 그 폴링 스레드가 계속 점유하게 되므로, 코어 예산이 넉넉한 지연-critical 서비스에서만 고려 대상이 됩니다. 완료를 기다리는 쪽도 마찬가지로 두 방식이 있는데, IORING_ENTER_GETEVENTS 플래그를 준 io_uring_enter() 호출은 지정한 개수의 완료가 쌓일 때까지 블로킹하고, 애플리케이션이 직접 CQ 링을 폴링하면 블로킹 없이 완료 여부만 확인할 수 있습니다.
최신 기능 개관
io_uring은 처음에는 파일·블록 I/O 위주로 설계됐지만, 이후 네트워크 이벤트 루프와 하드웨어 직접 제어 영역까지 옵코드를 확장해 왔습니다. 아래 세 기능은 이 장을 쓰는 시점 기준으로 최근 커널에 안정화된 확장이며, 각각이 “개요” 수준에서 무엇을 해결하는지만 짚고 세부 튜닝은 앞서 밝힌 대로 별도 트랙에 남겨 둡니다.
epoll 통합 (Linux 6.15)
많은 기존 서버는 이미 epoll_wait 기반 이벤트 루프로 구축되어 있어, io_uring으로 완전히 재작성하는 비용이 크거나 일부 레거시 구성 요소가 epoll에 강하게 결합돼 있는 경우가 흔합니다. **IORING_OP_EPOLL_WAIT**는 리눅스 6.15부터 지원되는 옵코드로, man7.org의 io_uring_enter(2) 문서는 이를 “Wait for events on an epoll instance. This is an async version of epoll_wait(2).“라고 설명합니다. fd에 epoll 인스턴스의 파일 디스크립터를, addr에 결과를 받을 struct epoll_event 배열을 지정하면, 이 SQE 하나가 완료될 때 마치 epoll_wait를 호출한 것과 같은 결과를 CQE로 돌려받습니다. 핵심 용도는 점진적 마이그레이션입니다 — 애플리케이션 전체를 한 번에 io_uring 네이티브 옵코드로 바꾸는 대신, epoll을 계속 쓰는 레거시 구성 요소를 IORING_OP_EPOLL_WAIT로 감싸 io_uring 이벤트 루프 하나에 합류시키고, 나머지 I/O는 io_uring 네이티브 옵코드로 점차 옮겨갈 수 있습니다.
NAPI busy-poll
네트워크 인터페이스가 패킷을 받으면 보통 인터럽트가 발생하고, NAPI(New API) 프레임워크가 그 인터럽트를 잠시 꺼 둔 채 폴링 방식으로 패킷을 배치 처리한 뒤 다시 인터럽트를 켭니다. 이 전환 과정 자체가 지연을 더하므로, 극단적으로 지연에 민감한 네트워크 워크로드에서는 아예 인터럽트를 기다리지 않고 짧은 시간 동안 계속 폴링하는 busy-poll이 유리할 때가 있습니다. io_uring은 io_uring_register_napi()로 이 설정을 링 단위로 등록할 수 있게 하는데, man7.org의 io_uring_register_napi(3) 문서는 “NAPI busy poll can reduce the network roundtrip time"이라고 밝히며, 마이크로초 단위의 폴링 타임아웃을 지정하는 busy_poll_to 필드와 소켓 옵션 SO_PREFER_BUSY_POLL에 대응하는 prefer_busy_poll 필드로 동작을 조정한다고 설명합니다. busy-poll은 지연을 줄이는 대가로 폴링하는 동안 CPU 코어를 계속 점유하므로, 코어를 아낌없이 쓸 수 있는 지연-critical 경로에만 적용 대상이 됩니다.
IORING_OP_URING_CMD와 NVMe passthrough
전통적인 블록 I/O 경로는 파일시스템·블록 계층·드라이버를 거치며 범용성을 확보하는 대신 그 계층들이 오버헤드를 더합니다. **IORING_OP_URING_CMD**는 파일별 사설(private) 명령을 ioctl(2)과 비슷하게 비동기로 실행할 수 있게 하는 옵코드로, man7.org의 io_uring_enter(2) 문서는 이를 “Issues an asynchronous, per-file private operation, similar to ioctl(2).“라고 설명합니다. io_uring 자체는 명령의 내용을 해석하지 않고, 대상 드라이버가 노출하는 uring_cmd 핸들러가 처리를 맡습니다. NVMe passthrough는 이 메커니즘의 대표적인 활용 사례로, 애플리케이션이 파일시스템을 거치지 않고 NVMe 명령을 거의 그대로 디바이스에 전달하면서도 io_uring의 배치 제출·비동기 완료 이점을 그대로 누릴 수 있게 합니다. NVMe 명령처럼 큰 페이로드는 표준 64바이트 SQE에 들어가지 않으므로, io_uring_setup(2) man 페이지에 따르면 5.19 기준으로 IORING_OP_URING_CMD의 NVMe passthrough는 IORING_SETUP_SQE128(128바이트 SQE, 명령 데이터 80바이트)과 IORING_SETUP_CQE32(32바이트 CQE)로 링을 만들어야 합니다. NVMe 명령 포맷 자체를 다루는 세부 사항은 스토리지 드라이버·펌웨어 지식을 요구하므로 이 장에서는 “io_uring이 범용 블록 계층을 우회하는 통로를 제공한다"는 개념만 짚고 지나갑니다.
흔한 오개념 바로잡기
오개념 1: “io_uring은 항상 완전한 논블로킹·논싱크 실행을 보장한다.” io_uring이 요청을 큐에 쌓아 비동기로 처리한다고 해서, 커널 내부 구현까지 모든 경우에 별도 워커 스레드를 거쳐 완전히 논블로킹으로 처리된다는 뜻은 아닙니다. 일부 파일시스템·연산 조합에서는 커널이 내부적으로 블로킹 경로를 타면서 io_uring의 워커 스레드 풀(io-wq)을 소비할 수 있고, 이 경우 워커 풀 크기나 경합이 다른 형태의 병목이 될 수 있습니다.
오개념 2: “SQE를 많이 채워 넣으면 넣을수록 무조건 성능이 좋아진다.” 큐 깊이를 키우면 syscall 횟수는 줄지만, 완료를 기다리는 지연이 함께 늘어날 수 있고(배치가 커질수록 마지막 항목의 완료를 기다리는 시간도 늘어남), SQ/CQ 링 자체가 차지하는 공유 메모리와 커널 측 자료구조 크기도 커집니다. 처리량과 지연의 균형점은 워크로드마다 다르므로 큐 깊이는 실측으로 정해야 합니다.
판단 기준 (언제 쓰고 언제 피할지)
| 상황 | 권장 | 비권장·주의 |
|---|---|---|
| 다건의 파일·블록 I/O를 배치로 제출하고 완료를 나중에 수거 | io_uring 네이티브 옵코드(IORING_OP_READ 등) | 요청마다 개별 syscall 반복 |
| 레거시 epoll 이벤트 루프를 점진적으로 io_uring으로 통합 | IORING_OP_EPOLL_WAIT(6.15+)로 우선 감싸기 | 검증 없이 전체를 한 번에 재작성 |
| 극단적으로 지연에 민감한 네트워크 수신 경로, 코어 여유 있음 | io_uring_register_napi busy-poll | 코어가 빠듯한 다중 테넌트 환경 |
| 파일시스템·블록 계층 오버헤드가 병목으로 확인된 NVMe 경로 | IORING_OP_URING_CMD 기반 passthrough(Tr.09 심화로 연계) | 충분한 벤치마크 없이 범용 경로부터 우회 |
| syscall 배치만으로 충분한 중간 규모 I/O 부하 | writev/sendmmsg(챕터 02) | io_uring 도입에 따른 코드·운영 복잡도를 감당하기 전에 먼저 시도 |
| 코어 예산이 빠듯한 일반 서버 | 기본 인터럽트 기반 제출 모드 | SQPOLL(전용 코어 상시 점유) |
비판적 시각: 한계와 트레이드오프
io_uring이 syscall 오버헤드를 구조적으로 줄이는 것은 사실이지만, 그 대가는 다른 곳으로 옮겨갑니다. SQPOLL과 NAPI busy-poll은 모두 “syscall·인터럽트를 없애는 대신 코어를 계속 점유한다"는 동일한 트레이드오프를 공유하며, 코어가 귀한 다중 테넌트 환경에서는 오히려 손해가 될 수 있습니다. io_uring의 공격 표면이 넓다는 지적도 꾸준히 제기되어 왔습니다 — 커널과 사용자 공간이 메모리를 공유하고 다양한 파일별 사설 명령(uring_cmd)까지 확장되면서 검토해야 할 보안 경계가 넓어졌고, 배포 환경의 보안 정책에 따라 io_uring 사용이 제한될 수 있으므로 도입 전에 직접 확인해야 합니다. 또한 io_uring은 API 표면이 넓고 세부 옵코드·플래그 조합에 따른 동작 차이가 커서, liburing의 고수준 헬퍼를 쓰더라도 완료 순서 보장 여부·부분 완료 처리·워커 풀 경합 같은 세부 사항을 잘못 이해하면 전통적 syscall 방식보다 디버깅이 더 어려워질 수 있습니다. 이 장에서 다룬 최신 기능들(epoll 통합, NAPI busy-poll, uring_cmd 확장)은 각각 특정 문제를 겨냥한 것이지 “무조건 켜면 좋은” 만능 스위치가 아니므로, 판단 기준 표의 조건에 맞는지 먼저 확인하고 도입해야 합니다.
마무리
이 장을 읽은 뒤 다음을 스스로 확인할 수 있어야 합니다.
- SQ/CQ 링 버퍼가 공유 메모리를 통해 커널·사용자 공간 사이의 복사와 syscall 횟수를 줄이는 원리를 설명할 수 있다.
-
io_uring_setup/io_uring_enter/io_uring_register의 역할과 SQE/CQE의 관계를 설명할 수 있다. - 인터럽트 기반 제출과 SQPOLL의 차이, 그리고 SQPOLL이 코어를 상시 점유하는 트레이드오프를 안다.
- epoll 통합(
IORING_OP_EPOLL_WAIT, 6.15+)·NAPI busy-poll·IORING_OP_URING_CMD가 각각 어떤 문제를 겨냥하는지 구분할 수 있다. - 이 장(개요)과 향후 I/O·네트워크 심화 트랙(Tr.09·Tr.10)의 경계를 설명할 수 있다.
-
strace -c로 io_uring 버전과 전통적 syscall 버전의 호출 횟수 차이를 실측하는 방법을 안다.
이전 장: 커널 바이패스 개요 (챕터 07)
다음 장에서는 XDP/eBPF 개요를 다룹니다. io_uring이 “커널을 거치되 오버헤드를 줄이는” 접근이라면, XDP는 네트워크 드라이버 초입에서 패킷을 가로채 커널 스택 진입 자체를 건너뛰는 또 다른 접근입니다. 두 기법이 겨냥하는 지점(범용 I/O 제출 경로 vs 패킷 수신 초입)이 어떻게 다른지 비교하며 읽으면 이 트랙의 커널 바이패스 스펙트럼 전체를 정리할 수 있습니다.
→ XDP/eBPF 개요 (챕터 09)
![Featured image of post [OS 06] io_uring 개요](/post/os-optimization/io-uring-overview-fundamentals/wordcloud_hu_971f45cbe6169ff5.webp)
![[OS 06] 정밀 시간 측정](/post/os-optimization/precise-time-measurement-rdtsc-clock-gettime/wordcloud_hu_880594b44c30cd79.webp)
![[OS 06] 커널 바이패스 개요](/post/os-optimization/kernel-bypass-overview/wordcloud_hu_592d027bde39b991.webp)
![[OS 06] io_uring 개요](/post/os-optimization/io-uring-overview-fundamentals/wordcloud_hu_f460bb626e150c9c.webp)
![[OS 06] XDP/eBPF 개요](/post/os-optimization/xdp-ebpf-overview-fundamentals/wordcloud_hu_d13d196d37b856f4.webp)
![[OS 06] Huge TLB Pages 활용](/post/os-optimization/huge-tlb-pages-utilization/wordcloud_hu_a653b506ee57003e.webp)
![[IO 09] io_uring 심화](/post/io-optimization/io-uring-advanced-deep-dive/wordcloud_hu_9430cc2847c0984b.webp)
![[IO 09] POSIX AIO vs io_uring](/post/io-optimization/posix-aio-vs-io-uring-performance-comparison/wordcloud_hu_774f265069068efb.webp)
![[Network 10] RDMA 기초](/post/network-optimization/rdma-infiniband-fundamentals/wordcloud_hu_76e1130d3c332e4c.webp)
![[IO 09] Memory-mapped I/O](/post/io-optimization/memory-mapped-io-mmap-usage-pitfalls/wordcloud_hu_90e9960d7992e6aa.webp)