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 내부 구조 딥다이브에서는 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_RINGSR_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);

문제: readcloseopen이 만든 fd를 알아야 한다. 이를 위해서는 IOSQE_FIXED_FILE과 file slot 등록이 필요하다. 또는 IORING_OP_OPENAT2의 결과 fd를 file table에 자동 등록하는 IORING_OP_OPENAT2 + IORING_OP_FIXED_FD_INSTALL 패턴이 있다.

  • IOSQE_IO_LINK — 앞 연산이 "사용자가 의도한 부분 결과"를 반환하면 (-EAGAIN이나 짧은 read) 체인이 끊긴다.
  • IOSQE_IO_HARDLINK — 짧은 read 등도 무시하고 다음 연산을 실행. 정말로 에러일 때만 끊김.

각 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.1io_uring 기본 도입 (read/write/fsync/nop)
5.2더 많은 opcode (timeout, accept, connect 등)
5.4sendmsg/recvmsg, IORING_OP_POLL
5.5IORING_FEAT_FAST_POLL (epoll보다 빠른 폴링)
5.6IORING_OP_OPENAT, READ/WRITE 향상
5.7provided buffers, IORING_OP_PROVIDE_BUFFERS
5.10IORING_SETUP_SQPOLL이 일반 유저에게 개방
5.11IORING_OP_SHUTDOWN, FILES_UPDATE
5.12IORING_OP_RENAMEAT, UNLINKAT
5.13IORING_OP_MKDIRAT, SYMLINKAT, LINKAT
5.15IORING_REGISTER_IOWQ_AFF (workqueue affinity)
5.18IORING_SETUP_SUBMIT_ALL, COOP_TASKRUN, TASKRUN_FLAG
5.19IORING_OP_URING_CMD (NVMe passthrough), big SQE/CQE
6.0IORING_SETUP_SINGLE_ISSUER, DEFER_TASKRUN
6.1multishot accept/recv, IORING_REGISTER_PBUF_RING
6.4IORING_OP_FUTEX_WAIT/WAKE/WAITV
6.6IORING_OP_READV_FIXED, WRITEV_FIXED
6.7IORING_OP_FIXED_FD_INSTALL, FTRUNCATE
6.10NAPI 통합 향상, send zero-copy
6.13epoll wait via io_uring

liburing은 런타임에 어떤 기능이 사용 가능한지 IORING_REGISTER_PROBE로 조회할 수 있다. 이를 활용해 fallback 경로를 짜는 것이 권장된다.


13. 실전 벤치마크 — io_uring vs epoll vs AIO

벤치마크 결과는 워크로드에 크게 의존하지만, 일반적인 경향을 정리하면:

13.1 작은 랜덤 read (NVMe, 4KB blocks)

방식IOPSCPU%비고
pread (동기)~150K100%단일 스레드
Linux AIO~600K100%4스레드
epoll + 스레드풀~500K100%4스레드
io_uring (기본)~900K100%단일 스레드
io_uring + SQPOLL~1.1M100% (+ 1 SQPOLL)단일 스레드 + SQPOLL
io_uring + IOPOLL~1.3M100% (+ 1 IOPOLL)폴링 모드
io_uring + URING_CMD~1.5M100% (+ 1 IOPOLL)NVMe passthrough

(숫자는 fio로 측정한 대략적인 값. 실제 결과는 디바이스, CPU, 커널 버전에 따라 다르다.)

13.2 네트워크 echo 서버

방식RPSp99 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-stealingepoll only일반
tokio-uringSingle-threaduring디스크 무거운 워크로드
monoioThread-per-coreuring (epoll fallback)고성능 인프라
glommioThread-per-coreuring only데이터 파이프라인
compioCross-platformuring + 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 — 참고 자료

부록 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 시대의 정점"과 "그 다음에 오는 것"의 풍경을 이어 볼 수 있을 것이다.

현재 단락 (1/792)

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

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