Skip to content

필사 모드: io_uring 완벽 가이드 — Linux 비동기 I/O의 새로운 표준: SQ/CQ 링 버퍼, SQPOLL, 멀티샷, 제로카피 (2025)

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며 — 왜 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 내부 구조 딥다이브](./2026-04-15-nginx-internals-event-loop-master-worker-reverse-proxy-deep-dive-guide-2025)에서는 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에는 치명적인 한계가 있었다:

1. **O_DIRECT만 지원** — 버퍼드 I/O (페이지 캐시 경유)는 호출이 블로킹되었다. 즉, 일반 파일 읽기에는 사실상 쓸 수 없었다.

2. **소켓 미지원** — 네트워크 I/O에는 적용 불가.

3. **메타데이터 연산 미지원** — `fsync`, `stat`, `open`, `close` 등은 모두 동기 호출로 남았다.

4. **API 디자인 결함** — `iocb` 구조체가 너무 무거웠고, 복잡했으며, 확장성이 떨어졌다.

Jens Axboe는 2019년 LSFMM 컨퍼런스 발표에서 이렇게 말했다 (의역): "Linux AIO는 만들지 말았어야 했다. 우리는 처음부터 다시 해야 한다."

1.3 epoll의 한계

한편 네트워크 I/O 쪽에서는 epoll이 표준이 되어 있었다. epoll은 "준비됨 알림(readiness notification)" 모델을 사용한다 — 커널이 "이 fd에서 읽을 데이터가 있다"고 알려주면, 사용자 코드가 직접 `read(2)`를 호출한다. 이 모델은 두 가지 약점이 있다:

1. **시스템 콜 오버헤드** — 알림(`epoll_wait`) + 실제 I/O(`read`) 두 번의 syscall이 필요하다. Spectre/Meltdown 이후 syscall 비용이 더 비싸졌다.

2. **버퍼 관리 부담** — 사용자가 직접 버퍼를 잡아야 하고, 짧은 `recv` 결과를 처리해야 한다.

3. **파일 I/O에 부적합** — 디스크 fd는 항상 "준비됨" 상태이므로 epoll로 비동기화할 수 없다.

io_uring은 이 두 가지 문제 — Linux AIO의 제약과 epoll의 비효율 — 를 동시에 해결하기 위해 설계되었다.

1.4 io_uring의 탄생

2019년 1월, Jens Axboe는 LKML에 첫 번째 io_uring 패치를 게시했다. 핵심 아이디어는 다음과 같았다:

1. **공유 메모리 링 버퍼**로 syscall 오버헤드를 제거한다. 사용자와 커널이 같은 메모리를 본다.

2. **두 개의 링** — 제출 큐(SQ)와 완료 큐(CQ) — 으로 양방향 통신을 한다.

3. **모든 I/O 연산** — 파일, 소켓, 메타데이터, 심지어 NVMe passthrough까지 — 을 통합 인터페이스로 처리한다.

4. **버퍼드 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)` — fsync

- `io_uring_prep_openat(sqe, dfd, path, flags, mode)` — 비동기 open

- `io_uring_prep_close(sqe, fd)` — 비동기 close

- `io_uring_prep_statx(sqe, dfd, path, flags, mask, statx_buf)` — 비동기 statx

- `io_uring_prep_accept(sqe, sockfd, addr, addrlen, flags)` — 비동기 accept

- `io_uring_prep_connect(sqe, sockfd, addr, addrlen)` — 비동기 connect

- `io_uring_prep_send(sqe, sockfd, buf, len, flags)` — 소켓 send

- `io_uring_prep_recv(sqe, sockfd, buf, len, flags)` — 소켓 recv

- `io_uring_prep_sendmsg(sqe, sockfd, msg, flags)` — sendmsg

- `io_uring_prep_recvmsg(sqe, sockfd, msg, flags)` — recvmsg

- `io_uring_prep_shutdown(sqe, sockfd, how)` — 소켓 shutdown

- `io_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 내부 구조 글](./2026-04-15-nginx-internals-event-loop-master-worker-reverse-proxy-deep-dive-guide-2025)의 마지막 절을 참고하라.

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, &params) < 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`으로 테스트.

이 코드의 핵심:

1. **멀티샷 accept** 한 번 등록으로 모든 신규 연결을 받는다.

2. **read/write 토글** — read 후에 write, write 후에 다시 read. 무한 핑퐁.

3. **single issuer + defer taskrun** — 단일 스레드 최적화 플래그 활용.

4. **에러는 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)](https://kernel.dk/io_uring.pdf) — io_uring 원저자의 최초 백서.

- [The Linux Kernel: io_uring documentation](https://kernel.org/doc/html/latest/io-uring/index.html) — 공식 커널 문서.

- [liburing GitHub repository](https://github.com/axboe/liburing) — Jens Axboe가 직접 관리하는 라이브러리.

- [Lord of the io_uring](https://unixism.net/loti/) — Shuveb Hussain의 가이드, 입문에 좋다.

- [io_uring by example (Shuveb Hussain)](https://unixism.net/loti/tutorial/index.html) — 단계별 튜토리얼.

- [tokio-uring](https://github.com/tokio-rs/tokio-uring) — Tokio + io_uring 연결.

- [monoio](https://github.com/bytedance/monoio) — Bytedance의 thread-per-core 런타임.

- [glommio](https://github.com/DataDog/glommio) — DataDog의 thread-per-core 런타임.

- [ScyllaDB Architecture](https://www.scylladb.com/product/technology/) — Seastar/io_uring 활용 사례.

- [Cloudflare Pingora 발표](https://blog.cloudflare.com/pingora-open-source/) — 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 내부 구조 딥다이브](./2026-04-15-nginx-internals-event-loop-master-worker-reverse-proxy-deep-dive-guide-2025)와 함께 읽으면, "epoll 시대의 정점"과 "그 다음에 오는 것"의 풍경을 이어 볼 수 있을 것이다.

현재 단락 (1/792)

2019년 5월, Linux 커널 5.1이 출시되면서 한 가지 조용한 혁명이 일어났다. Jens Axboe가 작성한 새 비동기 I/O 인터페이스 `io_uring`이 메인라인에 머...

작성 글자: 0원문 글자: 31,471작성 단락: 0/792