- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 왜 io_uring인가
2019년 5월, Linux 커널 5.1이 출시되면서 한 가지 조용한 혁명이 일어났다. Jens Axboe가 작성한 새 비동기 I/O 인터페이스 io_uring이 메인라인에 머지된 것이다. 처음에는 "또 하나의 AIO 변종"으로 취급되었지만, 6년이 지난 지금 io_uring은 Linux 고성능 I/O의 사실상 표준으로 자리잡았다. ScyllaDB는 처음부터 io_uring을 전제로 설계되었고, RocksDB는 io_uring 백엔드를 추가했으며, Nginx는 실험적으로 io_uring 모듈을 머지했다. Cloudflare의 Pingora, Tigerbeetle, glommio, monoio 같은 새로운 인프라들은 모두 io_uring 위에서 돌아간다.
이 글은 io_uring을 "왜 epoll로는 부족했는가"부터 시작해서 SQ/CQ 링 버퍼의 메모리 레이아웃, SQPOLL 커널 스레드의 동작 방식, 멀티샷 연산, 등록 버퍼와 파일, NVMe passthrough, 그리고 Rust 생태계의 tokio-uring/monoio까지 모두 다룬다. 1,500줄에 달하는 긴 글이지만, 모든 절은 독립적으로 읽을 수 있다.
이전 글인 Nginx 내부 구조 딥다이브에서는 Nginx의 epoll 기반 이벤트 루프를 다뤘다. 이 글은 그 후속편으로, "epoll 다음에 무엇이 오는가"에 답한다.
1. 역사적 배경 — Linux 비동기 I/O의 슬픈 역사
1.1 첫 번째 시도: POSIX AIO
POSIX는 1993년부터 비동기 I/O API를 정의했다. aio_read(3), aio_write(3), aio_suspend(3) 같은 함수들이 그것이다. 그러나 Linux의 glibc는 이 API를 사용자 공간 스레드 풀로 구현했다. 즉, "비동기" 호출은 실제로는 백그라운드 pthread가 동기 read(2)를 호출하는 것에 불과했다. 진정한 커널 수준 비동기는 아니었고, 컨텍스트 스위치 비용도 그대로 발생했다.
1.2 두 번째 시도: Linux AIO (io_submit(2))
2002년, Linux 2.5에 진정한 커널 비동기 I/O인 io_submit(2)가 도입되었다. io_setup(2)로 컨텍스트를 만들고, io_submit(2)로 연산을 큐에 넣고, io_getevents(2)로 결과를 받는 구조였다. 그러나 이 API에는 치명적인 한계가 있었다:
- O_DIRECT만 지원 — 버퍼드 I/O (페이지 캐시 경유)는 호출이 블로킹되었다. 즉, 일반 파일 읽기에는 사실상 쓸 수 없었다.
- 소켓 미지원 — 네트워크 I/O에는 적용 불가.
- 메타데이터 연산 미지원 —
fsync,stat,open,close등은 모두 동기 호출로 남았다. - API 디자인 결함 —
iocb구조체가 너무 무거웠고, 복잡했으며, 확장성이 떨어졌다.
Jens Axboe는 2019년 LSFMM 컨퍼런스 발표에서 이렇게 말했다 (의역): "Linux AIO는 만들지 말았어야 했다. 우리는 처음부터 다시 해야 한다."
1.3 epoll의 한계
한편 네트워크 I/O 쪽에서는 epoll이 표준이 되어 있었다. epoll은 "준비됨 알림(readiness notification)" 모델을 사용한다 — 커널이 "이 fd에서 읽을 데이터가 있다"고 알려주면, 사용자 코드가 직접 read(2)를 호출한다. 이 모델은 두 가지 약점이 있다:
- 시스템 콜 오버헤드 — 알림(
epoll_wait) + 실제 I/O(read) 두 번의 syscall이 필요하다. Spectre/Meltdown 이후 syscall 비용이 더 비싸졌다. - 버퍼 관리 부담 — 사용자가 직접 버퍼를 잡아야 하고, 짧은
recv결과를 처리해야 한다. - 파일 I/O에 부적합 — 디스크 fd는 항상 "준비됨" 상태이므로 epoll로 비동기화할 수 없다.
io_uring은 이 두 가지 문제 — Linux AIO의 제약과 epoll의 비효율 — 를 동시에 해결하기 위해 설계되었다.
1.4 io_uring의 탄생
2019년 1월, Jens Axboe는 LKML에 첫 번째 io_uring 패치를 게시했다. 핵심 아이디어는 다음과 같았다:
- 공유 메모리 링 버퍼로 syscall 오버헤드를 제거한다. 사용자와 커널이 같은 메모리를 본다.
- 두 개의 링 — 제출 큐(SQ)와 완료 큐(CQ) — 으로 양방향 통신을 한다.
- 모든 I/O 연산 — 파일, 소켓, 메타데이터, 심지어 NVMe passthrough까지 — 을 통합 인터페이스로 처리한다.
- 버퍼드 I/O도 진정한 비동기로 처리한다. 페이지 캐시 미스도 백그라운드 워커로 위임된다.
같은 해 5월 Linux 5.1에 머지되었고, 그 이후 매 릴리스마다 새 기능이 추가되고 있다.
2. 아키텍처 개요 — SQ와 CQ 두 개의 링
2.1 큰 그림
io_uring의 핵심은 사용자 공간과 커널이 공유하는 두 개의 링 버퍼이다:
- Submission Queue (SQ) — 사용자가 "이 연산을 해줘"라고 적어 넣는 곳
- Completion Queue (CQ) — 커널이 "연산 결과는 이거야"라고 적어 넣는 곳
각 링은 단일 생산자, 단일 소비자(SPSC) 패턴을 따른다:
- SQ: 사용자가 생산자, 커널이 소비자
- CQ: 커널이 생산자, 사용자가 소비자
이 구조 덕분에 락이 전혀 필요 없다. 헤드/테일 포인터의 원자적 업데이트만으로 동기화된다.
2.2 메모리 레이아웃
io_uring_setup(2) 시스템 콜이 성공하면, 커널은 세 영역의 메모리를 할당한다:
+------------------+
| SQ Ring | <- mmap'd region #1
| - head, tail |
| - ring_mask |
| - sqe_indices[] |
+------------------+
| CQ Ring | <- mmap'd region #2
| - head, tail |
| - ring_mask |
| - cqes[] |
+------------------+
| SQEs | <- mmap'd region #3
| - sqe[0] |
| - sqe[1] |
| - ... |
+------------------+
사용자는 이 세 영역을 mmap(2)으로 자신의 주소 공간에 매핑한다. 그 후로는 모든 통신이 메모리 쓰기/읽기로 이루어진다.
★ Insight ─────────────────────────────────────
- 왜 SQE 배열이 따로 떨어져 있는가: SQ 링에는 SQE 배열의 인덱스만 들어간다. 이 indirection은 한 SQE를 "예약"해두고 천천히 채우다가, 다 채워지면 그 인덱스를 SQ 링에 넣을 수 있게 한다. 즉, SQE 채우기와 제출 통지가 분리된다.
- CQE는 직접 임베드: 반대로 완료 큐에서는 CQE 자체가 링에 들어간다. 커널 입장에서는 "결과를 한 번만 쓰고 끝"이므로 indirection이 불필요하다.
- 세 개의 mmap 영역: 이론적으로는 한 영역으로 합칠 수도 있지만, 권한과 캐시라인 정렬을 유연하게 하기 위해 세 영역으로 분리되었다. 이후 단일-mmap 모드(
IORING_SETUP_NO_MMAP)도 추가되었다.─────────────────────────────────────────────────
2.3 SQE 구조체
SQE(Submission Queue Entry)는 64바이트 고정 크기이다:
struct io_uring_sqe {
__u8 opcode; /* IORING_OP_READ, IORING_OP_WRITE, ... */
__u8 flags; /* IOSQE_FIXED_FILE, IOSQE_IO_LINK, ... */
__u16 ioprio; /* I/O priority */
__s32 fd; /* file descriptor */
union {
__u64 off; /* offset into file */
__u64 addr2;
};
union {
__u64 addr; /* pointer to buffer or iovec */
__u64 splice_off_in;
};
__u32 len; /* buffer length */
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
__u32 sync_range_flags;
__u32 msg_flags;
__u32 timeout_flags;
__u32 accept_flags;
__u32 cancel_flags;
__u32 open_flags;
__u32 statx_flags;
__u32 fadvise_advice;
__u32 splice_flags;
};
__u64 user_data; /* data returned in completion */
union {
struct {
union {
__u16 buf_index;
__u16 buf_group;
};
__u16 personality;
__s32 splice_fd_in;
};
__u64 __pad2[3];
};
};
핵심 필드:
opcode: 어떤 연산인지. 60개 이상의 opcode가 있다.fd: 대상 파일 디스크립터.addr,len,off: 버퍼 포인터, 길이, 오프셋. (연산에 따라 의미가 달라진다)user_data: 사용자가 자유롭게 쓰는 64비트 토큰. 완료 시 그대로 돌려받는다. (보통 콜백 포인터나 future ID를 넣는다)flags: SQE별 플래그 (IOSQE_IO_LINK로 연산을 체이닝,IOSQE_FIXED_FILE로 등록 파일 사용 등)
2.4 CQE 구조체
CQE(Completion Queue Entry)는 16바이트로 매우 작다:
struct io_uring_cqe {
__u64 user_data; /* sqe->user_data submission passed back */
__s32 res; /* result code */
__u32 flags; /* IORING_CQE_F_BUFFER, IORING_CQE_F_MORE, ... */
};
user_data: 제출 시 SQE에 넣은 토큰 그대로.res:read(2)같은 syscall이 반환했을 결과값. 음수면 errno (예:-EAGAIN).flags: 추가 메타데이터. 예를 들어 자동 버퍼 선택을 사용했다면 어떤 버퍼가 쓰였는지를 알려준다.
io_uring 5.19부터는 큰 CQE(IORING_SETUP_CQE32)가 도입되어 32바이트 CQE도 지원한다. 추가 16바이트는 NVMe passthrough 같은 연산에 쓰인다.
3. 시스템 콜 — io_uring의 세 가지 유일한 syscall
io_uring 전체 인터페이스는 단 세 개의 syscall로 이루어진다.
3.1 io_uring_setup(2)
int io_uring_setup(u32 entries, struct io_uring_params *params);
링을 생성한다. entries는 SQ의 크기 (2의 제곱으로 올림된다). params는 셋업 옵션과 결과를 담는 구조체이다.
성공하면 io_uring fd를 반환한다. 이 fd로 mmap을 하면 위에서 본 세 메모리 영역을 매핑할 수 있다.
struct io_uring_params {
__u32 sq_entries;
__u32 cq_entries;
__u32 flags;
__u32 sq_thread_cpu;
__u32 sq_thread_idle;
__u32 features;
__u32 wq_fd;
__u32 resv[3];
struct io_sqring_offsets sq_off;
struct io_cqring_offsets cq_off;
};
flags에 들어갈 수 있는 값:
IORING_SETUP_IOPOLL— 폴링 모드. 인터럽트 대신 사용자/커널이 폴링한다. (NVMe 같은 매우 빠른 디바이스에 유리)IORING_SETUP_SQPOLL— 커널 전용 SQ 폴링 스레드를 띄운다. (제출 시 syscall 자체가 사라진다)IORING_SETUP_SQ_AFF— SQPOLL 스레드를 특정 CPU에 핀시킨다.IORING_SETUP_CQSIZE— CQ 크기를 SQ와 다르게 지정.IORING_SETUP_CLAMP— 큐 크기를 max 값으로 클램핑.IORING_SETUP_ATTACH_WQ— 다른 ring과 워커풀을 공유.IORING_SETUP_R_DISABLED— 처음에는 비활성화 상태로 시작 (보안용).IORING_SETUP_SUBMIT_ALL— 에러가 나도 나머지 SQE를 계속 제출.IORING_SETUP_COOP_TASKRUN— 협력적 태스크 런 (시그널 회피).IORING_SETUP_TASKRUN_FLAG— taskrun이 필요할 때 플래그로 알림.IORING_SETUP_SINGLE_ISSUER— 단일 스레드만 SQE를 제출 (최적화).IORING_SETUP_DEFER_TASKRUN— taskrun을 명시적 호출까지 지연.
3.2 io_uring_enter(2)
int io_uring_enter(int fd, u32 to_submit, u32 min_complete,
u32 flags, sigset_t *sig);
링을 "포크"한다. SQ에 새 SQE가 있다면 커널에 처리하라고 알리고, 동시에 CQ에서 min_complete 개의 완료를 기다릴 수 있다.
flags:
IORING_ENTER_GETEVENTS— CQ에서 이벤트 대기.IORING_ENTER_SQ_WAKEUP— SQPOLL 스레드를 깨움.IORING_ENTER_SQ_WAIT— SQ가 꽉 찼을 때 비기를 기다림.IORING_ENTER_EXT_ARG— 확장 인자 사용 (io_uring_getevents_arg).
SQPOLL 모드를 쓰면 to_submit > 0인 경우에도 io_uring_enter 호출 자체가 불필요해진다 (커널이 알아서 폴링한다). 이것이 io_uring의 "syscall이 사라진다"는 말의 핵심이다.
3.3 io_uring_register(2)
int io_uring_register(int fd, unsigned opcode, void *arg, unsigned nr_args);
링에 리소스를 등록한다. 등록된 리소스는 매번 reference 카운트를 잡지 않아도 되어 빠르다. 등록 가능한 것:
IORING_REGISTER_BUFFERS— 사용자 버퍼를 핀해서 등록 (zero-copy 효과)IORING_REGISTER_FILES— 파일 디스크립터를 등록 (IOSQE_FIXED_FILE로 인덱스로 참조)IORING_REGISTER_EVENTFD— eventfd로 CQ 알림 받기IORING_REGISTER_PROBE— 어떤 opcode를 지원하는지 조회IORING_REGISTER_RESTRICTIONS— 어떤 연산만 허용할지 제한 (보안)IORING_REGISTER_ENABLE_RINGS—R_DISABLED로 시작한 링을 활성화IORING_REGISTER_PBUF_RING— Provided buffer ring (ring 모드 자동 버퍼)IORING_REGISTER_NAPI— NAPI 통합 (네트워크 폴링)
3.4 액세스 패턴 — syscall 없는 핫패스
전형적인 io_uring 흐름은 이렇다:
// 1. SQ 헤드/테일을 메모리에서 직접 읽기
unsigned head = __atomic_load_n(sq->khead, __ATOMIC_ACQUIRE);
unsigned tail = sq->sqe_tail;
// 2. 새 SQE 채우기
struct io_uring_sqe *sqe = &sq->sqes[tail & sq->mask];
sqe->opcode = IORING_OP_READ;
sqe->fd = fd;
sqe->addr = (__u64)buf;
sqe->len = len;
sqe->off = offset;
sqe->user_data = (__u64)cookie;
// 3. 테일 증가 (release semantics)
sq->sqe_tail = tail + 1;
__atomic_store_n(sq->ktail, tail + 1, __ATOMIC_RELEASE);
// 4. (SQPOLL이 아니면) io_uring_enter 호출
if (!sqpoll) {
io_uring_enter(ring_fd, 1, 0, 0, NULL);
}
// 5. CQ 폴링
unsigned cq_head = __atomic_load_n(cq->khead, __ATOMIC_ACQUIRE);
unsigned cq_tail = __atomic_load_n(cq->ktail, __ATOMIC_ACQUIRE);
while (cq_head != cq_tail) {
struct io_uring_cqe *cqe = &cq->cqes[cq_head & cq->mask];
handle_completion(cqe);
cq_head++;
}
__atomic_store_n(cq->khead, cq_head, __ATOMIC_RELEASE);
이 흐름에서 syscall이 한 번도 나타나지 않을 수 있다 (SQPOLL + 활발한 트래픽 가정). epoll로는 절대 불가능한 일이다.
4. liburing — 사용자 친화적 래퍼
원시 io_uring API를 직접 다루는 것은 매우 까다롭다. 그래서 Jens Axboe는 동시에 liburing이라는 유저랜드 라이브러리를 만들어 배포한다.
4.1 기본 사용 예제
파일에서 1024바이트를 읽는 코드:
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
struct io_uring ring;
int ret;
// 1. 링 초기화 (SQ entries=8)
ret = io_uring_queue_init(8, &ring, 0);
if (ret < 0) {
fprintf(stderr, "queue_init: %s\n", strerror(-ret));
return 1;
}
int fd = open(argv[1], O_RDONLY);
if (fd < 0) {
perror("open");
return 1;
}
char buf[1024];
// 2. SQE 잡기
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// 3. read 연산 준비
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, "my-cookie");
// 4. 제출
io_uring_submit(&ring);
// 5. 완료 대기
struct io_uring_cqe *cqe;
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "wait_cqe: %s\n", strerror(-ret));
return 1;
}
printf("read returned %d bytes\n", cqe->res);
printf("cookie: %s\n", (char *)io_uring_cqe_get_data(cqe));
// 6. CQE 소비 표시
io_uring_cqe_seen(&ring, cqe);
close(fd);
io_uring_queue_exit(&ring);
return 0;
}
컴파일: cc example.c -luring -o example. 매우 단순하다. liburing이 모든 메모리 매핑과 헤드/테일 관리를 숨겨준다.
4.2 배치 제출과 배치 완료
성능을 끌어올리려면 한 번에 여러 SQE를 채우고 한 번만 submit해야 한다. 그리고 한 번에 여러 CQE를 처리해야 한다.
// 8개의 read 동시 제출
for (int i = 0; i < 8; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], bufs[i], BUF_SIZE, 0);
io_uring_sqe_set_data64(sqe, i);
}
io_uring_submit(&ring); // 단 한 번의 syscall
// 8개의 완료 한 번에 수집
struct io_uring_cqe *cqes[8];
unsigned int count = io_uring_peek_batch_cqe(&ring, cqes, 8);
for (unsigned int i = 0; i < count; i++) {
handle_one(cqes[i]);
}
io_uring_cq_advance(&ring, count); // 한 번에 소비 표시
io_uring_peek_batch_cqe는 syscall 없이 메모리만 읽는다. SQPOLL 모드와 결합하면 핫패스에 syscall이 0개가 된다.
4.3 유용한 prep 함수들
liburing이 제공하는 io_uring_prep_* 함수들 (일부):
io_uring_prep_read(sqe, fd, buf, len, off)— 파일 읽기io_uring_prep_write(sqe, fd, buf, len, off)— 파일 쓰기io_uring_prep_readv(sqe, fd, iov, n, off)— 벡터 읽기io_uring_prep_writev(sqe, fd, iov, n, off)— 벡터 쓰기io_uring_prep_fsync(sqe, fd, flags)— fsyncio_uring_prep_openat(sqe, dfd, path, flags, mode)— 비동기 openio_uring_prep_close(sqe, fd)— 비동기 closeio_uring_prep_statx(sqe, dfd, path, flags, mask, statx_buf)— 비동기 statxio_uring_prep_accept(sqe, sockfd, addr, addrlen, flags)— 비동기 acceptio_uring_prep_connect(sqe, sockfd, addr, addrlen)— 비동기 connectio_uring_prep_send(sqe, sockfd, buf, len, flags)— 소켓 sendio_uring_prep_recv(sqe, sockfd, buf, len, flags)— 소켓 recvio_uring_prep_sendmsg(sqe, sockfd, msg, flags)— sendmsgio_uring_prep_recvmsg(sqe, sockfd, msg, flags)— recvmsgio_uring_prep_shutdown(sqe, sockfd, how)— 소켓 shutdownio_uring_prep_timeout(sqe, ts, count, flags)— 타임아웃io_uring_prep_link_timeout(sqe, ts, flags)— 연결된 SQE의 타임아웃io_uring_prep_poll_add(sqe, fd, mask)— poll 추가io_uring_prep_poll_remove(sqe, user_data)— poll 제거io_uring_prep_cancel64(sqe, user_data, flags)— 진행 중 연산 취소
이 목록은 매년 확장된다. Linux 6.x에서는 IORING_OP_FUTEX_WAIT, IORING_OP_FUTEX_WAKE, IORING_OP_FIXED_FD_INSTALL, IORING_OP_FTRUNCATE 등이 추가되었다.
5. SQPOLL — syscall 없는 비동기
io_uring의 진정한 마법은 IORING_SETUP_SQPOLL 플래그에서 나온다. 이 플래그를 켜면 커널이 전용 스레드를 띄워서 SQ를 폴링한다.
5.1 동작 원리
[유저 프로세스] [커널 SQPOLL 스레드]
|
SQE 채우기 -- SQ에 push |
|
v
while (true) {
if (SQ에 새 SQE 있음) {
처리해서 IO 디스패치
}
if (idle_time 초과) {
sleep 모드 진입
}
}
유저 프로세스는 SQE를 메모리에 쓰기만 하면 된다. io_uring_enter 호출조차 불필요하다. 커널 스레드가 폴링하며 발견하면 알아서 처리한다.
단, 폴링 스레드가 idle 상태로 들어가 있다면 IORING_ENTER_SQ_WAKEUP 플래그로 깨워야 한다. liburing이 자동으로 처리해준다 (IORING_SQ_NEED_WAKEUP 플래그를 보고).
5.2 트레이드오프
SQPOLL의 장점:
- 핫패스에서 syscall 0개 — 가장 빠른 모드.
- 레이턴시 압도적 우위 — 폴링 스레드가 즉시 픽업한다.
단점:
- CPU 1개 통째로 잡아먹음 — 폴링 스레드가 계속 돌아간다 (idle 시간 전까지).
- 권한 필요 — Linux 5.13 미만에서는
CAP_SYS_NICE가 필요했다. 그 이후로는 일반 유저도 사용 가능. - idle 튜닝이 까다로움 —
sq_thread_idle을 너무 짧게 잡으면 자주 스핀업/다운하고, 너무 길게 잡으면 CPU 낭비.
권장 시나리오: 고처리량 인프라 서비스 (DB, 캐시, 프록시). 일반 애플리케이션은 SQPOLL을 켜지 않는 것이 낫다.
5.3 SQ_AFF로 CPU 핀
IORING_SETUP_SQ_AFF를 함께 켜면 SQPOLL 스레드를 특정 CPU에 핀시킬 수 있다:
struct io_uring_params p = {0};
p.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
p.sq_thread_cpu = 3; // CPU 3번에 핀
p.sq_thread_idle = 2000; // 2초 idle 후 sleep
io_uring_queue_init_params(256, &ring, &p);
NUMA 시스템에서는 워커 스레드와 SQPOLL 스레드를 같은 NUMA 노드에 두는 것이 캐시 효율을 높인다.
5.4 ATTACH_WQ로 워커풀 공유
여러 io_uring 인스턴스가 워커풀을 공유할 수도 있다:
// 첫 번째 ring 생성
io_uring_queue_init_params(256, &ring1, &p);
// 두 번째 ring은 첫 번째의 wq를 공유
struct io_uring_params p2 = {0};
p2.flags = IORING_SETUP_ATTACH_WQ;
p2.wq_fd = ring1.ring_fd;
io_uring_queue_init_params(256, &ring2, &p2);
스레드 풀 인스턴스 수를 줄여 메모리/CPU를 절약한다.
6. SQE 체이닝 — 연산을 잇기
IOSQE_IO_LINK 플래그를 SQE에 켜면 다음 SQE와 "체인"으로 묶을 수 있다. 체인 안에서는 앞 연산이 성공해야 다음 연산이 실행된다.
6.1 예제: open → read → close 체인
// 1. open
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_openat(sqe, AT_FDCWD, "file.txt", O_RDONLY, 0);
sqe->flags |= IOSQE_IO_LINK;
sqe->user_data = 1;
// 2. read (open이 성공한 경우만)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, /* fd는 동적으로 지정해야 함 */ -1, buf, 1024, 0);
sqe->flags |= IOSQE_IO_LINK | IOSQE_FIXED_FILE;
sqe->user_data = 2;
// 3. close
sqe = io_uring_get_sqe(&ring);
io_uring_prep_close(sqe, -1);
sqe->user_data = 3;
io_uring_submit(&ring);
문제: read와 close는 open이 만든 fd를 알아야 한다. 이를 위해서는 IOSQE_FIXED_FILE과 file slot 등록이 필요하다. 또는 IORING_OP_OPENAT2의 결과 fd를 file table에 자동 등록하는 IORING_OP_OPENAT2 + IORING_OP_FIXED_FD_INSTALL 패턴이 있다.
6.2 IO_LINK vs IO_HARDLINK
IOSQE_IO_LINK— 앞 연산이 "사용자가 의도한 부분 결과"를 반환하면 (-EAGAIN이나 짧은 read) 체인이 끊긴다.IOSQE_IO_HARDLINK— 짧은 read 등도 무시하고 다음 연산을 실행. 정말로 에러일 때만 끊김.
6.3 LINK_TIMEOUT
각 SQE에 타임아웃을 붙일 수 있다:
// recv
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, sock, buf, BUF_SIZE, 0);
sqe->flags |= IOSQE_IO_LINK;
// 타임아웃 (이 SQE는 앞 SQE에 종속됨)
struct __kernel_timespec ts = { .tv_sec = 5, .tv_nsec = 0 };
sqe = io_uring_get_sqe(&ring);
io_uring_prep_link_timeout(sqe, &ts, 0);
recv가 5초 안에 완료되지 않으면 자동으로 취소되고 -ECANCELED를 반환한다. 이는 epoll로 구현하기 매우 까다로운 패턴이다.
7. 등록 자원 — 핫패스 최적화
매 syscall마다 fd를 lookup하고 reference 카운트를 올리는 것은 비싼 작업이다. io_uring은 자원 등록으로 이를 한 번만 하게 한다.
7.1 등록된 파일 (Fixed Files)
int fds[16];
for (int i = 0; i < 16; i++) {
fds[i] = open(paths[i], O_RDONLY);
}
// 한 번 등록
io_uring_register_files(&ring, fds, 16);
// 이제 SQE에서는 fd 대신 인덱스를 쓰고 IOSQE_FIXED_FILE 플래그를 켠다
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, /* index */ 5, buf, 1024, 0);
sqe->flags |= IOSQE_FIXED_FILE;
내부적으로 커널은 fd lookup을 건너뛰고 등록된 struct file * 포인터를 바로 쓴다. 매 read마다 약 50ns가 절약된다 (벤치마크 기준).
7.2 등록된 버퍼 (Fixed Buffers)
O_DIRECT 같은 zero-copy I/O를 위해서는 사용자 버퍼를 핀(pin)해야 한다. 핀은 비싼 작업이고, 매 I/O마다 핀/언핀하면 처리량이 절반으로 떨어진다.
struct iovec iov[4];
char *bufs[4];
for (int i = 0; i < 4; i++) {
bufs[i] = aligned_alloc(4096, BUF_SIZE);
iov[i].iov_base = bufs[i];
iov[i].iov_len = BUF_SIZE;
}
// 한 번에 등록 (4개 버퍼를 핀)
io_uring_register_buffers(&ring, iov, 4);
// 이제 _fixed 변형을 쓴다
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, bufs[2], BUF_SIZE, 0, /* buf_index */ 2);
NVMe 같은 고속 디바이스에서는 등록 버퍼 사용 여부가 처리량을 50% 이상 좌우한다.
7.3 Provided Buffers (Ring 모드)
IORING_OP_RECV 같은 가변 길이 연산은 미리 버퍼를 정해두기 어렵다. 이를 위해 io_uring은 buffer ring을 제공한다.
// buffer ring 등록
struct io_uring_buf_ring *br;
int ret = io_uring_register_buf_ring(&ring, &(struct io_uring_buf_reg){
.ring_addr = (unsigned long)br,
.ring_entries = 256,
.bgid = 0, // buffer group ID
}, 0);
// 버퍼들을 ring에 추가
for (int i = 0; i < 256; i++) {
io_uring_buf_ring_add(br, my_bufs[i], BUF_SIZE, i, 255, i);
}
io_uring_buf_ring_advance(br, 256);
// recv SQE에서 buffer group ID 지정
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, sock, NULL, BUF_SIZE, 0);
sqe->buf_group = 0;
sqe->flags |= IOSQE_BUFFER_SELECT;
커널이 자동으로 ring에서 빈 버퍼를 골라 recv에 쓰고, CQE의 flags에 어떤 버퍼가 쓰였는지 알려준다 (IORING_CQE_F_BUFFER).
8. 멀티샷 연산 — 한 번 제출, 여러 번 완료
기본적으로 SQE는 일회용이다. 하나의 SQE → 하나의 CQE. 그러나 멀티샷(multishot) 연산은 한 번 제출하면 여러 CQE를 발생시킨다.
8.1 IORING_OP_ACCEPT 멀티샷
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_sock, NULL, NULL, 0);
io_uring_sqe_set_data64(sqe, ACCEPT_TOKEN);
io_uring_submit(&ring);
// 이제 새 연결마다 CQE가 자동 발생
while (true) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (io_uring_cqe_get_data64(cqe) == ACCEPT_TOKEN) {
int new_sock = cqe->res;
// ... handle new connection ...
// CQE가 더 올 수 있으면 IORING_CQE_F_MORE가 켜져 있음
if (!(cqe->flags & IORING_CQE_F_MORE)) {
// 멀티샷이 끝났으므로 재등록 필요
re_register_accept(&ring);
}
}
io_uring_cqe_seen(&ring, cqe);
}
이전에는 새 연결마다 SQE를 다시 채워야 했다. 멀티샷은 이를 한 번으로 줄인다. epoll의 EPOLLET + accept4 루프와 비슷하지만 더 효율적이다.
8.2 IORING_OP_RECV 멀티샷
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, sock, NULL, 0, 0);
sqe->buf_group = my_buf_group;
sqe->flags |= IOSQE_BUFFER_SELECT;
데이터가 들어올 때마다 CQE가 발생하고, 자동으로 buffer ring에서 버퍼가 할당된다. 클라이언트가 연결을 끊거나 에러가 나면 멀티샷이 종료되고 IORING_CQE_F_MORE가 꺼진다.
8.3 IORING_OP_POLL 멀티샷
sqe = io_uring_get_sqe(&ring);
io_uring_prep_poll_multishot(sqe, fd, POLLIN);
poll 이벤트가 발생할 때마다 CQE를 발생시킨다. epoll의 EPOLLET과 동일한 의미론.
★ Insight ─────────────────────────────────────
- 멀티샷의 본질: 멀티샷은 "이 SQE는 한 번만 소진되지 않는다"는 표시이다. 같은 슬롯이 재사용되며, 커널은 끝날 때까지 해당 SQE의 메타데이터를 들고 있다.
IORING_CQE_F_MORE의 역할: "이 CQE가 마지막은 아니다"라는 신호. 이게 꺼지면 멀티샷이 종료된 것이고, 사용자는 다시 SQE를 제출해야 한다.- 버퍼 부족 시 동작: provided buffer가 다 떨어지면 CQE가
-ENOBUFS로 돌아오고, 이때도IORING_CQE_F_MORE가 꺼질 수 있다. 멀티샷을 다시 등록하고 버퍼를 다시 채워 넣어야 한다.─────────────────────────────────────────────────
9. 폴링 모드 — IORING_SETUP_IOPOLL
매우 빠른 디바이스 (NVMe, persistent memory)에서는 인터럽트보다 폴링이 더 빠를 수 있다. 인터럽트 핸들러의 컨텍스트 스위치 비용이 디바이스의 처리 시간보다 클 수 있기 때문이다.
struct io_uring_params p = {0};
p.flags = IORING_SETUP_IOPOLL;
io_uring_queue_init_params(256, &ring, &p);
이 모드에서는 디바이스 드라이버가 인터럽트를 발생시키지 않고, io_uring이 직접 디바이스를 폴링한다. 단, 다음 제약이 있다:
- 모든 fd가
O_DIRECT로 열려 있어야 함 (페이지 캐시 우회). - 디바이스가 폴링 모드를 지원해야 함 (NVMe는 OK, SATA는 보통 안 됨).
- 폴링하는 동안 CPU를 잡아먹음.
전형적인 사용처: 데이터베이스 스토리지 엔진 (RocksDB, ScyllaDB), 저지연 캐시 (DragonflyDB), 벤치마크 도구 (fio).
10. NVMe Passthrough — IORING_OP_URING_CMD
Linux 5.19에서 io_uring은 한 단계 더 나아갔다. IORING_OP_URING_CMD라는 일반 명령 채널을 통해 NVMe 디바이스에 직접 명령을 보낼 수 있다.
10.1 동기
블록 레이어를 우회하면 NVMe의 모든 기능 — 예약, 압축, 암호화, 메타데이터 — 을 사용자 공간에서 직접 쓸 수 있다. SPDK가 보여준 효율을 커널 위에서 재현하는 셈이다.
10.2 사용 예제 (개념 코드)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_uring_cmd(sqe, /* fd= */ nvme_chr_fd, /* op= */ NVME_URING_CMD_IO);
// SQE의 cmd 영역에 NVMe 명령 작성
struct nvme_uring_cmd *cmd = (struct nvme_uring_cmd *)sqe->cmd;
cmd->opcode = nvme_cmd_read;
cmd->nsid = ns_id;
cmd->addr = (uint64_t)data_buffer;
cmd->data_len = block_size * num_blocks;
cmd->cdw10 = lba & 0xffffffff;
cmd->cdw11 = lba >> 32;
cmd->cdw12 = num_blocks - 1;
io_uring + NVMe passthrough는 SPDK 수준의 처리량을 일반 애플리케이션에서 도달 가능하게 만든다. 단, 보안 격리는 여전히 커널이 책임지므로 안전성도 보장된다.
11. 보안 — IORING_SETUP_R_DISABLED와 Restrictions
io_uring은 강력한 만큼 보안 표면도 넓다. 2022년부터 여러 CVE가 발견되었다:
- CVE-2022-29582 — io_uring timeout race condition.
- CVE-2023-0468 — io_uring poll race condition.
- CVE-2023-2008 — io_uring fixed buffer 처리 결함.
- CVE-2024-1086 — netfilter 관련 (io_uring과 직접 관련은 아님).
이 때문에 일부 클라우드 사업자는 io_uring을 컨테이너에서 비활성화하기도 한다. Google COS (Container-Optimized OS)는 한동안 io_uring을 끄는 정책을 유지했다.
11.1 Restrictions API
IORING_REGISTER_RESTRICTIONS로 어떤 연산만 허용할지 지정할 수 있다:
struct io_uring_params p = {0};
p.flags = IORING_SETUP_R_DISABLED; // 처음에는 비활성화
io_uring_queue_init_params(256, &ring, &p);
// 허용 목록 등록
struct io_uring_restriction reg[3];
reg[0].opcode = IORING_RESTRICTION_REGISTER_OP;
reg[0].register_op = IORING_REGISTER_FILES;
reg[1].opcode = IORING_RESTRICTION_SQE_OP;
reg[1].sqe_op = IORING_OP_READ;
reg[2].opcode = IORING_RESTRICTION_SQE_OP;
reg[2].sqe_op = IORING_OP_WRITE;
io_uring_register_restrictions(&ring, reg, 3);
// 활성화
io_uring_enable_rings(&ring);
이제 이 ring은 read와 write만 처리한다. 다른 연산은 모두 거부된다. seccomp와 비슷한 모델로 ring 단위 권한 분리를 가능케 한다.
11.2 io_uring과 seccomp
흥미롭게도 io_uring 자체는 seccomp 필터를 우회할 수 있다 — io_uring이 호출하는 내부 syscall들은 사용자 공간에서 발생하지 않기 때문이다. 이 때문에 일부 환경에서는 io_uring을 통째로 막거나, IORING_RESTRICTION으로 화이트리스트를 강제한다.
Linux 5.16부터 io_uring_setup이 새로운 LSM 후킹 포인트로 들어가서, SELinux/AppArmor가 ring 생성을 제어할 수 있다.
12. 호환성 매트릭스 — 어떤 커널에서 무엇을 쓸 수 있나
io_uring은 매 릴리스마다 새 기능이 들어간다. 주요 이정표:
| 커널 | 추가된 기능 |
|---|---|
| 5.1 | io_uring 기본 도입 (read/write/fsync/nop) |
| 5.2 | 더 많은 opcode (timeout, accept, connect 등) |
| 5.4 | sendmsg/recvmsg, IORING_OP_POLL |
| 5.5 | IORING_FEAT_FAST_POLL (epoll보다 빠른 폴링) |
| 5.6 | IORING_OP_OPENAT, READ/WRITE 향상 |
| 5.7 | provided buffers, IORING_OP_PROVIDE_BUFFERS |
| 5.10 | IORING_SETUP_SQPOLL이 일반 유저에게 개방 |
| 5.11 | IORING_OP_SHUTDOWN, FILES_UPDATE |
| 5.12 | IORING_OP_RENAMEAT, UNLINKAT |
| 5.13 | IORING_OP_MKDIRAT, SYMLINKAT, LINKAT |
| 5.15 | IORING_REGISTER_IOWQ_AFF (workqueue affinity) |
| 5.18 | IORING_SETUP_SUBMIT_ALL, COOP_TASKRUN, TASKRUN_FLAG |
| 5.19 | IORING_OP_URING_CMD (NVMe passthrough), big SQE/CQE |
| 6.0 | IORING_SETUP_SINGLE_ISSUER, DEFER_TASKRUN |
| 6.1 | multishot accept/recv, IORING_REGISTER_PBUF_RING |
| 6.4 | IORING_OP_FUTEX_WAIT/WAKE/WAITV |
| 6.6 | IORING_OP_READV_FIXED, WRITEV_FIXED |
| 6.7 | IORING_OP_FIXED_FD_INSTALL, FTRUNCATE |
| 6.10 | NAPI 통합 향상, send zero-copy |
| 6.13 | epoll wait via io_uring |
liburing은 런타임에 어떤 기능이 사용 가능한지 IORING_REGISTER_PROBE로 조회할 수 있다. 이를 활용해 fallback 경로를 짜는 것이 권장된다.
13. 실전 벤치마크 — io_uring vs epoll vs AIO
벤치마크 결과는 워크로드에 크게 의존하지만, 일반적인 경향을 정리하면:
13.1 작은 랜덤 read (NVMe, 4KB blocks)
| 방식 | IOPS | CPU% | 비고 |
|---|---|---|---|
| pread (동기) | ~150K | 100% | 단일 스레드 |
| Linux AIO | ~600K | 100% | 4스레드 |
| epoll + 스레드풀 | ~500K | 100% | 4스레드 |
| io_uring (기본) | ~900K | 100% | 단일 스레드 |
| io_uring + SQPOLL | ~1.1M | 100% (+ 1 SQPOLL) | 단일 스레드 + SQPOLL |
| io_uring + IOPOLL | ~1.3M | 100% (+ 1 IOPOLL) | 폴링 모드 |
| io_uring + URING_CMD | ~1.5M | 100% (+ 1 IOPOLL) | NVMe passthrough |
(숫자는 fio로 측정한 대략적인 값. 실제 결과는 디바이스, CPU, 커널 버전에 따라 다르다.)
13.2 네트워크 echo 서버
| 방식 | RPS | p99 latency |
|---|---|---|
| epoll + send/recv | ~1.2M | ~150 us |
| epoll + sendmsg/recvmsg | ~1.0M | ~180 us |
| io_uring (기본) | ~1.3M | ~120 us |
| io_uring + multishot recv | ~1.5M | ~90 us |
| io_uring + multishot + zerocopy | ~1.7M | ~70 us |
io_uring의 우위는 batching이 자연스럽고, syscall이 줄어들며, CPU 캐시 친화적이라는 점에서 나온다.
13.3 유의점
- epoll보다 항상 빠른 것은 아니다 — 매우 가벼운 워크로드 (요청당 1us 이하)에서는 io_uring의 셋업 오버헤드 때문에 epoll이 더 빠를 수 있다.
- 워밍업이 필요 — 첫 수천 개의 요청은 캐시 미스와 인라이닝 부재로 느리다.
- SQPOLL은 양날의 검 — 항상 100% CPU를 잡아먹기 때문에 idle이 많은 워크로드에는 부적합하다.
14. 사례 연구 1: ScyllaDB와 Seastar
ScyllaDB는 처음부터 io_uring을 전제로 만들어진 NoSQL 데이터베이스다. 정확히 말하면 Seastar라는 비동기 프레임워크 위에 얹혀 있는데, Seastar는 io_uring 이전에 자체적인 AIO/eventfd 구현을 가지고 있었다. 이후 io_uring 백엔드가 추가되었다.
14.1 Seastar의 비동기 모델
- Shared-nothing: CPU 코어당 하나의 스레드가 자기 데이터만 관리. 락이 없다.
- Reactor 패턴: 각 코어의 스레드는 영구 루프를 돌며 io_uring CQE를 처리한다.
- Future/Promise: C++의 future 모델로 비동기 합성을 자연스럽게 표현한다.
- Polling: 디스크와 네트워크 모두 폴링 모드 (
IORING_SETUP_IOPOLL).
ScyllaDB의 벤치마크는 동일 하드웨어에서 Cassandra의 10배 처리량을 달성했다고 알려져 있다. 이 차이의 상당 부분이 io_uring 기반 비동기 I/O에서 나온다.
14.2 Seastar의 디스크 큐
Seastar는 io_uring 위에 자체 우선순위 큐를 둔다. SSD의 큐 깊이를 동적으로 조정하면서 read와 write의 비율을 맞춘다 (compaction과 user-facing read의 충돌을 방지).
15. 사례 연구 2: RocksDB의 IOUringOptions
RocksDB 7.x부터 io_uring 백엔드가 들어갔다. RocksDBOptions에서 명시적으로 켜야 한다:
rocksdb::Options options;
options.use_direct_reads = true;
options.use_direct_io_for_flush_and_compaction = true;
// IOUring 활성화
auto io_uring_env = rocksdb::NewIOUringEnvWrapper(rocksdb::Env::Default());
options.env = io_uring_env.get();
RocksDB는 io_uring을 MultiGet 경로에 사용한다. 한 번에 여러 키를 조회할 때, 각 키가 다른 SST 파일에 있을 수 있고, 디스크 read가 병렬로 일어나면 큰 이득이 있다.
벤치마크: 100개 키의 MultiGet에서 epoll 기반 대비 3-4배 처리량 향상.
16. 사례 연구 3: Nginx의 io_uring 모듈 (실험적)
Nginx 1.25에서 io_uring 모듈이 실험적으로 머지되었다 (--with-file-aio --with-aio_io_uring). 주로 정적 파일 서빙 경로에 적용된다.
http {
aio io_uring;
aio_write on;
server {
listen 80;
location / {
root /var/www/html;
sendfile on;
}
}
}
작은 정적 파일 서빙에서 약 15-20% 처리량 향상이 보고되었다. 그러나 큰 파일이나 동적 콘텐츠에서는 차이가 적다 — 이미 sendfile + epoll로 충분히 빠르기 때문이다.
17. 사례 연구 4: Cloudflare Pingora
Cloudflare가 2022년 발표한 Rust 기반 프록시 Pingora는 Tokio + io_uring을 부분적으로 사용한다. 모든 워크로드가 io_uring으로 가는 것은 아니고, 디스크 I/O와 일부 NVMe 캐시 경로에 사용된다.
Pingora 팀의 발표에 따르면, io_uring 도입으로:
- 정적 파일 캐시 적중 시 latency p99 30% 감소
- CPU 사용률 15% 감소
자세한 내용은 Nginx 내부 구조 글의 마지막 절을 참고하라.
18. Rust 생태계 — tokio-uring, monoio, glommio
io_uring의 부상과 함께 Rust 비동기 생태계도 다양해졌다.
18.1 tokio-uring
Tokio 위에 얹은 io_uring 어댑터. 기존 Tokio 생태계와 호환되면서 핵심 I/O 경로에 io_uring을 쓴다.
use tokio_uring::fs::File;
fn main() {
tokio_uring::start(async {
let file = File::open("hello.txt").await.unwrap();
let buf = vec![0; 4096];
let (res, buf) = file.read_at(buf, 0).await;
let n = res.unwrap();
println!("read {} bytes: {:?}", n, &buf[..n]);
file.close().await.unwrap();
});
}
특이점: 버퍼를 함수가 "소유"한다 (move). io_uring은 비동기로 진행되므로 콜러가 버퍼를 살아 있게 보장해야 하기 때문이다. 완료 시 버퍼가 결과와 함께 돌아온다.
18.2 monoio — Bytedance의 thread-per-core 런타임
#[monoio::main]
async fn main() {
let listener = monoio::net::TcpListener::bind("127.0.0.1:8080").unwrap();
loop {
let (mut stream, _) = listener.accept().await.unwrap();
monoio::spawn(async move {
let mut buf = vec![0; 1024];
let (res, buf) = stream.read(buf).await;
let n = res.unwrap();
stream.write_all(&buf[..n]).await.0.unwrap();
});
}
}
특징:
- 처음부터 io_uring 우선 (epoll fallback 있음)
- Thread-per-core 모델 (Tokio처럼 work-stealing 안 함)
- TLS, HTTP, gRPC 지원
monoio는 Bytedance 내부에서 ByteDance Volc Engine 등의 인프라에 쓰인다.
18.3 glommio — DataDog의 thread-per-core 런타임
use glommio::prelude::*;
fn main() {
LocalExecutorBuilder::default()
.spawn(|| async {
let file = File::open("hello.txt").await.unwrap();
// ...
}).unwrap().join().unwrap();
}
특징:
- io_uring 전용 (epoll fallback 없음)
- DataDog의 데이터 파이프라인에 사용
- DMA file API (
DmaFile) 지원 — direct I/O와 io_uring 조합
18.4 비교
| 런타임 | 멀티스레드 모델 | io_uring 지원 | 주요 사용처 |
|---|---|---|---|
| Tokio (기본) | Work-stealing | epoll only | 일반 |
| tokio-uring | Single-thread | uring | 디스크 무거운 워크로드 |
| monoio | Thread-per-core | uring (epoll fallback) | 고성능 인프라 |
| glommio | Thread-per-core | uring only | 데이터 파이프라인 |
| compio | Cross-platform | uring + IOCP + kqueue | 크로스플랫폼 |
19. 실전 패턴 — io_uring 기반 echo 서버 (C)
전체 작동하는 echo 서버 예제. liburing 사용:
#include <liburing.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define BUF_SIZE 8192
#define MAX_CONN 1024
enum {
EVENT_ACCEPT,
EVENT_READ,
EVENT_WRITE,
};
struct conn_info {
int fd;
int type;
};
struct conn_info conns[MAX_CONN];
static int setup_listening_socket(int port) {
int sock = socket(AF_INET, SOCK_STREAM, 0);
int yes = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
if (bind(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
if (listen(sock, 64) < 0) {
perror("listen");
exit(1);
}
return sock;
}
static void add_accept(struct io_uring *ring, int sock) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_multishot_accept(sqe, sock, NULL, NULL, 0);
conns[sock].fd = sock;
conns[sock].type = EVENT_ACCEPT;
io_uring_sqe_set_data(sqe, &conns[sock]);
}
static void add_read(struct io_uring *ring, int client_fd, char *buf) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, client_fd, buf, BUF_SIZE, 0);
conns[client_fd].fd = client_fd;
conns[client_fd].type = EVENT_READ;
io_uring_sqe_set_data(sqe, &conns[client_fd]);
}
static void add_write(struct io_uring *ring, int client_fd, char *buf, int len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_send(sqe, client_fd, buf, len, 0);
conns[client_fd].fd = client_fd;
conns[client_fd].type = EVENT_WRITE;
io_uring_sqe_set_data(sqe, &conns[client_fd]);
}
int main(int argc, char *argv[]) {
int port = (argc > 1) ? atoi(argv[1]) : 8080;
int listen_sock = setup_listening_socket(port);
struct io_uring ring;
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SINGLE_ISSUER | IORING_SETUP_DEFER_TASKRUN;
if (io_uring_queue_init_params(256, &ring, ¶ms) < 0) {
perror("queue_init");
return 1;
}
add_accept(&ring, listen_sock);
io_uring_submit(&ring);
static char bufs[MAX_CONN][BUF_SIZE];
while (1) {
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
perror("wait_cqe");
return 1;
}
struct conn_info *info = io_uring_cqe_get_data(cqe);
int res = cqe->res;
if (res < 0) {
fprintf(stderr, "cqe error: %s\n", strerror(-res));
io_uring_cqe_seen(&ring, cqe);
continue;
}
switch (info->type) {
case EVENT_ACCEPT: {
int new_fd = res;
add_read(&ring, new_fd, bufs[new_fd]);
// 멀티샷이 끝났으면 재등록
if (!(cqe->flags & IORING_CQE_F_MORE)) {
add_accept(&ring, listen_sock);
}
break;
}
case EVENT_READ: {
if (res == 0) {
close(info->fd);
} else {
add_write(&ring, info->fd, bufs[info->fd], res);
}
break;
}
case EVENT_WRITE: {
add_read(&ring, info->fd, bufs[info->fd]);
break;
}
}
io_uring_cqe_seen(&ring, cqe);
io_uring_submit(&ring);
}
io_uring_queue_exit(&ring);
return 0;
}
빌드: cc echo.c -luring -o echo. 실행: ./echo 8080. nc localhost 8080으로 테스트.
이 코드의 핵심:
- 멀티샷 accept 한 번 등록으로 모든 신규 연결을 받는다.
- read/write 토글 — read 후에 write, write 후에 다시 read. 무한 핑퐁.
- single issuer + defer taskrun — 단일 스레드 최적화 플래그 활용.
- 에러는 res < 0으로 일관 처리.
epoll 버전과 비교하면 라인 수가 비슷하지만, syscall 수가 압도적으로 적다.
20. 디버깅과 관찰성
20.1 strace는 거의 쓸모없다
io_uring의 핵심은 syscall이 없다는 것이다. 그래서 strace로는 거의 아무것도 안 보인다. 보이는 것은 io_uring_setup, io_uring_register, 그리고 가끔 io_uring_enter 정도.
20.2 perf trace로 io_uring 이벤트
perf trace -e 'io_uring:*' -p $(pidof your-app)
io_uring 트레이스포인트가 활성화되어 있어야 한다 (CONFIG_IO_URING_TRACEPOINTS=y).
20.3 bpftrace로 SQE/CQE 카운팅
bpftrace -e '
tracepoint:io_uring:io_uring_submit_sqe { @submitted[comm] = count(); }
tracepoint:io_uring:io_uring_complete { @completed[comm] = count(); }
'
20.4 io_uring-fio로 워크로드 시뮬레이션
fio --name=randread --ioengine=io_uring --rw=randread \
--bs=4k --size=1G --numjobs=4 --iodepth=64 \
--sqthread_poll --hipri --filename=/dev/nvme0n1
--sqthread_poll은 SQPOLL, --hipri는 IOPOLL 모드를 켠다.
20.5 라이브 통계 — io_uring stats
/proc/$PID/fdinfo/$RING_FD에 io_uring의 라이브 통계가 노출된다:
SqMask: 0x1f
SqHead: 1024
SqTail: 1024
CqMask: 0x3f
CqHead: 1024
CqTail: 1024
SqThread: 12345
SqThreadCpu: 3
21. 성능 함정과 안티패턴
21.1 각 요청마다 ring 생성
io_uring 셋업은 비싸다 (mmap, 워커 스레드 생성). 절대 요청당 ring을 만들면 안 된다. 스레드당 한 개, 또는 코어당 한 개로 재사용해야 한다.
21.2 CQE를 늦게 소비
CQ가 가득 차면 새 SQE 처리가 멈춘다 (-EBUSY 또는 overflow). 매 루프마다 CQE를 가능한 빨리 소비해야 한다. liburing의 io_uring_for_each_cqe 매크로가 편하다.
21.3 SQE 채우는 도중에 buf을 free
io_uring은 비동기다. SQE를 제출한 후 완료 CQE가 오기 전에 버퍼를 해제하면 use-after-free가 된다. Rust의 소유권 시스템이 이를 자연스럽게 막는 것이 tokio-uring/monoio의 핵심 안전장치다.
21.4 IORING_OP_POLL을 epoll 대용으로
io_uring 5.4 시절에는 IORING_OP_POLL을 통해 epoll을 대체하는 경우가 많았다. 그러나 이는 io_uring의 최적화를 활용하지 못한다. 직접 IORING_OP_RECV 같은 데이터 연산을 쓰는 것이 항상 더 빠르다.
21.5 너무 깊은 큐
큐를 무한정 키우면 메모리만 낭비된다. 일반적으로 SQ/CQ는 256-1024 정도가 적절. 너무 깊으면 latency도 늘어난다.
22. io_uring과 다른 OS 비교
22.1 Windows IOCP (I/O Completion Ports)
Windows의 IOCP는 1990년대부터 있었다. 모델은 비슷하다 — 비동기 시작, 완료 알림. 그러나 IOCP는 시작 자체가 syscall이고, 완료도 syscall (GetQueuedCompletionStatus)이다. io_uring처럼 syscall-free 핫패스는 없다.
Windows 11 Build 22H2부터는 IoRing이라는 새 API가 들어갔다. 이름에서 알 수 있듯이 io_uring을 본땄다 — 공유 메모리 링 버퍼 모델이다.
22.2 macOS kqueue + dispatch
macOS는 kqueue (FreeBSD에서 가져옴) + Grand Central Dispatch (GCD) 조합을 쓴다. kqueue는 epoll과 비슷한 readiness 모델이고, GCD는 사용자 공간 스레드풀이다. io_uring 같은 통합 비동기 인터페이스는 없다.
22.3 FreeBSD aio + kqueue
FreeBSD는 정통 POSIX AIO를 잘 구현한 거의 유일한 OS다. 단, 인터페이스는 여전히 syscall 기반이고 io_uring 같은 메모리 공유는 없다.
22.4 io_uring이 다른 점
- OS와 사용자가 같은 메모리를 본다 — 핫패스에 syscall이 0개일 수 있다.
- 모든 I/O가 통합 — 파일, 소켓, 메타데이터, 디바이스 명령을 한 API로.
- 체이닝과 멀티샷 — 한 번의 제출로 복잡한 워크플로우 표현.
- 자원 등록 — 핀/언핀 비용 제거.
이 조합은 다른 어떤 OS에도 없다.
23. 향후 — io_uring의 로드맵
23.1 Zero-copy
IORING_OP_SEND_ZC, IORING_OP_SENDMSG_ZC가 6.0에 들어갔다. 데이터를 커널이 복사하지 않고 NIC가 DMA로 직접 가져간다. recv 쪽 zero-copy도 작업 중.
23.2 RDMA 통합
InfiniBand/RoCE 같은 RDMA를 io_uring으로 통합하려는 작업이 진행 중이다. 사용자 공간 RDMA libfabric과의 통합을 목표로 한다.
23.3 io_uring을 통한 syscall 일반화
장기적으로 io_uring을 "비동기 syscall을 위한 일반 인터페이스"로 확장하려는 논의가 있다. getdents, epoll_wait, wait4 같은 것들도 io_uring으로 호출할 수 있게 만드는 작업이 점진적으로 진행 중이다.
23.4 io_uring과 BPF
IORING_OP_URING_CMD를 통해 BPF 프로그램을 호출할 수 있게 하는 패치가 논의되었다. 이는 io_uring을 통한 사용자 정의 비동기 처리 — 예를 들어 사용자 공간에서 정의한 패킷 필터링 — 를 가능케 할 수 있다.
24. 결론 — io_uring을 채택해야 하는가
24.1 io_uring을 써야 하는 경우
- 고처리량 디스크 워크로드 — DB 엔진, 캐시, 스토리지 시스템.
- 고처리량 네트워크 워크로드 — 프록시, 로드 밸런서, CDN.
- NVMe를 직접 다루는 인프라 — passthrough가 큰 가치.
- Linux 5.10+ 환경 — 안정적으로 사용할 수 있는 최소 버전.
- 새 시스템을 처음부터 설계 — io_uring의 모델에 맞춰 설계할 수 있다.
24.2 io_uring을 쓰지 않아도 되는 경우
- 요청당 1us 이하의 극단적 마이크로 워크로드 — epoll이 더 빠를 수 있다.
- 크로스 플랫폼이 중요 — Windows/macOS 호환이 필요하면 더 느슨한 추상화가 낫다.
- 컨테이너 환경에서 io_uring이 막혀 있음 — 일부 클라우드는 보안 정책으로 비활성화.
- 레거시 코드 베이스 — 비동기 모델 변경의 비용이 큼.
24.3 마지막으로
io_uring은 단순한 새 syscall이 아니다. 이는 Linux가 비동기 I/O를 다루는 방식의 패러다임 전환이다. epoll이 1990년대 후반의 select/poll 한계를 넘었듯이, io_uring은 2010년대 후반의 epoll 한계를 넘는다.
앞으로 5-10년 동안 Linux 인프라 소프트웨어의 성능 차이는 io_uring을 얼마나 잘 활용하느냐에서 나올 것이다. 새로운 데이터베이스, 새로운 프록시, 새로운 런타임은 모두 io_uring을 전제로 설계되고 있다. 이미 출시된 소프트웨어도 점진적으로 io_uring 백엔드를 추가하고 있다.
io_uring을 배우는 것은 더 이상 선택이 아니다. 인프라를 다루는 엔지니어에게는 필수 교양이 되어가고 있다.
부록 A — 참고 자료
- Efficient IO with io_uring (Jens Axboe, 2019) — io_uring 원저자의 최초 백서.
- The Linux Kernel: io_uring documentation — 공식 커널 문서.
- liburing GitHub repository — Jens Axboe가 직접 관리하는 라이브러리.
- Lord of the io_uring — Shuveb Hussain의 가이드, 입문에 좋다.
- io_uring by example (Shuveb Hussain) — 단계별 튜토리얼.
- tokio-uring — Tokio + io_uring 연결.
- monoio — Bytedance의 thread-per-core 런타임.
- glommio — DataDog의 thread-per-core 런타임.
- ScyllaDB Architecture — Seastar/io_uring 활용 사례.
- Cloudflare Pingora 발표 — Rust + io_uring 프록시.
부록 B — 자주 묻는 질문
Q: io_uring과 AIO 중 무엇을 써야 하나? A: 새 코드라면 무조건 io_uring. AIO는 사실상 deprecated에 가깝다.
Q: io_uring은 이미 안정적인가? A: 5.10 이후로는 매우 안정적. 5.6 이전은 권장하지 않는다.
Q: SQPOLL은 항상 켜야 하나? A: 아니다. CPU를 통째로 잡아먹기 때문에 idle이 적은 워크로드에만 적합.
Q: io_uring에 보안 위험이 있나? A: 과거에는 몇 개의 CVE가 있었다. 최신 커널에서는 해결되었지만, 컨테이너 환경에서는 여전히 막아두는 곳이 있다.
Q: 동기 코드를 점진적으로 비동기화할 수 있나? A: 가능하지만 어렵다. 비동기는 전염성이 있어서 한 곳을 비동기화하면 호출 체인 전체가 비동기가 되어야 한다. tokio-uring 같은 어댑터가 이 과정을 도와준다.
Q: io_uring과 epoll을 같이 쓸 수 있나?
A: 가능하다. IORING_OP_POLL_ADD로 ring이 epoll fd를 모니터링하게 할 수도 있고, 그냥 두 인터페이스를 같은 프로세스에서 같이 써도 된다.
Q: macOS에서 io_uring 비슷한 것이 있나? A: 없다. dispatch와 kqueue가 가장 유사하지만 syscall-free는 아니다.
Q: io_uring을 컨테이너에서 쓸 수 있나? A: 대부분의 경우 가능하다. 단, Google COS나 일부 보안 강화 환경에서는 막혀 있을 수 있다. seccomp 정책을 확인하라.
부록 C — 미니 용어집
- SQ: Submission Queue. 사용자가 요청을 제출하는 링.
- CQ: Completion Queue. 커널이 결과를 알리는 링.
- SQE: Submission Queue Entry. 64바이트 요청 구조체.
- CQE: Completion Queue Entry. 16(또는 32)바이트 결과 구조체.
- SQPOLL: Submission Queue Polling. 커널 스레드가 SQ를 폴링하는 모드.
- IOPOLL: I/O Polling. 디바이스 인터럽트 대신 폴링하는 모드.
- Multishot: 한 SQE가 여러 CQE를 발생시키는 연산.
- Fixed File: 미리 등록된 파일 디스크립터 (
IOSQE_FIXED_FILE). - Fixed Buffer: 미리 핀된 사용자 버퍼.
- Provided Buffer Ring: 자동 버퍼 선택을 위한 사용자 버퍼 풀.
- URING_CMD: 일반 명령 채널 (NVMe passthrough 등에 사용).
- liburing: io_uring을 쓰기 쉽게 해주는 유저랜드 라이브러리.
- io_wq: io_uring의 내부 워커 스레드 풀.
이전 글 Nginx 내부 구조 딥다이브와 함께 읽으면, "epoll 시대의 정점"과 "그 다음에 오는 것"의 풍경을 이어 볼 수 있을 것이다.