TT Lab

한국어

시작하기
블로그

블로그

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

대화로 듣기 · 7분두 사람이 이 글을 풀어 줍니다

들어가며 — 왜 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의 핵심은 사용자 공간과 커널이 공유하는 두 개의 링 버퍼이다:

각 링은 단일 생산자, 단일 소비자(SPSC) 패턴을 따른다:

이 구조 덕분에 락이 전혀 필요 없다. 헤드/테일 포인터의 원자적 업데이트만으로 동기화된다.

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 ─────────────────────────────────────

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];
    };
};

핵심 필드:

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, ... */
};

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에 들어갈 수 있는 값:

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:

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 카운트를 잡지 않아도 되어 빠르다. 등록 가능한 것:

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_* 함수들 (일부):

이 목록은 매년 확장된다. 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의 장점:

단점:

권장 시나리오: 고처리량 인프라 서비스 (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 패턴이 있다.

각 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 ─────────────────────────────────────


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이 직접 디바이스를 폴링한다. 단, 다음 제약이 있다:

전형적인 사용처: 데이터베이스 스토리지 엔진 (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가 발견되었다:

이 때문에 일부 클라우드 사업자는 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 유의점


14. 사례 연구 1: ScyllaDB와 Seastar

ScyllaDB는 처음부터 io_uring을 전제로 만들어진 NoSQL 데이터베이스다. 정확히 말하면 Seastar라는 비동기 프레임워크 위에 얹혀 있는데, Seastar는 io_uring 이전에 자체적인 AIO/eventfd 구현을 가지고 있었다. 이후 io_uring 백엔드가 추가되었다.

14.1 Seastar의 비동기 모델

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 도입으로:

자세한 내용은 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();
        });
    }
}

특징:

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();
}

특징:

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에도 없다.


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을 써야 하는 경우

24.2 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 — 미니 용어집


이전 글 Nginx 내부 구조 딥다이브와 함께 읽으면, "epoll 시대의 정점"과 "그 다음에 오는 것"의 풍경을 이어 볼 수 있을 것이다.

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다