io_uring (Async I/O)

Linux io_uring 비동기 I/O 인터페이스를 커널-사용자 공간 공유 링 모델 관점에서 심층 분석합니다. SQE/CQE 기반 제출·완료 경로, SQPOLL/IOPOLL/고정 버퍼/고정 파일 등 지연시간 최적화 모드, io-wq와 worker 오프로딩 동작, timeout·cancel·multishot·링크드 연산 같은 고급 제어 패턴, zero-copy 전송과 io_uring_cmd passthrough 활용, liburing 실전 코드 구조, epoll/스레드풀/AIO 대비 선택 기준, 보안 제약과 취약점 대응 전략, 운영 환경에서의 계측·튜닝·장애 분석 절차까지 고성능 서비스 개발에 필요한 핵심 내용을 종합적으로 다룹니다.

관련 표준: NVMe Specification 2.0 (비동기 I/O 커맨드), POSIX.1-2017 (AIO 비교 기준) — io_uring은 POSIX AIO를 대체하는 고성능 비동기 I/O 인터페이스입니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.
전제 조건: Block I/O 서브시스템VFS 문서를 먼저 읽으세요. 스토리지 경로는 큐잉, 병합, 플러시 정책이 연쇄적으로 동작하므로, 요청 수명주기와 완료 경로를 먼저 추적해야 합니다.

핵심 요약

  • io_uring — Linux 5.1에서 도입된 고성능 비동기 I/O 인터페이스입니다.
  • SQ / CQ — Submission Queue(제출 큐)와 Completion Queue(완료 큐). 사용자-커널 간 공유 링 버퍼입니다.
  • SQPOLL — 커널 스레드가 SQ를 폴링하여 시스템 콜 없이 I/O를 처리하는 모드입니다.
  • liburing — io_uring을 쉽게 사용하기 위한 사용자 공간 라이브러리입니다.
  • 제로카피 — 데이터 복사 없이 네트워크 전송/수신을 수행하는 고급 기능입니다.

단계별 이해

  1. 왜 필요한가 — 기존 read()/write()는 매번 시스템 콜 전환이 필요하고, POSIX AIO는 제한적입니다.

    NVMe SSD처럼 수백만 IOPS 디바이스에서는 시스템 콜 오버헤드가 병목이 됩니다.

  2. 링 버퍼 이해 — 사용자가 SQE(Submission Queue Entry)를 SQ에 넣으면, 커널이 처리 후 CQE(Completion Queue Entry)를 CQ에 넣습니다.

    공유 메모리이므로 데이터 복사 없이 포인터만 이동합니다.

  3. liburing 체험io_uring_queue_init()으로 링을 초기화하고, io_uring_prep_readv()로 읽기를 준비합니다.

    io_uring_submit()으로 제출, io_uring_wait_cqe()로 완료를 기다립니다.

  4. 성능 확인fio --ioengine=io_uring --bs=4k --iodepth=64으로 io_uring 성능을 벤치마크합니다.

    기존 libaio 대비 latency와 IOPS에서 큰 향상을 확인할 수 있습니다.

개요

io_uring은 Linux 5.1(2019)에서 도입된 비동기 I/O 인터페이스입니다. 기존 AIO(io_submit/io_getevents)의 한계(버퍼드 I/O 미지원, 시스템 콜 오버헤드)를 해결하며, 사용자-커널 간 공유 링 버퍼를 통해 시스템 콜 없이 I/O를 제출하고 완료를 수확합니다.

io_uring 아키텍처 User Space Application liburing (helper library) SQ Ring head/tail + SQE[] CQ Ring head/tail + CQE[] mmap() 공유 메모리 Kernel Space io_uring core SQE 파싱 & 디스패치 io-wq worker thread pool VFS / Block read, write, fsync Net / Socket send, recv, accept 완료 → CQE 게시
io_uring 전체 아키텍처: 사용자-커널 공유 링 버퍼, io-wq 워커, 서브시스템 연동

io_uring 발전 역사

io_uring은 Jens Axboe가 설계하여 Linux 5.1에서 처음 도입되었으며, 이후 매 커널 릴리스마다 새로운 opcode와 기능이 추가되어 범용 비동기 인터페이스로 발전하고 있습니다.

커널 버전주요 추가 기능
5.1 (2019-05)io_uring 도입: READV/WRITEV, FSYNC, POLL_ADD, io_uring_setup/enter/register
5.2POLL_REMOVE, io-wq 워커 풀 도입
5.3TIMEOUT, SQE 링크(IOSQE_IO_LINK)
5.4TIMEOUT_REMOVE, ACCEPT, ASYNC_CANCEL, LINK_TIMEOUT
5.5CONNECT, FALLOCATE, OPENAT, CLOSE, STATX, PROVIDE_BUFFERS
5.6READ/WRITE (단순화), SPLICE, TEE, SQPOLL CPU affinity 개선
5.7EPOLL_CTL, MADVISE, OPENAT2
5.11SHUTDOWN, RENAMEAT, UNLINKAT, MKDIRAT
5.12SYMLINKAT, LINKAT, io_uring_disabled sysctl 보안 옵션
5.15MSG_RING (ring-to-ring 메시징)
5.18SOCKET (소켓 생성), 등록 파일 업데이트
5.19SEND_ZC (제로카피 전송), provided buf ring mmap API
6.0SEND_ZC 안정화, io_uring_cmd (NVMe passthrough)
6.1IORING_SETUP_SINGLE_ISSUER, IORING_SETUP_DEFER_TASKRUN
6.2RECV_ZC (제로카피 수신), 멀티 CQE32
6.3WAITID, IORING_REGISTER_RESTRICTIONS
6.7IORING_SETUP_NO_SQARRAY (SQ 배열 제거로 메모리 절약)
6.9FUTEX_WAIT/WAKE, 네이티브 futex 지원, IORING_REGISTER_NAPI (busy-poll)
6.10FUTEX_WAITV, clock 소스 지원, pbuf_ring 증분 소비(incremental), io-wq 해시 최적화
6.11IORING_SETUP_NO_MMAP, 커널 측 ring 할당, 번들(bundle) SQE 실험적 지원
6.12FIXED_FD_INSTALL opcode, 소켓 직접 설치, 등록 잠금 최적화
6.13CLONE 관련 정리, per-ring NAPI 개선, 대기 영역(wait region) 실험적
6.14FUSE io_uring 지원, 번들 recv/send 안정화, 성능 카운터 통합
6.15io_uring 전용 LSM 훅 (security_uring_sqe, security_uring_cmd), 보안 강화

시스템 콜 인터페이스

io_uring은 3개의 시스템 콜로 동작합니다. 초기 설정 이후에는 io_uring_enter()조차 호출하지 않는 완전한 커널 폴링 모드도 가능합니다.

/* 1. io_uring 인스턴스 생성 */
int io_uring_setup(u32 entries, struct io_uring_params *params);
/* entries: SQ 크기 (2의 거듭제곱으로 올림)
 * params: 설정 플래그 + 커널이 채워주는 SQ/CQ 오프셋 정보
 * 반환: io_uring fd → mmap()으로 SQ/CQ 매핑 */

/* 2. I/O 제출 및 완료 대기 */
int io_uring_enter(int fd, u32 to_submit, u32 min_complete,
                    u32 flags, sigset_t *sig);
/* to_submit: 제출할 SQE 수
 * min_complete: 최소 완료 대기 수 (0이면 논블로킹)
 * flags: IORING_ENTER_GETEVENTS, IORING_ENTER_SQ_WAKEUP 등 */

/* 3. 리소스 사전 등록 (선택) */
int io_uring_register(int fd, u32 opcode, void *arg, u32 nr_args);
/* fd/버퍼를 커널에 사전 등록 → 매 I/O마다 fget/fput, 페이지 핀 비용 제거
 * IORING_REGISTER_BUFFERS: 고정 버퍼 등록
 * IORING_REGISTER_FILES: 고정 파일 디스크립터 등록 */

SQE / CQE 자료구조

SQE(Submission Queue Entry)는 I/O 요청을, CQE(Completion Queue Entry)는 완료 결과를 나타냅니다. 두 구조체 모두 고정 크기로 캐시 친화적입니다.

/* include/uapi/linux/io_uring.h */
struct io_uring_sqe {
    __u8    opcode;     /* IORING_OP_READ, IORING_OP_WRITE, ... */
    __u8    flags;      /* IOSQE_FIXED_FILE, IOSQE_IO_LINK, ... */
    __u16   ioprio;     /* I/O 우선순위 */
    __s32   fd;         /* 대상 파일 디스크립터 */
    union {
        __u64 off;      /* 파일 오프셋 */
        __u64 addr2;    /* 두 번째 주소 (opcode에 따라) */
    };
    union {
        __u64 addr;     /* 버퍼 주소 또는 iovec 포인터 */
        __u64 splice_off_in;
    };
    __u32   len;        /* 버퍼 크기 또는 iovec 수 */
    union {
        __kernel_rwf_t rw_flags;
        __u32          fsync_flags;
        __u32          poll_events;
        __u32          msg_flags;
        __u32          accept_flags;
    };
    __u64   user_data;  /* CQE에 그대로 복사 → 요청 식별자 */
    union {
        __u16 buf_index; /* 고정 버퍼 인덱스 */
        __u16 buf_group; /* 버퍼 그룹 ID (provided buffers) */
    };
    __u16   personality;
    union {
        __s32 splice_fd_in;
        __u32 file_index;
    };
    __u64   __pad2[2];
};  /* sizeof = 64 bytes (1 캐시라인) */

struct io_uring_cqe {
    __u64   user_data;  /* SQE에서 복사된 사용자 데이터 */
    __s32   res;        /* 결과값 (바이트 수 또는 -errno) */
    __u32   flags;      /* IORING_CQE_F_BUFFER, IORING_CQE_F_MORE */
};  /* sizeof = 16 bytes */

링 버퍼 동작 원리

SQ/CQ 링은 mmap()으로 사용자 공간에 매핑된 lock-free SPSC(Single-Producer Single-Consumer) 링 버퍼입니다. 메모리 배리어만으로 동기화합니다.

/* SQE 제출 과정 (사용자 공간) */
unsigned idx = sq->tail & sq->ring_mask;
struct io_uring_sqe *sqe = &sq->sqes[idx];

sqe->opcode  = IORING_OP_READ;
sqe->fd      = file_fd;
sqe->addr    = (unsigned long)buf;
sqe->len     = buf_size;
sqe->off     = offset;
sqe->user_data = my_request_id;

/* SQ tail 포인터 갱신 (write barrier 필수) */
io_uring_smp_store_release(&sq->tail, sq->tail + 1);
io_uring_enter(ring_fd, 1, 0, 0, NULL);

/* CQE 수확 과정 (사용자 공간) */
unsigned head = io_uring_smp_load_acquire(&cq->head);
while (head != cq->tail) {
    struct io_uring_cqe *cqe = &cq->cqes[head & cq->ring_mask];
    handle_completion(cqe->user_data, cqe->res);
    head++;
}
io_uring_smp_store_release(&cq->head, head);
ℹ️

SQ는 간접 인덱싱을 사용합니다: sq->array[idx]가 실제 sqes[] 인덱스를 가리킵니다. 이를 통해 SQE를 순서 무관하게 재사용할 수 있습니다. CQ는 직접 인덱싱으로 더 단순합니다.

mmap 메모리 레이아웃

io_uring의 SQ/CQ 링과 SQE 배열은 mmap()을 통해 사용자 공간에 매핑됩니다. 커널 5.4+ 이후 SQ와 CQ 링은 하나의 mmap 영역을 공유하여 메모리를 절약합니다.

io_uring mmap 메모리 레이아웃 mmap #1: IORING_OFF_SQ_RING (0x00000000) SQ Ring + CQ Ring 공유 매핑 (5.4+에서 통합) SQ Ring 메타데이터 sq.head sq.tail sq.ring_mask sq.ring_entries sq.flags sq.dropped sq.array[0..ring_entries-1] — 간접 인덱스 배열 (NO_SQARRAY 시 생략) 각 항목은 sqes[] 배열의 인덱스를 가리킴 CQ Ring 메타데이터 cq.head cq.tail cq.ring_mask cq.ring_entries cq.overflow cq.flags cqes[0..cq_entries-1] — CQE 배열 각 CQE: 16바이트 (user_data + res + flags), CQE32 시 32바이트 mmap #2: IORING_OFF_SQES (0x10000000) sqes[0..sq_entries-1] — SQE 배열 (별도 mmap) 각 SQE: 64바이트 (1 캐시라인), SQE128 시 128바이트 mmap #3: IORING_OFF_PBUF_RING (선택) Provided Buffer Ring — 사용 시에만 매핑 (REGISTER_PBUF_RING) 커널이 완료 시 자동으로 버퍼 선택 offset: params.sq_off.* offset: params.cq_off.* array[i] → sqes[idx]
io_uring의 3개 mmap 영역: SQ/CQ 공유 링, SQE 배열, Provided Buffer Ring
/* mmap 설정 코드 (liburing 내부 동작) */
struct io_uring_params p;
int fd = io_uring_setup(256, &p);

/* 1. SQ Ring 매핑 (CQ Ring도 같은 영역에 포함) */
size_t sq_ring_sz = p.sq_off.array + p.sq_entries * sizeof(u32);
size_t cq_ring_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);
size_t ring_sz = sq_ring_sz > cq_ring_sz ? sq_ring_sz : cq_ring_sz;

void *sq_ptr = mmap(NULL, ring_sz, PROT_READ | PROT_WRITE,
                    MAP_SHARED | MAP_POPULATE, fd, IORING_OFF_SQ_RING);

/* SQ 필드 오프셋 (params.sq_off에서 제공) */
u32 *sq_head     = sq_ptr + p.sq_off.head;
u32 *sq_tail     = sq_ptr + p.sq_off.tail;
u32 *sq_mask     = sq_ptr + p.sq_off.ring_mask;
u32 *sq_entries  = sq_ptr + p.sq_off.ring_entries;
u32 *sq_flags    = sq_ptr + p.sq_off.flags;
u32 *sq_array    = sq_ptr + p.sq_off.array;

/* CQ 필드 오프셋 (같은 mmap 영역, params.cq_off에서 제공) */
u32 *cq_head     = sq_ptr + p.cq_off.head;    /* 5.4+: 같은 mmap */
u32 *cq_tail     = sq_ptr + p.cq_off.tail;
struct io_uring_cqe *cqes = sq_ptr + p.cq_off.cqes;

/* 2. SQE 배열 매핑 (별도 mmap 영역) */
struct io_uring_sqe *sqes = mmap(NULL,
    p.sq_entries * sizeof(struct io_uring_sqe),
    PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
    fd, IORING_OFF_SQES);
mmap 오프셋 상수매핑 대상크기
IORING_OFF_SQ_RING0x00000000SQ Ring + CQ Ring (공유)max(SQ 링 크기, CQ 링 크기)
IORING_OFF_CQ_RING0x08000000CQ Ring (5.4 이전 별도 매핑)5.4+에서는 SQ_RING과 동일
IORING_OFF_SQES0x10000000SQE 배열sq_entries × 64 (또는 128)
IORING_OFF_PBUF_RING0x80000000Provided buffer ring등록 시 결정
ℹ️

SQ/CQ 통합 매핑 (5.4+): 커널 5.4 이전에는 SQ Ring과 CQ Ring이 별도로 mmap 되었으나, 이후 하나의 mmap 호출로 통합되었습니다. IORING_OFF_CQ_RING으로 mmap하면 IORING_OFF_SQ_RING과 동일한 주소를 반환합니다. params.featuresIORING_FEAT_SINGLE_MMAP 비트가 설정되어 있으면 통합 매핑이 지원됩니다.

SQE 간접 인덱싱 상세

SQ Ring에는 SQE를 직접 저장하지 않고, sq.array[]라는 간접 인덱스 배열이 있습니다. 이 배열의 각 항목은 별도 mmap된 sqes[] 배열의 인덱스를 가리킵니다.

SQ 간접 인덱싱 vs 직접 인덱싱 간접 인덱싱 (기본) sq.array[]: 2 0 3 1 sqes[]: [0] READ [1] SEND [2] WRITE [3] FSYNC 제출 순서: WRITE → READ → FSYNC → SEND SQE 슬롯을 순서와 무관하게 재사용 가능 직접 인덱싱 (NO_SQARRAY, 6.7+) sq.array 없음 sqes[]: [0] = SQE0 [1] = SQE1 [2] = SQE2 [3] = SQE3 tail 기반 직접 매핑 sqes[tail & mask]가 곧 SQE 메모리 절약 + 간접 참조 1회 제거 비교: 간접 인덱싱은 유연성 우선, NO_SQARRAY는 성능 우선 대부분의 애플리케이션은 SQE를 순차적으로 사용하므로 NO_SQARRAY가 유리 (6.7+ 사용 시)
SQ 간접 인덱싱 (기본) vs 직접 인덱싱 (IORING_SETUP_NO_SQARRAY)
/* 간접 인덱싱 (기본): sq.array[idx]가 sqes[] 인덱스를 가리킴 */
unsigned idx = sq_tail & sq_mask;
sq_array[idx] = idx;           /* 간접 인덱스 설정 (보통 idx == idx) */
struct io_uring_sqe *sqe = &sqes[idx];
sqe->opcode = IORING_OP_READ;
/* ... SQE 필드 설정 ... */

/* 간접 인덱싱의 장점: 순서 변경 가능 */
sq_array[0] = 2;  /* 첫 번째 제출할 SQE: sqes[2] */
sq_array[1] = 0;  /* 두 번째 제출할 SQE: sqes[0] */

/* 직접 인덱싱 (6.7+): sq.array 불필요 */
struct io_uring_params params = {
    .flags = IORING_SETUP_NO_SQARRAY,
};
/* sqes[sq_tail & mask]가 곧 SQE → 간접 참조 1단계 제거 */
💡

NO_SQARRAY 권장: 대부분의 애플리케이션은 SQE를 순차적으로 사용하므로 간접 인덱싱이 불필요합니다. IORING_SETUP_NO_SQARRAY(6.7+)를 사용하면 sq.array[] 메모리(sq_entries × 4바이트)를 절약하고, 한 단계 간접 참조를 제거하여 미미하지만 일관된 성능 향상을 얻습니다.

주요 연산 (opcodes)

카테고리Opcode설명도입
파일 I/OIORING_OP_READ파일 읽기 (고정 버퍼 지원)5.6
IORING_OP_WRITE파일 쓰기 (고정 버퍼 지원)5.6
IORING_OP_READV / WRITEVScatter-gather I/O (iovec)5.1
IORING_OP_READ_FIXED / WRITE_FIXED등록된 고정 버퍼 사용 읽기/쓰기5.1
IORING_OP_FSYNC파일 동기화 (fdatasync 포함)5.1
IORING_OP_FALLOCATE파일 공간 사전 할당5.6
IORING_OP_FADVISE파일 접근 패턴 힌트 (posix_fadvise)5.6
네트워크IORING_OP_ACCEPT소켓 연결 수락 (multishot 지원)5.5
IORING_OP_CONNECT소켓 연결5.5
IORING_OP_SEND / RECV소켓 송수신 (multishot recv 지원)5.6
IORING_OP_SENDMSG / RECVMSGmsghdr 기반 소켓 송수신5.3
IORING_OP_SEND_ZC제로카피 송신 (2개 CQE 생성)6.0
IORING_OP_RECV_ZC제로카피 수신6.2
IORING_OP_SOCKET소켓 생성 (fixed file 직접 등록 가능)5.19
IORING_OP_SHUTDOWN소켓 종료5.11
IORING_OP_BIND / LISTEN소켓 바인드/리슨 (실험적)6.11
파일시스템IORING_OP_OPENAT / OPENAT2파일 열기 (고급 플래그 지원)5.6/5.7
IORING_OP_CLOSE파일 닫기 (fixed file 해제 포함)5.6
IORING_OP_STATX파일 상태 조회5.6
IORING_OP_RENAMEAT파일 이름 변경5.11
IORING_OP_UNLINKAT파일/디렉토리 삭제5.11
IORING_OP_MKDIRAT디렉토리 생성5.15
IORING_OP_SYMLINKAT / LINKAT심볼릭/하드 링크 생성5.15
IORING_OP_GETXATTR / SETXATTR / FGETXATTR / FSETXATTR확장 속성 읽기/쓰기5.19
데이터 전달IORING_OP_SPLICE파이프 기반 제로카피 데이터 이동5.7
IORING_OP_TEE파이프 데이터 복제 (소비하지 않음)5.7
IORING_OP_PROVIDE_BUFFERS커널에 버퍼 풀 제공 (레거시 방식)5.7
IORING_OP_REMOVE_BUFFERS제공된 버퍼 제거5.7
제어/고급IORING_OP_POLL_ADD / POLL_REMOVE이벤트 폴링 (multishot 지원, epoll 대체)5.2
IORING_OP_TIMEOUT / TIMEOUT_REMOVE타임아웃 설정/해제5.4
IORING_OP_LINK_TIMEOUT링크된 SQE에 타임아웃 부여5.5
IORING_OP_ASYNC_CANCEL진행 중인 요청 취소 (user_data/fd/전체)5.5
IORING_OP_MSG_RING링 간 CQE/fd 전송5.18
IORING_OP_NOP아무 작업 안 함 (벤치마크/테스트용)5.1
IORING_OP_MADVISE메모리 조언 (posix_madvise)5.6
특수IORING_OP_URING_CMD드라이버 직접 명령 (NVMe passthrough 등)6.0
IORING_OP_FUTEX_WAIT / FUTEX_WAKE커널 futex 비동기 대기/깨우기6.7
IORING_OP_WAITID프로세스 상태 비동기 대기6.7
IORING_OP_FIXED_FD_INSTALLfixed file을 프로세스 fd 테이블에 설치6.12
Epoll 호환IORING_OP_EPOLL_CTLepoll_ctl 비동기 실행5.6
IORING_OP_FILES_UPDATE등록된 파일 테이블 업데이트5.6

io_uring_params 플래그 상세

io_uring_setup() 호출 시 io_uring_params.flags에 설정하는 플래그들은 링의 동작 방식을 결정합니다.

플래그도입설명
IORING_SETUP_IOPOLL5.1완료를 인터럽트 대신 폴링으로 확인. O_DIRECT 전용
IORING_SETUP_SQPOLL5.1커널 스레드가 SQ를 폴링. 시스템 콜 없이 I/O 제출
IORING_SETUP_SQ_AFF5.1SQPOLL 스레드를 sq_thread_cpu에 바인딩
IORING_SETUP_CQSIZE5.5CQ 크기를 cq_entries로 별도 지정
IORING_SETUP_ATTACH_WQ5.6기존 ring의 io-wq 워커 풀을 공유
IORING_SETUP_R_DISABLED5.10ring을 비활성 상태로 생성. ENABLE_RINGS로 활성화
IORING_SETUP_COOP_TASKRUN5.19task_work를 협력적으로 처리. io_uring_enter() 진입 시에만 완료
IORING_SETUP_SQE1285.19SQE를 128바이트로 확장 (NVMe passthrough 등)
IORING_SETUP_CQE325.19CQE를 32바이트로 확장
IORING_SETUP_SINGLE_ISSUER6.0단일 태스크만 제출 보장. 내부 잠금 최적화
IORING_SETUP_DEFER_TASKRUN6.1SINGLE_ISSUER 필요. task_work를 io_uring_enter() 시 일괄 처리
IORING_SETUP_NO_SQARRAY6.7SQ 간접 인덱스 배열 생략. 메모리 절약
💡

최고 성능 조합: IORING_SETUP_SQPOLL | IORING_SETUP_IOPOLL | IORING_SETUP_SINGLE_ISSUER | IORING_SETUP_DEFER_TASKRUN. NVMe O_DIRECT 워크로드에서 시스템 콜과 인터럽트 없이 극한의 IOPS를 달성합니다.

동작 모드

기본 모드 (Interrupt Driven)

struct io_uring_params params = {};
int ring_fd = io_uring_setup(256, &params);
io_uring_enter(ring_fd, 1, 1, IORING_ENTER_GETEVENTS, NULL);

SQPOLL 모드 (커널 폴링)

커널 스레드(io_uring-sq)가 SQ를 지속적으로 폴링합니다. 시스템 콜 없이 SQ tail만 갱신하면 커널이 자동으로 처리합니다.

SQPOLL 모드: Zero-Syscall I/O User Space Kernel Space 1. SQE 작성 IORING_OP_READ 2. SQ tail 갱신 메모리 쓰기만 3. CQ head 확인 완료 대기 Shared Memory SQ Ring CQ Ring SQE Array io_uring-sq 전용 커널 스레드 CPU 코어 고정 가능 SQ Polling tail ≠ head ? 지속적 확인 I/O 처리 블록 레이어 호출 CQE 작성 CQ tail 갱신 유휴 상태 관리 sq_thread_idle (예: 2초) 동안 작업 없으면 슬립 IORING_SQ_NEED_WAKEUP 플래그 설정 → io_uring_enter()로 깨움 ✓ 장점 · 시스템 콜 0회 · 초저지연 · 컨텍스트 스위치 제거 ⚠ 주의 · CPU 1개 전용 소비 · CAP_SYS_NICE 필요 · 저부하 시 비효율
struct io_uring_params params = {
    .flags = IORING_SETUP_SQPOLL,
    .sq_thread_idle = 2000,  /* 2초 유휴 시 스레드 슬립 */
};
int ring_fd = io_uring_setup(256, &params);

/* 커널 스레드가 슬립했다면 깨워야 함 */
if (*sq->flags & IORING_SQ_NEED_WAKEUP)
    io_uring_enter(ring_fd, 0, 0, IORING_ENTER_SQ_WAKEUP, NULL);

IOPOLL 모드 (하드웨어 폴링)

커널이 블록 디바이스 완료를 인터럽트 대신 폴링으로 확인합니다. NVMe 등 고성능 스토리지에서 인터럽트 지연을 제거합니다. O_DIRECT 전용입니다.

struct io_uring_params params = {
    .flags = IORING_SETUP_IOPOLL,
};
/* SQPOLL + IOPOLL = 완전한 폴링 기반 I/O (시스템 콜 0, 인터럽트 0) */

고급 기능

/* write → fsync 순차 실행 보장 */
sqe1->opcode = IORING_OP_WRITE;
sqe1->flags  = IOSQE_IO_LINK;
sqe2->opcode = IORING_OP_FSYNC;
sqe2->flags  = 0;
/* IOSQE_IO_HARDLINK: 앞 SQE 실패해도 계속 실행 */

고정 파일/버퍼 (Registered Resources)

매 I/O마다 발생하는 fget()/fput()와 페이지 핀(GUP) 비용을 제거합니다.

int fds[] = {fd1, fd2, fd3};
io_uring_register(ring_fd, IORING_REGISTER_FILES, fds, 3);
sqe->flags |= IOSQE_FIXED_FILE;
sqe->fd = 0;  /* fds[0] = fd1 사용 */

struct iovec iovs[] = { { buf1, 4096 }, { buf2, 4096 } };
io_uring_register(ring_fd, IORING_REGISTER_BUFFERS, iovs, 2);
sqe->opcode = IORING_OP_READ_FIXED;
sqe->buf_index = 0;

Provided Buffers (커널 버퍼 선택)

버퍼 풀을 커널에 제공하고, 커널이 완료 시 적절한 버퍼를 자동 선택합니다.

struct io_uring_buf_ring *br;
br = mmap(..., ring_fd, IORING_OFF_PBUF_RING);
for (int i = 0; i < nr_bufs; i++)
    io_uring_buf_ring_add(br, bufs[i], buf_size, i, mask, i);
io_uring_buf_ring_advance(br, nr_bufs);

sqe->opcode = IORING_OP_RECV;
sqe->flags  = IOSQE_BUFFER_SELECT;
sqe->buf_group = group_id;

Multishot 연산

하나의 SQE로 여러 번의 CQE를 생성합니다. accept, recv, poll 등에서 반복적인 SQE 재제출 오버헤드를 제거합니다.

/* Multishot accept */
sqe->opcode = IORING_OP_ACCEPT;
sqe->fd     = listen_fd;
sqe->ioprio = IORING_ACCEPT_MULTISHOT;
/* 새 연결마다 CQE 생성, CQE.flags에 IORING_CQE_F_MORE 설정 */

/* Multishot recv */
sqe->opcode    = IORING_OP_RECV;
sqe->ioprio    = IORING_RECV_MULTISHOT;
sqe->flags     = IOSQE_BUFFER_SELECT;
sqe->buf_group = group_id;

Cancel / Timeout 연산 심화

IORING_OP_ASYNC_CANCEL

sqe->opcode = IORING_OP_ASYNC_CANCEL;
sqe->addr   = target_user_data;
/* 결과: 0=취소됨, -ENOENT=없음, -EALREADY=이미 완료 중 */

/* fd 기반 취소 (6.0+) */
sqe->fd    = target_fd;
sqe->flags = IORING_ASYNC_CANCEL_FD;

/* 모든 요청 취소 (6.1+) */
sqe->cancel_flags = IORING_ASYNC_CANCEL_ANY;

IORING_OP_TIMEOUT

struct __kernel_timespec ts = { .tv_sec = 2 };
sqe->opcode = IORING_OP_TIMEOUT;
sqe->addr   = (unsigned long)&ts;
sqe->len    = 1;
sqe->off    = 5;  /* 5개 CQE 완료되면 조기 해제 */
/* -ETIME=만료, 0=조기 해제, -ECANCELED=취소됨 */
/* read가 3초 내 완료되지 않으면 취소 */
sqe1->opcode = IORING_OP_READ;
sqe1->flags  = IOSQE_IO_LINK;

struct __kernel_timespec ts = { .tv_sec = 3 };
sqe2->opcode = IORING_OP_LINK_TIMEOUT;
sqe2->addr   = (unsigned long)&ts;
sqe2->len    = 1;

Zero-copy 네트워킹

IORING_OP_SEND_ZC는 사용자 공간 버퍼를 복사 없이 커널 네트워크 스택에 직접 전달합니다.

sqe->opcode = IORING_OP_SEND_ZC;
sqe->fd     = sock_fd;
sqe->addr   = (unsigned long)send_buf;
sqe->len    = send_len;

/* 주의: 2개의 CQE가 생성됨
 * 1) 전송 완료 (IORING_CQE_F_MORE)
 * 2) notification: 버퍼 해제 가능 (IORING_CQE_F_NOTIF) */
if (cqe->flags & IORING_CQE_F_NOTIF)
    recycle_buffer(cqe->user_data);
ℹ️

제로카피 전송은 64KB 이상의 대용량 전송에서 효과적이며, 10GbE 이상의 고속 네트워크에서 CPU 사용량을 30-50% 절감할 수 있습니다.

MSG_RING — 링 간 메시징

IORING_OP_MSG_RING(커널 5.18+)은 한 io_uring 인스턴스에서 다른 인스턴스로 CQE를 직접 전송하는 연산입니다. 멀티스레드 환경에서 별도 동기화 없이 스레드 간 통신이 가능합니다.

MSG_RING: 링 간 CQE 전송 Thread 1 — Ring A SQ Ring CQ Ring SQE: MSG_RING → Ring B fd user_data=0x42, len=결과값 MSG_RING_DATA 또는 MSG_RING_FD Thread 2 — Ring B SQ Ring CQ Ring CQE 수신! CQE: user_data=0x42, res=결과값 별도 시스템 콜 없이 도착 MSG_RING 커널 내부 직접 전달
IORING_OP_MSG_RING: Ring A에서 Ring B의 CQ로 직접 CQE 주입
MSG_RING 서브타입도입설명전달 데이터
IORING_MSG_DATA5.18임의 데이터를 CQE로 전달sqe->len → CQE.res, sqe->addr → CQE.user_data
IORING_MSG_SEND_FD6.3파일 디스크립터를 대상 ring에 설치fd가 대상 ring의 fixed file 테이블에 등록됨
/* MSG_RING: Ring A에서 Ring B로 데이터 전달 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring_a);

io_uring_prep_msg_ring(sqe,
    ring_b.ring_fd,   /* 대상 ring fd */
    42,               /* CQE.res에 들어갈 값 */
    0xDEAD,           /* CQE.user_data에 들어갈 값 */
    0                 /* 플래그 */
);
io_uring_submit(&ring_a);

/* Ring B 쪽에서 CQE 수신 */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring_b, &cqe);
/* cqe->res = 42, cqe->user_data = 0xDEAD */

/* MSG_RING_FD: fd를 대상 ring에 전달 (6.3+) */
sqe = io_uring_get_sqe(&ring_a);
sqe->opcode   = IORING_OP_MSG_RING;
sqe->fd       = ring_b.ring_fd;
sqe->len      = IORING_MSG_SEND_FD;
sqe->addr     = 0;                /* CQE.user_data */
sqe->addr3    = client_fd;         /* 전달할 fd */
sqe->file_index = dest_slot;       /* 대상 ring의 fixed file 슬롯 */
💡

활용 패턴: MSG_RING은 accept 전용 ring에서 새 연결 fd를 워커 ring으로 전달하거나, 타이머 ring에서 I/O ring으로 타임아웃 알림을 보내는 패턴에 유용합니다. pipe()/eventfd() 기반 통신보다 오버헤드가 훨씬 낮습니다.

FUSE io_uring 지원 (v6.14+)

커널 6.14에서 FUSE(Filesystem in Userspace)에 io_uring 지원이 추가되었습니다. 기존 FUSE는 /dev/fuse를 통한 read/write 시스템 콜로 커널↔유저스페이스 간 요청을 교환했으나, io_uring을 사용하면 시스템 콜 오버헤드와 컨텍스트 스위칭을 크게 줄일 수 있습니다.

경로시스템 콜 수 (요청당)컨텍스트 스위칭
기존 FUSE2 (read + write)2회 이상
FUSE + io_uring0 (SQ/CQ 공유 메모리)최소화 (SQPOLL 시 0)
성능 향상: 고빈도 메타데이터 연산(stat, readdir 등)이 많은 FUSE 파일시스템에서 io_uring 경로는 기존 대비 상당한 IOPS 향상을 달성할 수 있습니다. 특히 클라우드 스토리지 FUSE 마운트(GCSFuse, S3FS 등)에서 효과적입니다.

io_uring_cmd (Passthrough)

IORING_OP_URING_CMD는 디바이스 드라이버에 io_uring을 통해 직접 커스텀 명령을 전달합니다. NVMe passthrough가 대표적입니다.

/* NVMe passthrough (IORING_SETUP_SQE128 필요) */
sqe->opcode = IORING_OP_URING_CMD;
sqe->fd     = nvme_ns_fd;       /* /dev/ng0n1 */
sqe->cmd_op = NVME_URING_CMD_IO;

struct nvme_uring_cmd *cmd = (struct nvme_uring_cmd *)sqe->cmd;
cmd->opcode  = nvme_cmd_read;
cmd->addr    = (__u64)buffer;
cmd->data_len = 4096;

/* 커널 드라이버 측 */
static const struct file_operations my_fops = {
    .uring_cmd = my_uring_cmd_handler,
};

CQE Overflow 처리

CQ 링이 가득 찬 상태에서 새 CQE가 생성되면 오버플로가 발생합니다. 커널은 내부 오버플로 리스트에 CQE를 보관하고, 사용자가 CQ에서 CQE를 소비하면 자동으로 옮겨줍니다.

/* 오버플로 감지 */
if (*sq->flags & IORING_SQ_CQ_OVERFLOW)
    io_uring_enter(ring_fd, 0, 0, IORING_ENTER_GETEVENTS, NULL);

/* 오버플로 방지: CQ 크기를 충분히 크게 */
params.flags |= IORING_SETUP_CQSIZE;
params.cq_entries = 4096;  /* SQ의 4배 이상 권장 */
⚠️

CQE 오버플로는 성능 저하의 원인이 됩니다. 오버플로 리스트는 GFP_ATOMIC 할당을 사용하며, 지속되면 메모리 부족으로 CQE가 손실될 수 있습니다. CQ 크기를 충분히 설정하고 CQE를 적시에 소비하세요.

CQ 링 원형 버퍼 내부 구조

CQ(Completion Queue) 링은 커널이 완료된 I/O 결과를 기록하고 사용자 공간이 이를 수확하는 lock-free SPSC 원형 버퍼입니다. SQ와 달리 간접 인덱싱 배열 없이 직접 인덱싱을 사용하여 구조가 더 단순합니다.

ℹ️

CQ 링의 크기는 항상 power-of-2이며, ring_mask = entries - 1로 비트 AND 연산만으로 인덱싱합니다. 기본값은 cq_entries = 2 × sq_entries이고, IORING_SETUP_CQSIZE 플래그로 커스텀 설정할 수 있습니다.

/* CQ 링 인덱싱 메커니즘 */
/* 커널: CQE 기록 후 tail 전진 */
unsigned tail = ctx->rings->cq.tail;
unsigned idx = tail & ctx->cq_mask;  /* cq_mask = cq_entries - 1 */
struct io_uring_cqe *cqe = &ctx->rings->cqes[idx];
cqe->user_data = req->cqe.user_data;
cqe->res       = req->cqe.res;
cqe->flags     = req->cqe.flags;
smp_store_release(&ctx->rings->cq.tail, tail + 1);

/* 유저: head ~ tail 범위에서 CQE 소비 */
unsigned head = io_uring_smp_load_acquire(cq->khead);
while (head != *cq->ktail) {
    struct io_uring_cqe *cqe = &cq->cqes[head & cq->ring_mask];
    process_cqe(cqe);
    head++;
}
io_uring_smp_store_release(cq->khead, head);
/* struct io_rings — CQ 관련 필드 (include/uapi/linux/io_uring.h) */
struct io_rings {
    struct io_uring sq, cq;       /* 각각 head/tail/flags/entries 포함 */
    u32 sq_ring_mask;              /* sq_entries - 1 */
    u32 cq_ring_mask;              /* cq_entries - 1 */
    u32 sq_ring_entries;           /* SQ 엔트리 수 */
    u32 cq_ring_entries;           /* CQ 엔트리 수 */
    u32 sq_dropped;                /* 드롭된 SQE 카운터 */
    u32 sq_flags;                  /* IORING_SQ_* 플래그 */
    u32 cq_flags;                  /* IORING_CQ_* 플래그 */
    u32 cq_overflow;               /* 오버플로 카운터 */
    struct io_uring_cqe cqes[];   /* CQE 배열 (가변 길이) */
};
CQ 링 원형 버퍼 (8슬롯 예시) CQE[0] CQE[1] CQE[2] CQE[3] CQE[4] CQE[7] CQE[6] CQE[5] head = 2 유저가 전진 (소비) tail = 5 커널이 전진 (게시) 소비 완료 (head 이전) 미소비 CQE (head ~ tail) 빈 슬롯 (tail 이후) Wrap-around 인덱싱 실제 인덱스 = tail & ring_mask = tail & (entries - 1) tail = 13인 경우: 13 & 7 = 5 → CQE[5]에 기록 사용 가능 CQE 수 = tail - head (부호 없는 산술로 wrap-around 자동 처리) SQ와의 차이: SQ는 sq_array[] 간접 인덱싱, CQ는 직접 cqes[] 인덱싱
CQ 링 메타데이터 필드오프셋크기기록자읽기자설명
cq.headIORING_OFF_CQ_RING + 04B유저커널다음 소비할 CQE 위치
cq.tailIORING_OFF_CQ_RING + 44B커널유저다음 기록할 CQE 위치
cq.ring_maskio_cqring_offsets.ring_mask4B커널유저entries - 1
cq.ring_entriesio_cqring_offsets.ring_entries4B커널유저CQ 엔트리 총 개수
cq.overflowio_cqring_offsets.overflow4B커널유저오버플로 발생 누적 횟수
cq.cqes[]io_cqring_offsets.cqesentries × 16B커널유저CQE 배열 (CQE32는 ×32B)
cq.flagsio_cqring_offsets.flags4B커널유저IORING_CQ_EVENTFD_DISABLED 등
ℹ️

교차참조: CQ 링의 전체 mmap 배치는 mmap 메모리 레이아웃, SQ 간접 인덱싱과의 구조적 차이는 SQE 간접 인덱싱 상세, 링 버퍼의 기본 동작 원리는 링 버퍼 동작 원리 섹션을 참고하세요.

CQE 필드 상세 분석

각 CQE는 16바이트(CQE32 확장 시 32바이트)로 3개의 필드로 구성됩니다: user_data(8B), res(4B), flags(4B). 이 필드들의 의미와 활용 패턴을 상세히 분석합니다.

user_data 필드와 요청 식별 패턴

user_data는 SQE 제출 시 설정한 8바이트 값이 CQE에 그대로 복사되는 식별자입니다. 커널은 이 값을 해석하지 않으며, 애플리케이션이 어떤 요청의 완료인지 식별하는 유일한 수단입니다.

/* 패턴 1: 포인터 캐스트 — 가장 직관적 */
struct my_request *req = malloc(sizeof(*req));
req->op = OP_READ;
req->buf = buffer;
sqe->user_data = (__u64)(uintptr_t)req;
/* CQE 수확 시 */
struct my_request *req = (void *)(uintptr_t)cqe->user_data;

/* 패턴 2: 태그드 유니온 — 상위 비트 = 타입, 하위 = 인덱스 */
enum { TAG_ACCEPT = 0, TAG_READ = 1, TAG_WRITE = 2 };
#define MAKE_UD(tag, idx) (((__u64)(tag) << 56) | (__u64)(idx))
#define UD_TAG(ud)       ((unsigned)((ud) >> 56))
#define UD_IDX(ud)       ((unsigned)((ud) & 0x00FFFFFFFFFFFFFF))
sqe->user_data = MAKE_UD(TAG_READ, conn_id);
/* CQE 수확 시 */
switch (UD_TAG(cqe->user_data)) {
    case TAG_READ:  handle_read(UD_IDX(cqe->user_data), cqe->res); break;
    case TAG_WRITE: handle_write(UD_IDX(cqe->user_data), cqe->res); break;
}

/* 패턴 3: 순차 ID + 해시맵 — 대규모 요청 추적 */
static __u64 next_id = 1;
__u64 id = next_id++;
hashmap_insert(pending, id, request_ctx);
sqe->user_data = id;
/* CQE 수확 시 */
struct request_ctx *ctx = hashmap_remove(pending, cqe->user_data);

res 필드: opcode별 결과 의미

res 필드는 요청의 결과를 나타내며, 양수는 성공(바이트 수, fd 번호 등), 0은 EOF 또는 성공, 음수는 -errno 에러 코드입니다. Short read/write는 res > 0이지만 요청한 크기보다 작은 경우로, 에러가 아닌 정상 동작입니다.

Opcode양수 res 의미0 의미음수 res 예시비고
IORING_OP_READ읽은 바이트 수EOF-EIO, -EAGAINshort read 가능
IORING_OP_WRITE쓴 바이트 수(해당 없음)-ENOSPC, -EIOshort write 가능
IORING_OP_READV읽은 총 바이트EOF-EFAULT벡터 I/O
IORING_OP_WRITEV쓴 총 바이트(해당 없음)-ENOSPC벡터 I/O
IORING_OP_ACCEPT새 소켓 fd(해당 없음)-ECONNABORTEDmultishot: F_MORE
IORING_OP_CONNECT(해당 없음)성공-ECONNREFUSEDres == 0이 성공
IORING_OP_RECV수신 바이트 수연결 종료-ENOTCONNmultishot: F_MORE
IORING_OP_SEND송신 바이트 수(해당 없음)-EPIPE, -ECONNRESETshort send 가능
IORING_OP_POLL_ADD발생한 poll 이벤트(해당 없음)-ECANCELED비트마스크
IORING_OP_TIMEOUT(해당 없음)-ETIME(만료)-ECANCELED만료 시 -ETIME
IORING_OP_NOP(해당 없음)성공(없음)항상 0
IORING_OP_OPENAT새 파일 fd(해당 없음)-ENOENT, -EACCESdirect: 고정 fd 인덱스
IORING_OP_CLOSE(해당 없음)성공-EBADF
IORING_OP_FSYNC(해당 없음)성공-EIO
IORING_OP_SENDMSG송신 바이트(해당 없음)-ENOBUFS
IORING_OP_RECVMSG수신 바이트연결 종료-ENOTCONNmultishot 지원
IORING_OP_SPLICE전송 바이트EOF-EINVAL파이프 필요
IORING_OP_PROVIDE_BUFFERS(해당 없음)성공-ENOMEM버퍼 등록
IORING_OP_CANCEL(해당 없음)성공-ENOENT, -EALREADY대상 없으면 -ENOENT

flags 필드: CQE 플래그 전체 참조

CQE의 flags 필드는 완료 결과에 대한 추가 메타데이터를 전달합니다. 하위 비트는 플래그, 상위 16비트는 버퍼 선택(provided buffers) 시 버퍼 ID를 인코딩합니다.

플래그비트설명도입 커널
IORING_CQE_F_BUFFER00x1Provided buffer 사용됨; 상위 16비트 = 버퍼 ID5.7
IORING_CQE_F_MORE10x2Multishot: 추가 CQE가 더 올 예정5.13
IORING_CQE_F_SOCK_NONEMPTY20x4소켓에 아직 읽을 데이터 있음5.19
IORING_CQE_F_NOTIF30x8제로카피 send 완료 알림 (데이터 해제 가능)6.0
/* CQE 플래그 해석 패턴 */
#define IORING_CQE_BUFFER_SHIFT  16

if (cqe->flags & IORING_CQE_F_BUFFER) {
    unsigned buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    void *buf = bufs[buf_id];
    /* buf_id로 실제 버퍼 접근 */
}

if (cqe->flags & IORING_CQE_F_MORE) {
    /* multishot: 아직 더 올 CQE가 있음, SQE 재제출 불필요 */
} else {
    /* 마지막 CQE: multishot 종료, 필요 시 SQE 재제출 */
}

if (cqe->flags & IORING_CQE_F_NOTIF) {
    /* 제로카피 send: 커널이 버퍼를 해제함, 실제 전송 결과 아님 */
    return;  /* 알림만 — res 무시 */
}
ℹ️

교차참조: CQE32 확장 포맷(big_cqe 필드 추가)은 CQE32 / SQE128 확장 포맷, Multishot 동작의 자세한 설명은 Multishot CQE 동작 상세 섹션을 참고하세요.

커널 CQE 게시 내부 경로

커널이 I/O 완료 후 CQE를 사용자에게 전달하는 내부 경로를 추적합니다. 핵심 함수 체인은 io_req_complete_post()io_fill_cqe_req()io_commit_cqring()io_cqring_ev_posted()입니다.

커널 CQE 게시 내부 경로 I/O 완료 (io_req_complete) 배치? compl_reqs 리스트에 추가 io_submit_flush_completions() io_fill_cqe_req() CQ full? 오버플로 리스트 추가 CQE 슬롯에 user_data/res/flags 기록 io_commit_cqring() — smp_store_release(tail) io_cqring_ev_posted() — waitqueue 깨우기 + eventfd 시그널
/* io_fill_cqe_req() — 간소화된 커널 코드 */
static bool io_fill_cqe_req(struct io_ring_ctx *ctx,
                             struct io_kiocb *req)
{
    struct io_uring_cqe *cqe;
    u32 tail = ctx->rings->cq.tail;

    /* CQ 가득 찼는지 확인 */
    if (tail - READ_ONCE(ctx->rings->cq.head) >= ctx->cq_entries) {
        /* 오버플로 리스트에 추가 */
        return io_cqring_event_overflow(ctx, req->cqe.user_data,
                                        req->cqe.res, req->cqe.flags);
    }

    cqe = &ctx->rings->cqes[tail & ctx->cq_mask];
    cqe->user_data = req->cqe.user_data;
    cqe->res       = req->cqe.res;
    cqe->flags     = req->cqe.flags;

    return true;
}

/* io_commit_cqring() — tail을 store_release로 갱신 */
static void io_commit_cqring(struct io_ring_ctx *ctx)
{
    smp_store_release(&ctx->rings->cq.tail,
                      ctx->cached_cq_tail);
}

/* io_cqring_ev_posted() — 대기자 깨우기 */
static void io_cqring_ev_posted(struct io_ring_ctx *ctx)
{
    if (wq_has_sleeper(&ctx->cq_wait))
        wake_up_all(&ctx->cq_wait);
    if (ctx->cq_ev_fd)
        eventfd_signal(ctx->cq_ev_fd, 1);
}
ℹ️

배치 플러시: 성능을 위해 커널은 여러 완료를 compl_reqs 리스트에 모은 뒤 io_submit_flush_completions()에서 한 번에 CQ에 기록합니다. completion_lock은 io-wq 워커와 메인 태스크 간 동시 CQE 게시를 보호합니다.

ℹ️

교차참조: io_kiocb 구조체의 생명주기는 io_kiocb 생명주기, task_work 기반 완료 전달은 task_work 완료 전달 메커니즘 섹션을 참고하세요.

CQE 소비 패턴과 liburing API

liburing은 CQE 수확을 위한 다양한 API를 제공합니다. 상황에 맞는 소비 패턴 선택이 지연시간과 처리량에 직접 영향을 줍니다.

peek vs wait vs batch

CQE 소비 패턴 결정 흐름도 CQE 필요 블로킹? No 배치? No io_uring_peek_cqe() Yes io_uring_peek_batch_cqe() Yes N > 1? No io_uring_wait_cqe() Yes io_uring_wait_cqe_nr(N) CQE 처리 후 head 전진 단일: io_uring_cqe_seen(cqe) — head += 1 배치: io_uring_cq_advance(ring, N) — head += N (store_release 1회)
liburing API동작블로킹반환사용 시나리오
io_uring_peek_cqe()CQ 확인, 없으면 즉시 반환아니오0 또는 -EAGAINbusy-poll 루프
io_uring_wait_cqe()CQE 1개 도착까지 대기0 또는 -errno일반적인 이벤트 루프
io_uring_wait_cqe_nr(N)CQE N개 도착까지 대기0 또는 -errno배치 처리 최적화
io_uring_peek_batch_cqe()가용 CQE 전부 논블로킹 수확아니오수확한 CQE 수고처리량 배치
io_uring_cqe_seen()CQE 1개 소비 완료 표시-void단일 CQE 처리 후
io_uring_cq_advance(N)CQE N개 소비 완료 (head += N)-void배치 처리 후 한 번에
/* 배치 소비 패턴 — 최적 성능 */
struct io_uring_cqe *cqes[256];
unsigned count, i;

/* 최소 1개 대기 후 가용한 만큼 배치 수확 */
io_uring_wait_cqe(&ring, &cqes[0]);
count = io_uring_peek_batch_cqe(&ring, cqes, 256);

for (i = 0; i < count; i++) {
    process_completion(cqes[i]->user_data,
                       cqes[i]->res,
                       cqes[i]->flags);
}
/* 한 번의 store_release로 모든 CQE 소비 완료 */
io_uring_cq_advance(&ring, count);

CQ 크기 선택 전략

CQ 크기는 성능과 메모리 사이의 트레이드오프입니다. 너무 작으면 오버플로가 발생하고, 너무 크면 메모리를 낭비합니다. Multishot 연산은 단일 SQE에서 무한 CQE를 생성할 수 있어 특별한 고려가 필요합니다.

워크로드SQ 크기CQ 배수CQ 엔트리CQE 메모리근거
단순 파일 I/O641282 KB기본값 충분, 순차 I/O
웹 서버 (epoll 대체)256102416 KB다중 연결, 간헐적 burst
DB WAL 쓰기512204832 KBfsync 지연 중 쓰기 누적
Multishot recv 서버2568×~16×2048~409632~64 KB단일 SQE → 다수 CQE
고성능 스토리지 (NVMe)1024409664 KB높은 QD, 빠른 완료
프록시/게이트웨이512409664 KB양방향 I/O, splice 체인
/* IORING_SETUP_CQSIZE로 CQ 크기 커스텀 설정 */
struct io_uring_params params = {};
params.flags = IORING_SETUP_CQSIZE;
params.cq_entries = 4096;  /* 반드시 power-of-2 */

int ring_fd = io_uring_setup(512, &params);
/* 커널이 params.cq_entries를 실제 할당된 값으로 갱신 */

/* liburing 래퍼 */
struct io_uring ring;
struct io_uring_params p = { .flags = IORING_SETUP_CQSIZE, .cq_entries = 4096 };
io_uring_queue_init_params(512, &ring, &p);

/* 동적 크기 산정: multishot 연결 수 기반 */
unsigned sq_size = 256;
unsigned max_multishot_conns = 1000;
unsigned cq_size = next_power_of_2(sq_size * 2 + max_multishot_conns * 4);
/* 최소 SQ×2, multishot당 여유 4 CQE 확보 */
⚠️

IORING_SETUP_CQSIZE 없이 CQ 크기를 지정하면 무시됩니다. 또한 CQ 크기가 SQ 크기보다 작으면 -EINVAL이 반환됩니다. CQ 크기는 커널이 자동으로 다음 power-of-2로 올림합니다.

ℹ️

교차참조: CQ 오버플로 발생 시의 동작은 CQE Overflow 처리CQ 오버플로 내부 메커니즘 섹션을 참고하세요.

CQE 대기 메커니즘 심화

CQE를 대기하는 방법은 여러 가지이며, 각각 지연시간/CPU 사용량 특성이 다릅니다. 워크로드에 맞는 대기 전략 선택이 중요합니다.

CQE 대기 메커니즘 비교 GETEVENTS io_uring_enter( IORING_ENTER_GETEVENTS, min_complete=N) ✓ 기본 블로킹 대기 ✓ 시스콜 1회 ✗ 타임아웃 불가 EXT_ARG io_uring_getevents_arg { sigmask, sigmask_sz, ts (나노초 타임아웃) } ✓ 정밀 타임아웃 ✓ 시그널 마스크 eventfd + epoll eventfd 등록 → epoll_ctl(ADD) → epoll_wait() ✓ 다른 fd와 통합 ✓ 기존 이벤트 루프 ✗ 추가 시스콜 오버헤드 Busy-Poll (peek) while (1) { ret = peek_cqe(); if (ret == 0) break; } ✓ 최저 지연시간 ✓ 시스콜 제로 ✗ CPU 100% 점유 대기 방식 비교 방식 지연시간 CPU 사용 적합 상황 GETEVENTS 중간 (~수 μs) 낮음 (sleep) 범용, 단순 서버 EXT_ARG 중간 (타임아웃) 낮음 타임아웃 필요, 시그널 처리 eventfd+epoll 높음 (~수십 μs) 낮음 기존 이벤트 루프 통합 Busy-Poll 최저 (ns급) 최고 (100%) 초저지연 (HFT, DPDK류)
/* EXT_ARG: 나노초 타임아웃 + 시그널 마스크 */
struct __kernel_timespec ts = { .tv_sec = 0, .tv_nsec = 500000000 }; /* 500ms */
struct io_uring_getevents_arg arg = {
    .sigmask    = (__u64)(uintptr_t)&sigmask,
    .sigmask_sz = sizeof(sigmask),
    .ts         = (__u64)(uintptr_t)&ts,
};
io_uring_enter(ring_fd, 0, 1,
               IORING_ENTER_GETEVENTS | IORING_ENTER_EXT_ARG,
               &arg);
/* 반환: 타임아웃 시 -ETIME, CQE 도착 시 0 */

/* eventfd + ASYNC: 비동기 완료만 시그널 */
int efd = eventfd(0, EFD_NONBLOCK);
io_uring_register_eventfd_async(&ring, efd);
/* epoll에 eventfd 등록 */
struct epoll_event ev = { .events = EPOLLIN, .data.fd = efd };
epoll_ctl(epfd, EPOLL_CTL_ADD, efd, &ev);
ℹ️

DEFER_TASKRUN 모드: IORING_SETUP_DEFER_TASKRUN | IORING_SETUP_SINGLE_ISSUER 설정 시, CQE는 io_uring_enter() 호출 시에만 게시됩니다. 비동기 인터럽트가 없어 예측 가능한 지연을 제공하며, busy-poll과 결합하면 최적의 성능을 달성합니다.

ℹ️

교차참조: eventfd 기반 이벤트 루프 통합의 구체적인 예제는 eventfd 기반 CQ 알림 상세, 이벤트 루프 설계 패턴은 이벤트 루프 설계 패턴 섹션을 참고하세요.

CQ 오버플로 내부 메커니즘

CQ 링이 가득 찬 상태에서 새 CQE가 발생하면 커널은 오버플로 리스트(struct io_overflow_cqe)에 보관합니다. 이 리스트의 생명주기와 플러시/드롭 메커니즘을 상세히 분석합니다.

CQ 오버플로 생명주기 정상 게시 CQ에 빈 슬롯 존재 CQ full 오버플로 리스트 추가 io_cqring_event_overflow() IORING_SQ_CQ_OVERFLOW sq_flags에 설정 유저 CQE 소비 io_cqring_overflow_flush() 리스트 → CQ 링 이동 리스트 비움 정상 복귀 GFP_ATOMIC 실패 CQE 드롭 cq.overflow 카운터 증가 • 오버플로 리스트: struct io_overflow_cqe (list_head + io_uring_cqe 내장) • GFP_ATOMIC 할당 — 인터럽트 컨텍스트에서도 안전, 실패 시 CQE 영구 손실 • 플러시 시점: io_uring_enter(), CQE 소비(head 전진), io_cqring_overflow_flush() 호출 시
/* struct io_overflow_cqe — 오버플로 CQE 보관 구조 */
struct io_overflow_cqe {
    struct list_head list;      /* ctx->cq_overflow_list에 연결 */
    struct io_uring_cqe cqe;    /* CQE 데이터 직접 내장 */
};

/* 오버플로 모니터링 패턴 */
static void check_overflow(struct io_uring *ring) {
    unsigned flags = IO_URING_READ_ONCE(*ring->sq.kflags);

    if (flags & IORING_SQ_CQ_OVERFLOW) {
        /* 오버플로 발생: CQ에서 CQE를 빨리 소비하여 플러시 유도 */
        fprintf(stderr, "CQ overflow detected!\n");

        /* GETEVENTS로 플러시 트리거 */
        io_uring_enter(ring->ring_fd, 0, 0,
                       IORING_ENTER_GETEVENTS, NULL);

        /* 드롭된 CQE 확인 */
        unsigned overflow = *ring->cq.koverflow;
        if (overflow)
            fprintf(stderr, "Dropped %u CQEs!\n", overflow);
    }
}
오버플로 관련 필드/플래그위치의미
IORING_SQ_CQ_OVERFLOWsq_flags (bit 1)오버플로 리스트에 CQE 존재
cq.overflowio_rings드롭된 CQE 누적 카운터 (GFP_ATOMIC 실패 시)
cq_overflow_listio_ring_ctx커널 내부 연결 리스트
IORING_SETUP_NOCLAMPparams.flagsCQ 크기를 SQ 크기로 클램핑하지 않음 (5.8+)
ℹ️

교차참조: 기본 오버플로 처리 개요는 CQE Overflow 처리, 오버플로 예방을 위한 CQ 크기 전략은 CQ 크기 선택 전략 섹션을 참고하세요.

CQE 순서 보장과 완료 의미론

io_uring은 기본적으로 CQE 순서를 보장하지 않습니다. I/O가 완료되는 순서대로 CQE가 게시되므로, 빠른 연산이 느린 연산보다 먼저 완료될 수 있습니다. 링크와 드레인 플래그로 순서를 강제할 수 있습니다.

CQE 순서 보장 시나리오 1. 독립 SQE (기본) SQE A SQE B SQE C CQE 순서: B → C → A (임의) 순서 보장 없음 user_data로 식별 2. IOSQE_IO_LINK SQE A SQE B SQE C CQE 순서: A → B → C (보장) A 실패 시 B, C = -ECANCELED 소프트 링크 3. IOSQE_IO_DRAIN SQE A SQE B DRAIN C A,B 완료 후 C 시작 배리어 역할 이전 SQE 전부 완료 보장 IOSQE_IO_HARDLINK (하드링크) • 소프트 링크(IO_LINK)와 동일하게 순서 보장하되, 앞선 SQE가 실패해도 체인을 계속 진행 • 사용 사례: read → timeout 연결 (타임아웃 만료 시 -ETIME이지만 read는 실행) • A(HARDLINK) → B(HARDLINK) → C: A 실패(-EIO)해도 B 실행, B 실패해도 C 실행
플래그순서 보장실패 동작사용 사례
(없음) 독립 SQE없음개별 처리병렬 I/O, 최대 처리량
IOSQE_IO_LINK체인 순서 보장실패 시 후속 -ECANCELEDread → process → write 파이프라인
IOSQE_IO_HARDLINK체인 순서 보장실패해도 체인 계속read → timeout (타임아웃 제한 읽기)
IOSQE_IO_DRAIN이전 전체 완료 보장개별 처리배리어 (fsync 전 쓰기 완료 보장)
⚠️

SQPOLL 모드에서의 순서: SQPOLL 모드에서도 링크/드레인 의미론은 동일하게 유지됩니다. 다만 IOSQE_ASYNC 플래그가 설정된 SQE는 io-wq 워커에서 실행되므로, 독립 SQE 간 완료 순서가 더욱 비결정적입니다.

ℹ️

교차참조: SQE 링크 체인의 상세한 설명은 고급 기능 섹션의 링크 부분을 참고하세요.

Multishot CQE 동작 상세

Multishot 연산은 하나의 SQE 제출로 다수의 CQE를 스트림으로 수신합니다. IORING_CQE_F_MORE 플래그가 설정된 CQE는 "아직 더 올 CQE가 있다"는 의미이며, 이 플래그 없이 도착한 CQE가 스트림의 종료를 나타냅니다.

Multishot CQE 스트림 (accept 예시) SQE: ACCEPT flags |= IOSQE_MULTISHOT CQE #1 res=fd, F_MORE ✓ CQE #2 res=fd, F_MORE ✓ CQE #N res=fd, F_MORE ✓ 종료 CQE F_MORE ✗ Provided Buffer 연동 (recv multishot) flags = IORING_CQE_F_BUFFER | IORING_CQE_F_MORE | (buf_id << 16) buf_id = (cqe→flags >> IORING_CQE_BUFFER_SHIFT) — 커널이 선택한 버퍼 ID CQ 압력: multishot이 CQE를 빠르게 채우면 오버플로 위험 → CQ 크기 여유 확보 필수 Multishot 지원 Opcode별 CQE 의미 Opcode CQE res 의미 종료 조건 ACCEPT 새 소켓 fd 에러 발생 또는 취소 RECV 수신 바이트 수 (F_BUFFER) 연결 종료, 에러, 취소 RECVMSG 수신 바이트 수 에러, 취소 POLL_ADD poll 이벤트 비트마스크 취소
/* Multishot recv CQE 처리 루프 */
void handle_multishot_recv(struct io_uring *ring,
                           struct io_uring_cqe *cqe,
                           int sock_fd)
{
    if (cqe->res < 0) {
        /* 에러: -ECONNRESET, -ECANCELED 등 */
        fprintf(stderr, "multishot recv error: %d\n", cqe->res);
        if (!(cqe->flags & IORING_CQE_F_MORE))
            resubmit_multishot_recv(ring, sock_fd);
        return;
    }

    if (cqe->res == 0) {
        /* 연결 종료 (EOF) */
        close_connection(sock_fd);
        return;
    }

    /* 성공: 버퍼 ID 추출 */
    if (cqe->flags & IORING_CQE_F_BUFFER) {
        unsigned buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
        void *buf = provided_bufs[buf_id];
        process_data(buf, cqe->res);
        replenish_buffer(ring, buf_id);  /* 버퍼 재공급 */
    }

    /* F_MORE 없으면 multishot 종료 → 재제출 */
    if (!(cqe->flags & IORING_CQE_F_MORE)) {
        resubmit_multishot_recv(ring, sock_fd);
    }
}
ℹ️

교차참조: Provided buffers 메커니즘의 상세한 설명은 고급 기능 섹션을, CQ 크기와 multishot의 관계는 CQ 크기 선택 전략 섹션을 참고하세요.

CQ 성능 최적화

배치 소비와 tail advance 최적화

CQE를 하나씩 io_uring_cqe_seen()으로 소비하면 매번 smp_store_release가 발생합니다. 배치로 처리 후 한 번의 io_uring_cq_advance()를 호출하면 메모리 배리어 비용을 최소화할 수 있습니다.

CQE 캐시라인 정렬 (64B 캐시라인) 캐시라인 #0 (offset 0~63) CQE[0] (16B) user_data|res|flags CQE[1] (16B) user_data|res|flags CQE[2] (16B) user_data|res|flags CQE[3] (16B) user_data|res|flags 캐시라인 #1 (offset 64~127) CQE[4] (16B) CQE[5] (16B) CQE[6] (16B) CQE[7] (16B) • CQE = 16바이트 → 64B 캐시라인에 4개 CQE 밀집 (캐시 효율 우수) • 배치 소비 시 같은 캐시라인의 CQE를 연속 접근 → L1 캐시 히트율 극대화 • CQE32(32B)는 캐시라인당 2개: 확장 필드가 필요 없다면 기본 CQE16 권장

DEFER_TASKRUN과 CQ 성능

IORING_SETUP_DEFER_TASKRUN | IORING_SETUP_SINGLE_ISSUER 조합은 CQE 게시를 io_uring_enter() 호출 시점으로 지연시킵니다. 이를 통해 task_work 인터럽트 없이 예측 가능한 지연시간을 제공합니다.

일반 모드 vs DEFER_TASKRUN 타이밍 일반 모드 (task_work) 유저 작업 IRQ 유저 작업 IRQ 유저 작업 CQE 처리 ↑ 비동기 인터럽트로 CQE 게시 (비예측적) DEFER_TASKRUN 모드 유저 작업 (인터럽트 없음) enter() CQE 일괄 처리 유저 작업 (인터럽트 없음) ↑ io_uring_enter() 시에만 CQE 게시 (예측 가능) 성능 특성 비교 일반: 낮은 평균 지연, 높은 tail 지연 (인터럽트 jitter) DEFER_TASKRUN: 약간 높은 평균 지연, 낮은 tail 지연 (예측 가능, 배치 효율)
CQ 성능 튜닝 파라미터효과트레이드오프
배치 소비 (cq_advance)store_release 횟수 감소소비 지연 증가
DEFER_TASKRUN인터럽트 제거, 예측 가능평균 지연 소폭 증가
SINGLE_ISSUERlock-free 경로 활성단일 스레드 제한
CQ 크기 확대오버플로 방지메모리 사용 증가
Busy-poll + peek최저 지연시간CPU 100% 사용
CQE16 (기본)캐시라인당 4개 밀집확장 필드 사용 불가
/* DEFER_TASKRUN 최적 폴링 루프 */
struct io_uring ring;
struct io_uring_params p = {
    .flags = IORING_SETUP_DEFER_TASKRUN
           | IORING_SETUP_SINGLE_ISSUER
           | IORING_SETUP_CQSIZE,
    .cq_entries = 4096,
};
io_uring_queue_init_params(512, &ring, &p);

while (running) {
    /* 제출할 SQE 준비 */
    prepare_submissions(&ring);
    io_uring_submit(&ring);

    /* CQE 대기 — DEFER_TASKRUN이므로 이 시점에 CQE 게시 */
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);

    /* 배치 수확 */
    struct io_uring_cqe *cqes[256];
    unsigned n = io_uring_peek_batch_cqe(&ring, cqes, 256);

    for (unsigned i = 0; i < n; i++)
        dispatch_cqe(cqes[i]);

    io_uring_cq_advance(&ring, n);
}

CQE 에러 처리 패턴

견고한 io_uring 애플리케이션은 CQE의 res 값을 체계적으로 분류하고 적절히 처리해야 합니다. 양수(성공), 0(EOF/성공), 음수(에러)의 세 가지 범주와 각 에러 코드별 복구 전략을 정리합니다.

CQE 결과 해석 결정 트리 cqe→res 확인 res < 0? Yes -EAGAIN? Yes 재제출 No -ECANCELED? 정리/무시 에러 로그 + 복구 No res == 0? Yes EOF 또는 성공 (NOP 등) No (양수) res < len? No 완전 완료 Yes Short read/write offset 조정 후 재제출
에러 코드카테고리원인복구 전략
-EAGAIN일반자원 일시 부족재제출 (IOSQE_ASYNC 또는 지연 후)
-ECANCELED일반요청 취소됨 (CANCEL/링크 실패)정리, 필요 시 재제출
-ETIME타임아웃타임아웃 만료타임아웃 정상 처리
-EINVAL일반잘못된 파라미터SQE 파라미터 검증 (프로그래밍 오류)
-EBADF파일 I/O잘못된 fdfd 유효성 검증
-EIO파일 I/O디스크/하드웨어 오류재시도 또는 대체 경로
-ENOSPC파일 I/O디스크 공간 부족알림, 공간 확보 후 재시도
-EFAULT일반잘못된 사용자 주소버퍼 주소 검증 (프로그래밍 오류)
-ECONNRESET네트워크상대방이 연결 리셋연결 정리, 재연결
-ECONNREFUSED네트워크연결 거부재시도 (backoff)
-EPIPE네트워크깨진 파이프/소켓연결 정리
-ENOBUFS네트워크버퍼 부족provided buffer 보충
-ENOENT취소취소 대상 없음이미 완료됨, 무시
-EALREADY취소이미 취소 진행 중무시, CQE 대기
/* 견고한 CQE 에러 처리 함수 */
static void handle_cqe_result(struct io_uring *ring,
                               struct io_uring_cqe *cqe,
                               struct request *req)
{
    if (cqe->res < 0) {
        switch (-cqe->res) {
        case EAGAIN:
            /* 자원 부족: IOSQE_ASYNC로 재제출 */
            req->flags |= IOSQE_ASYNC;
            resubmit_request(ring, req);
            return;
        case ECANCELED:
            /* 취소됨: 자원 정리 */
            cleanup_request(req);
            return;
        case ECONNRESET:
        case EPIPE:
            /* 연결 끊김: 소켓 정리 */
            close_connection(req->fd);
            free_request(req);
            return;
        default:
            fprintf(stderr, "I/O error: %s (fd=%d)\n",
                    strerror(-cqe->res), req->fd);
            free_request(req);
            return;
        }
    }

    if (cqe->res == 0 && req->op == OP_READ) {
        /* EOF */
        handle_eof(req);
        return;
    }

    /* Short read/write 처리 */
    if (cqe->res > 0 && (unsigned)cqe->res < req->len) {
        /* 부분 완료: offset 조정 후 나머지 재제출 */
        req->buf  += cqe->res;
        req->len  -= cqe->res;
        req->off  += cqe->res;
        resubmit_request(ring, req);
        return;
    }

    /* 완전 완료 */
    complete_request(req, cqe->res);
}

eventfd 기반 CQ 알림 상세

eventfd를 io_uring에 등록하면 CQE 게시 시 자동으로 시그널이 발생합니다. 이를 통해 기존 epoll 기반 이벤트 루프에 io_uring을 통합할 수 있습니다. 일반 모드와 ASYNC 모드의 차이를 이해하는 것이 중요합니다.

eventfd CQ 알림 경로 I/O 완료 io_cqring_ev_posted() 등록 모드? REGISTER_EVENTFD 일반 모드 모든 CQE에 시그널 인라인 + io-wq REGISTER_EVENTFD_ASYNC ASYNC 모드 인라인? Yes 시그널 스킵 No 비동기(io-wq) 완료만 시그널 전달 인라인 완료 시 스킵 eventfd_signal(ctx→cq_ev_fd, 1) epoll_wait() → EPOLLIN → CQE 수확 ASYNC 모드 이점 인라인 완료는 이미 io_uring_enter()에서 처리됨 → 불필요한 eventfd 시그널 제거
/* eventfd 등록 + epoll 통합 */
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

/* 일반 eventfd: 모든 CQE에 시그널 */
int efd = eventfd(0, EFD_NONBLOCK);
io_uring_register_eventfd(&ring, efd);

/* 또는 ASYNC 변형: 비동기 완료만 시그널 */
/* io_uring_register_eventfd_async(&ring, efd); */

/* epoll에 eventfd 추가 */
int epfd = epoll_create1(0);
struct epoll_event ev = {
    .events = EPOLLIN,
    .data.fd = efd,
};
epoll_ctl(epfd, EPOLL_CTL_ADD, efd, &ev);

/* 이벤트 루프: io_uring + 다른 fd 통합 */
while (running) {
    struct epoll_event events[64];
    int n = epoll_wait(epfd, events, 64, -1);

    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == efd) {
            /* eventfd 시그널: io_uring CQE 도착 */
            uint64_t val;
            read(efd, &val, sizeof(val));  /* eventfd 소비 */

            struct io_uring_cqe *cqe;
            while (io_uring_peek_cqe(&ring, &cqe) == 0) {
                process_cqe(cqe);
                io_uring_cqe_seen(&ring, cqe);
            }
        } else {
            /* 다른 fd 이벤트 처리 */
            handle_other_fd(events[i].data.fd);
        }
    }
}

/* eventfd 해제 */
io_uring_unregister_eventfd(&ring);
close(efd);
⚠️

eventfd 성능 비용: 매 CQE마다 eventfd_signal()이 호출되면 시스콜 오버헤드가 발생합니다. 고성능 시나리오에서는 REGISTER_EVENTFD_ASYNC를 사용하거나, DEFER_TASKRUN으로 전환하여 eventfd 없이 직접 io_uring_enter()로 CQE를 수확하는 것이 좋습니다.

ℹ️

DEFER_TASKRUN과의 상호작용: DEFER_TASKRUN 모드에서는 CQE가 io_uring_enter() 시점에만 게시되므로, eventfd 시그널도 그 시점에 발생합니다. 이 경우 eventfd는 비동기 알림이 아닌 동기적 확인 용도로만 유용하며, 대부분 불필요합니다.

ℹ️

교차참조: 이벤트 루프 통합의 전체 설계 패턴은 이벤트 루프 설계 패턴 섹션을 참고하세요.

커널 내부 구현

/* io_uring/io_uring.c - 핵심 커널 자료구조 */
struct io_ring_ctx {
    struct {
        unsigned int        flags;
        unsigned int        sq_entries;
        unsigned int        cq_entries;
        struct io_rings     *rings;
        struct io_uring_sqe *sq_sqes;
    } ____cacheline_aligned_in_smp;

    struct io_sq_data    *sq_data;   /* SQPOLL 스레드 */
    struct io_wq        *io_wq;     /* 비동기 워커 풀 */
    struct io_rsrc_data *file_data;  /* 고정 파일 */
    struct io_rsrc_data *buf_data;   /* 고정 버퍼 */
};

/* SQE 처리 흐름 */
io_uring_enter()
  → io_submit_sqes()
      → io_get_sqe()         /* SQ에서 SQE 가져오기 */io_init_req()        /* SQE → io_kiocb 변환 */io_issue_sqe()       /* opcode별 핸들러 디스패치 */io_read() → vfs_read()
          → io_req_complete()  /* 즉시 완료: CQE 게시 */io_queue_async()   /* 블로킹: io-wq에 위임 */

io_kiocb 생명주기

io_kiocb는 io_uring 내부에서 각 I/O 요청을 추적하는 핵심 구조체입니다.

struct io_kiocb {
    struct file        *file;
    u8                  opcode;
    u64                 user_data;
    s32                 result;
    struct io_ring_ctx  *ctx;
    struct task_struct  *task;
    struct io_kiocb     *link;
    struct io_wq_work   work;
};
io_kiocb 생명주기 ① io_alloc_req() SQE 파싱 시 slab 캐시에서 할당 ② io_init_req() SQE → io_kiocb 필드 복사, opcode 결정 ③ io_issue_sqe() opcode별 핸들러 호출 (io_read, io_write ...) 동기 완료 비동기 즉시 결과 사용 가능 io_queue_async() io-wq 워커에 전달 ④ io_req_complete() CQE(완료 큐)에 결과 기록 → 사용자 알림 ⑤ io_req_task_complete() task_work로 완료 처리 (시그널/고아 처리) ⑥ io_free_req() — slab 캐시 풀로 반환
ℹ️

task_work 메커니즘: io_uring은 완료 처리를 제출자 태스크의 컨텍스트에서 수행하기 위해 task_work_add()를 사용합니다. DEFER_TASKRUN 플래그를 사용하면 task_work가 io_uring_enter() 호출 시에만 일괄 실행되어 효율이 높아집니다.

io-wq 워커 스레드 풀

즉시 완료되지 않는 (블로킹) 요청은 io-wq 커널 워커 스레드 풀로 넘겨집니다.

io-wq 워커 스레드 풀 스케줄링 SQ Ring SQE 1: read() SQE 2: write() io_issue_sqe() 즉시 완료 가능? Fast Path 즉시 CQE 생성 O_DIRECT ready Slow Path io-wq로 전달 블로킹 필요 io-wq Work Queue 작업 대기열 Hash로 분산 (per-NUMA) 동일 파일은 순서 보장 Bounded Workers 용도: buffered read/write Worker 1 Worker 2 ... 최대: RLIMIT_NPROC Unbounded Workers 용도: network I/O, poll 대기 Worker 1 Worker 2 Worker N 동적 생성/제거 워커 스레드 동작 1. 작업 큐에서 work 가져오기 2. I/O 수행 (블로킹 가능) 3. CQE 작성 유휴 시간 초과 → 스레드 종료 CQ Ring (완료 큐) 모든 경로(Fast/Slow)의 CQE가 여기로 집결
워커 유형용도최대 수
Bounded블로킹 파일 I/O (buffered read/write)RLIMIT_NPROC 기반
Unbounded네트워크 I/O, 긴 대기 작업별도 제한
# 실행 중인 io-wq 워커 확인
ps -eo pid,comm | grep io_uring
ls /proc/<pid>/task/ | wc -l

task_work 완료 전달 메커니즘

io_uring은 I/O 완료를 제출자 태스크의 컨텍스트에서 처리하기 위해 커널의 task_work 메커니즘을 사용합니다. 완료가 어떤 컨텍스트(IRQ, softirq, 워커 스레드)에서 발생하든, 최종 CQE 게시와 사용자 알림은 제출자 태스크에서 실행됩니다.

task_work 기반 CQE 완료 전달 경로 I/O 완료 (IRQ) io-wq 워커 완료 poll 이벤트 task_work_add() 제출자 태스크의 task_work 큐에 추가 기본 모드 (TWA_SIGNAL) task_work 추가 시 TIF_NOTIFY_SIGNAL 설정 태스크가 커널→유저 전환 시 task_work 실행 즉시 처리되지만 IPI 오버헤드 발생 가능 COOP_TASKRUN 모드 시그널 없이 IORING_SQ_TASKRUN 플래그 설정 사용자가 io_uring_enter() 호출 시 task_work 실행 IPI 제거, 폴링 루프에 적합 DEFER_TASKRUN 모드 (최적) SINGLE_ISSUER 필수, task_work를 적립 io_uring_enter(GETEVENTS) 시 일괄 실행 배치 CQE 게시 → 캐시 효율 극대화 가장 낮은 오버헤드, 예측 가능한 지연 CQE 게시 → 사용자 공간에서 CQ tail 확인 → 결과 처리
task_work를 통한 CQE 완료 전달: 기본/COOP_TASKRUN/DEFER_TASKRUN 모드 비교
완료 전달 모드플래그task_work 실행 시점IPI적합 상황
기본(없음)커널→유저 전환 시 즉시발생범용, 단순 사용
COOP_TASKRUNIORING_SETUP_COOP_TASKRUNio_uring_enter() 호출 시없음폴링 루프 앱
DEFER_TASKRUNIORING_SETUP_DEFER_TASKRUNio_uring_enter(GETEVENTS) 시 일괄없음최고 성능 (SINGLE_ISSUER 필요)
/* DEFER_TASKRUN 최적 설정 */
struct io_uring_params params = {
    .flags = IORING_SETUP_SINGLE_ISSUER
           | IORING_SETUP_DEFER_TASKRUN
           | IORING_SETUP_COOP_TASKRUN,  /* DEFER에 포함되지만 명시적 설정 */
};
io_uring_queue_init_params(256, &ring, &params);

/* 이벤트 루프: GETEVENTS 시 적립된 task_work 일괄 실행 */
while (1) {
    io_uring_submit_and_wait(&ring, 1);
    /* ↑ 여기서 적립된 모든 task_work가 한번에 실행됨
     *   → CQE가 배치로 게시 → 한 번의 CQ 순회로 모두 처리 */

    struct io_uring_cqe *cqe;
    unsigned head, count = 0;
    io_uring_for_each_cqe(&ring, head, cqe) {
        process(cqe);
        count++;
    }
    io_uring_cq_advance(&ring, count);
}

io_uring 커널 소스 구조

io_uring 코드는 Linux 5.20(6.0) 이후 fs/io_uring.c 단일 파일(~27,000줄)에서 io_uring/ 디렉토리로 분리되어 약 30개 파일로 모듈화되었습니다.

io_uring/ 커널 소스 구조 (v6.x) io_uring.c / io_uring.h 핵심: SQE 파싱, 디스패치, CQE 게시, ring 관리 sqpoll.c SQPOLL 스레드 io-wq.c / io-wq.h 워커 스레드 풀 rsrc.c 리소스 등록 kbuf.c Provided 버퍼 fdinfo.c 디버그 정보 rw.c net.c poll.c timeout.c cancel.c msg_ring.c splice.c fs.c openclose.c uring_cmd.c statx.c xattr.c nop.c futex.c 각 파일이 특정 opcode 그룹의 핸들러를 구현 소스 규모 (v6.8 기준) 약 30개 파일 · 총 ~35,000줄 · opcode 핸들러 50+ · io_uring.c만 ~5,000줄
io_uring/ 디렉토리 구조: 핵심 모듈, 인프라, opcode 핸들러
파일역할주요 함수
io_uring.c핵심: ring 생성/파괴, SQE 디스패치, CQE 게시io_uring_setup(), io_submit_sqes()
sqpoll.cSQPOLL 커널 스레드 관리io_sq_thread(), 슬립/웨이크업
io-wq.c비동기 워커 스레드 풀io_wq_enqueue(), 워커 생성/소멸
rsrc.c파일/버퍼 등록, 리소스 수명 관리io_register_files(), io_register_buffers()
kbuf.cProvided buffer ring 관리io_provide_buffers()
rw.cread/write/readv/writev 핸들러io_read(), io_write()
net.csend/recv/accept/connect 핸들러io_sendmsg(), io_accept()
poll.cpoll_add/poll_remove 핸들러io_poll_add(), multishot poll
timeout.ctimeout/link_timeout 핸들러io_timeout()
cancel.casync_cancel 핸들러io_async_cancel()
msg_ring.cMSG_RING 링 간 통신io_msg_ring()
uring_cmd.cio_uring_cmd passthroughio_uring_cmd()
splice.csplice/tee 핸들러io_splice(), io_tee()
openclose.copen/close/ftruncate 핸들러io_openat(), io_close()
fdinfo.c/proc/PID/fdinfo 출력디버깅용 정보 출력

liburing 사용 예제

liburing은 io_uring 시스템 콜의 저수준 복잡성을 추상화하는 헬퍼 라이브러리입니다.

#include <liburing.h>

int main(void) {
    struct io_uring ring;
    struct io_uring_sqe *sqe;
    struct io_uring_cqe *cqe;
    char buf[4096];

    io_uring_queue_init(256, &ring, 0);

    sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
    io_uring_sqe_set_data(sqe, my_context);

    io_uring_submit(&ring);

    io_uring_wait_cqe(&ring, &cqe);
    if (cqe->res < 0)
        fprintf(stderr, "I/O error: %s\\n", strerror(-cqe->res));
    else
        printf("Read %d bytes\\n", cqe->res);

    io_uring_cqe_seen(&ring, cqe);
    io_uring_queue_exit(&ring);
    return 0;
}

SQPOLL 모드 설정 예제

SQPOLL 모드를 사용하면 커널 스레드가 SQ를 폴링하여 시스템 콜 없이 I/O를 처리합니다.

#include <liburing.h>

int setup_sqpoll_ring(struct io_uring *ring) {
    struct io_uring_params params;
    memset(&params, 0, sizeof(params));

    params.flags = IORING_SETUP_SQPOLL;
    params.sq_thread_idle = 2000;  /* 2초 유휴 시 슬립 */
    params.sq_thread_cpu = 2;       /* CPU 2번에 고정 (선택) */

    return io_uring_queue_init_params(256, ring, &params);
}

void submit_sqpoll(struct io_uring *ring, int fd) {
    struct io_uring_sqe *sqe;
    char buf[4096];

    sqe = io_uring_get_sqe(ring);
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);

    /* SQPOLL 모드에서는 submit()만으로 충분 (syscall 없음) */
    io_uring_submit(ring);  /* 커널 스레드가 자동 처리 */

    /* 만약 커널 스레드가 슬립했다면 깨워야 함 */
    if (io_uring_sq_ring_needs_wakeup(ring)) {
        io_uring_enter(ring->ring_fd, 0, 0,
                        IORING_ENTER_SQ_WAKEUP, NULL);
    }
}

Multishot Accept 예제

Multishot accept는 하나의 SQE로 여러 연결을 처리합니다 (커널 5.19+).

#include <liburing.h>

int start_multishot_accept(struct io_uring *ring, int listen_fd) {
    struct io_uring_sqe *sqe;

    sqe = io_uring_get_sqe(ring);
    io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)listen_fd);

    io_uring_submit(ring);

    /* 이벤트 루프 */
    while (1) {
        struct io_uring_cqe *cqe;
        int ret = io_uring_wait_cqe(ring, &cqe);

        if (ret < 0) break;

        if (cqe->res < 0) {
            fprintf(stderr, "Accept error: %s\n", strerror(-cqe->res));
        } else {
            int client_fd = cqe->res;
            printf("Accepted connection: fd=%d\n", client_fd);

            /* 클라이언트 처리... */
            handle_client(ring, client_fd);
        }

        /* Multishot: CQE에 IORING_CQE_F_MORE 플래그가 있으면 계속 accept */
        if (!(cqe->flags & IORING_CQE_F_MORE)) {
            printf("Multishot accept ended, resubmitting...\n");
            start_multishot_accept(ring, listen_fd);
        }

        io_uring_cqe_seen(ring, cqe);
    }
}

Fixed Files 사용 예제

파일 디스크립터를 사전 등록하여 매 I/O마다 fd 조회 오버헤드를 제거합니다.

int use_fixed_files(struct io_uring *ring, int *fds, int nr_files) {
    /* 파일 디스크립터 배열 등록 */
    int ret = io_uring_register_files(ring, fds, nr_files);
    if (ret < 0) return ret;

    /* I/O 수행 시 fixed file index 사용 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    char buf[4096];

    io_uring_prep_read(sqe, 0, buf, sizeof(buf), 0);  /* index 0 사용 */
    sqe->flags |= IOSQE_FIXED_FILE;  /* Fixed file 플래그 */

    io_uring_submit(ring);

    /* 정리 */
    io_uring_unregister_files(ring);
    return 0;
}

Multi-ring 패턴

하나의 프로세스에서 여러 io_uring 인스턴스를 사용하면 잠금 경합 회피, I/O 유형별 격리, NUMA 최적화 등의 이점을 얻을 수 있습니다.

Multi-ring 패턴: Per-thread + 공유 Worker Pool Thread 1 (Storage) Ring A SINGLE_ISSUER SQ/CQ read, write, fsync 전용 IOPOLL + SQPOLL Thread 2 (Network) Ring B SINGLE_ISSUER SQ/CQ accept, recv, send 전용 multishot + provided buffers Thread 3 (Timer) Ring C ATTACH_WQ SQ/CQ timeout, nop, msg_ring Ring A의 워커 풀 공유 공유 io-wq Worker Pool IORING_SETUP_ATTACH_WQ로 Ring A의 워커 풀을 Ring B, C가 공유 워커 스레드 총 수를 제어하여 리소스 낭비 방지 REGISTER_IOWQ_MAX_WORKERS로 bounded/unbounded 각각 상한 설정 MSG_RING MSG_RING
Per-thread ring + ATTACH_WQ 공유 워커 풀 + MSG_RING 링 간 통신
/* Per-thread ring 설정 + ATTACH_WQ로 워커 풀 공유 */
struct io_uring primary_ring, secondary_ring;

/* 1. 메인 ring 생성 (워커 풀 소유) */
struct io_uring_params p1 = {
    .flags = IORING_SETUP_SINGLE_ISSUER
           | IORING_SETUP_DEFER_TASKRUN,
};
io_uring_queue_init_params(256, &primary_ring, &p1);

/* 워커 수 제한: [bounded=8, unbounded=4] */
unsigned int workers[2] = { 8, 4 };
io_uring_register_iowq_max_workers(&primary_ring, workers);

/* 2. 보조 ring 생성 (메인 ring의 워커 풀 공유) */
struct io_uring_params p2 = {
    .flags = IORING_SETUP_SINGLE_ISSUER
           | IORING_SETUP_ATTACH_WQ,
    .wq_fd = primary_ring.ring_fd,  /* 메인 ring의 fd */
};
io_uring_queue_init_params(128, &secondary_ring, &p2);
접근 방식장점단점적합 상황
단일 Ring단순, 관리 쉬움멀티스레드 시 잠금 필요단일 스레드 앱
Per-thread Ring잠금 없음, SINGLE_ISSUER 가능워커 풀 분산멀티스레드 고성능
Per-thread + ATTACH_WQ잠금 없음 + 워커 풀 공유약간의 설정 복잡도멀티스레드 최적 구성
I/O 유형별 Ring스토리지/네트워크 설정 독립관리 복잡혼합 워크로드 (DB 등)
💡

SINGLE_ISSUER 성능 효과: IORING_SETUP_SINGLE_ISSUER는 단일 태스크만 SQE를 제출함을 커널에 보장하여, 내부 잠금을 제거합니다. Per-thread ring에서 반드시 설정하세요. DEFER_TASKRUN과 함께 사용하면 완료 처리도 일괄적으로 수행되어 최고 효율을 달성합니다.

이벤트 루프 설계 패턴

io_uring은 Proactor 패턴(비동기 I/O 완료 통지)을 따르며, epoll의 Reactor 패턴(준비 상태 통지)과 근본적으로 다릅니다.

Reactor (epoll) Proactor (io_uring) epoll_wait() 이벤트 수집 fd ready 확인 read()/write() 호출 처리 로직 실행 통지 내용: "fd가 읽기 가능" 실제 I/O는 직접 수행 블로킹 가능 (파일 I/O) 시스템 콜: 2+회/이벤트 SQE 배치 작성 io_uring_submit_and_wait CQE 수확 완료 결과 처리 새 SQE 등록 통지 내용: "I/O 완료됨" I/O는 커널이 수행 완료 블로킹 없음 (io-wq 자동) 시스템 콜: 0~1회/배치
Reactor (준비 통지) vs Proactor (완료 통지) 이벤트 루프 모델 비교

io_uring 이벤트 루프 스켈레톤

#include <liburing.h>

/* 4단계 이벤트 루프: 준비 → 제출+대기 → 수확 → 디스패치 */
void event_loop(struct io_uring *ring) {
    while (!shutdown_requested) {
        /* 1단계: 새 SQE 준비 (타이머, accept 재등록 등) */
        prepare_pending_ops(ring);

        /* 2단계: 배치 제출 + 최소 1개 완료 대기 */
        io_uring_submit_and_wait(ring, 1);

        /* 3단계: CQE 수확 (배치 처리) */
        struct io_uring_cqe *cqe;
        unsigned head;
        unsigned count = 0;

        io_uring_for_each_cqe(ring, head, cqe) {
            /* 4단계: user_data 기반 디스패치 */
            struct request *req = (struct request *)cqe->user_data;
            switch (req->type) {
            case REQ_ACCEPT:
                handle_accept(ring, cqe); break;
            case REQ_READ:
                handle_read(ring, cqe);   break;
            case REQ_WRITE:
                handle_write(ring, cqe);  break;
            case REQ_TIMEOUT:
                handle_timeout(ring, cqe); break;
            }
            count++;
        }
        io_uring_cq_advance(ring, count);
    }
}

/* user_data 인코딩 전략:
 * - 구조체 포인터: (struct request *)sqe->user_data
 * - 인덱스 + 타입: upper 8bit = type, lower 56bit = index
 * - 태그: 고유 ID로 해시맵에서 컨텍스트 조회
 */

eventfd 통합: 기존 이벤트 루프와 연동

기존 epoll/select 기반 이벤트 루프에 io_uring을 통합하려면, eventfd를 등록하여 CQE 완료 시 epoll에 통지할 수 있습니다.

int efd = eventfd(0, EFD_NONBLOCK);

/* io_uring에 eventfd 등록: CQE 완료 시 efd에 시그널 */
io_uring_register_eventfd(&ring, efd);

/* epoll에 eventfd 추가 */
struct epoll_event ev = { .events = EPOLLIN, .data.fd = efd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, efd, &ev);

/* 이벤트 루프에서 io_uring 완료를 epoll로 감지 */
while (1) {
    struct epoll_event events[64];
    int n = epoll_wait(epoll_fd, events, 64, -1);

    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == efd) {
            uint64_t val;
            read(efd, &val, sizeof(val));
            /* io_uring CQE 처리 */
            drain_cqes(&ring);
        } else {
            /* 기존 소켓 이벤트 처리 */
        }
    }
}
ℹ️

EVENTFD_ASYNC: IORING_REGISTER_EVENTFD_ASYNC를 사용하면 비동기로 완료된 요청에 대해서만 eventfd 시그널이 발생합니다. 동기 완료(즉시 결과 반환) 시에는 시그널이 불필요하므로, 이 옵션으로 불필요한 eventfd 쓰기를 줄일 수 있습니다.

성능 비교

I/O 방식시스템 콜/요청컨텍스트 스위치특징
동기 read/write1블로킹 시 발생단순, 저처리량
epoll + 논블로킹2+ (epoll_wait + read)이벤트 기반네트워크에 적합, 파일 I/O 제한
Linux AIO (io_submit)2 (submit + getevents)최소O_DIRECT 전용, 제한적
io_uring (기본)1 (io_uring_enter)최소범용, 배치 제출
io_uring (SQPOLL)0없음최고 성능, CPU 사용
io_uring (SQPOLL+IOPOLL)0없음극한 저지연 (NVMe)
💡

NVMe SSD에서 SQPOLL+IOPOLL 모드는 동기 I/O 대비 IOPS 2~5배, 지연 시간 50% 이상 감소를 달성할 수 있습니다. 단, CAP_SYS_NICE 권한이 필요하며 유휴 시에도 CPU를 소비합니다.

벤치마크 데이터: libaio vs io_uring

4KB 랜덤 읽기, NVMe SSD (Samsung 980 PRO), QD=128 환경에서 측정한 실제 성능 비교:

I/O 방식IOPS (K)평균 지연 (μs)CPU 사용률 (%)비고
libaio (io_submit)38533282O_DIRECT 필수
io_uring (기본)47227178+22.6% IOPS
io_uring (SQPOLL)53124192+37.9% IOPS, 1 CPU 전용
io_uring (SQPOLL+IOPOLL)624205145+62.1% IOPS, 인터럽트 제거
io_uring (SQPOLL+IOPOLL+FIXEDFILE)698183148+81.3% IOPS, fd 조회 제거

순차 읽기 성능 (128KB, QD=32, buffered I/O):

I/O 방식처리량 (GB/s)시스템 콜 수/초특징
read() 동기2.116,800스레드 풀 필요
libaioN/A-buffered I/O 미지원
io_uring (기본)4.81,200배치 제출 효과
io_uring (SQPOLL)5.60Zero syscall
ℹ️

벤치마크 해석 주의: SQPOLL+IOPOLL은 전용 CPU 코어를 100% 소비하므로, 실제 애플리케이션에서는 총 시스템 처리량과 CPU 효율성을 함께 고려해야 합니다. 워크로드가 충분히 높지 않으면 오히려 비효율적일 수 있습니다.

io_uring vs epoll 상세 비교

epoll 모델 io_uring 모델 epoll_wait() fd ready 확인 read()/write() 실제 I/O 수행 이벤트당 최소 2회 시스템 콜 User↔Kernel 전환 반복 파일 I/O는 논블로킹 불가 (스레드 풀 필요) SQE 작성 CQE 수확 공유 메모리로 직접 교환 io_uring_enter() (배치/선택) SQPOLL: 시스템 콜 0회 파일 I/O + 네트워크 I/O 통합 배치 제출 + 배치 완료 SQE 링크로 순서 보장
epoll vs io_uring: 시스템 콜 흐름과 아키텍처 비교
비교 항목epollio_uring
시스템 콜이벤트당 2회+0~1회 (SQPOLL이면 0)
I/O 유형네트워크(소켓) 중심파일 + 네트워크 + 기타 모두 통합
파일 I/O논블로킹 불가 → 스레드 풀 필요네이티브 비동기 (io-wq 자동)
배치 처리이벤트 수집만 배치제출 + 완료 모두 배치
메모리 복사커널-사용자 간 이벤트 복사공유 메모리로 제로카피
연산 체이닝불가SQE 링크로 순서 보장
학습 곡선낮음높음 (liburing 사용 시 완화)
적합 시나리오소켓 이벤트 다중화고성능 스토리지/네트워크, 통합 이벤트 루프

실전 패턴: 고성능 Echo 서버

liburing 기반 multishot accept + provided buffers를 활용한 이벤트 루프 구현입니다.

#include <liburing.h>
#include <netinet/in.h>

#define ENTRIES   256
#define BUF_COUNT 64
#define BUF_SIZE  4096
#define BUF_BGID  0

enum { EV_ACCEPT, EV_RECV, EV_SEND };
struct conn_info { int fd; int type; };

static char bufs[BUF_COUNT][BUF_SIZE];
static struct io_uring_buf_ring *buf_ring;

static void setup_buf_ring(struct io_uring *ring) {
    struct io_uring_buf_reg reg = {
        .ring_entries = BUF_COUNT, .bgid = BUF_BGID,
    };
    buf_ring = io_uring_setup_buf_ring(ring, &reg, 0);
    for (int i = 0; i < BUF_COUNT; i++)
        io_uring_buf_ring_add(buf_ring, bufs[i], BUF_SIZE,
                              i, BUF_COUNT - 1, i);
    io_uring_buf_ring_advance(buf_ring, BUF_COUNT);
}

int main(void) {
    struct io_uring ring;
    io_uring_queue_init(ENTRIES, &ring, 0);
    setup_buf_ring(&ring);

    int listen_fd = /* socket + bind + listen */;

    /* Multishot accept 등록 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
    struct conn_info ci = { listen_fd, EV_ACCEPT };
    memcpy(&sqe->user_data, &ci, sizeof(ci));

    while (1) {
        io_uring_submit_and_wait(&ring, 1);

        struct io_uring_cqe *cqe;
        unsigned head, count = 0;
        io_uring_for_each_cqe(&ring, head, cqe) {
            struct conn_info ci;
            memcpy(&ci, &cqe->user_data, sizeof(ci));

            if (ci.type == EV_ACCEPT && cqe->res >= 0) {
                /* 새 연결: multishot recv 등록 */
                struct io_uring_sqe *s = io_uring_get_sqe(&ring);
                io_uring_prep_recv_multishot(s, cqe->res, NULL, 0, 0);
                s->flags |= IOSQE_BUFFER_SELECT;
                s->buf_group = BUF_BGID;
                struct conn_info ri = { cqe->res, EV_RECV };
                memcpy(&s->user_data, &ri, sizeof(ri));
            } else if (ci.type == EV_RECV && cqe->res > 0) {
                int bid = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
                /* echo: 받은 데이터 그대로 전송 */
                struct io_uring_sqe *s = io_uring_get_sqe(&ring);
                io_uring_prep_send(s, ci.fd, bufs[bid], cqe->res, 0);
                struct conn_info si = { ci.fd, EV_SEND };
                memcpy(&s->user_data, &si, sizeof(si));
                /* 버퍼 반환 */
                io_uring_buf_ring_add(buf_ring, bufs[bid], BUF_SIZE,
                                      bid, BUF_COUNT - 1, 0);
                io_uring_buf_ring_advance(buf_ring, 1);
            } else if (ci.type == EV_RECV && cqe->res <= 0) {
                close(ci.fd);
            }
            count++;
        }
        io_uring_cq_advance(&ring, count);
    }
}

실전 패턴: 비동기 파일 복사

io_uring의 SQE 링크를 활용하여 read→write 체인으로 비동기 파일 복사를 구현합니다.

#include <liburing.h>
#include <fcntl.h>

#define BLOCK_SIZE  (128 * 1024)
#define QUEUE_DEPTH 32

static int copy_file(const char *src, const char *dst) {
    struct io_uring ring;
    io_uring_queue_init(QUEUE_DEPTH, &ring, 0);

    int in_fd  = open(src, O_RDONLY);
    int out_fd = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);

    off_t offset = 0;
    int inflight = 0, done = 0;
    char *bufs[QUEUE_DEPTH];
    for (int i = 0; i < QUEUE_DEPTH; i++)
        bufs[i] = malloc(BLOCK_SIZE);

    while (!done || inflight) {
        while (!done && inflight < QUEUE_DEPTH) {
            /* read → write 링크 */
            struct io_uring_sqe *sqe_r = io_uring_get_sqe(&ring);
            io_uring_prep_read(sqe_r, in_fd, bufs[inflight],
                               BLOCK_SIZE, offset);
            sqe_r->flags |= IOSQE_IO_LINK;
            sqe_r->user_data = offset;

            struct io_uring_sqe *sqe_w = io_uring_get_sqe(&ring);
            io_uring_prep_write(sqe_w, out_fd, bufs[inflight],
                                BLOCK_SIZE, offset);
            sqe_w->user_data = offset | (1ULL << 63);

            offset += BLOCK_SIZE;
            inflight++;
        }
        io_uring_submit(&ring);

        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);
        if (cqe->res == 0 && !(cqe->user_data & (1ULL << 63)))
            done = 1;
        if (cqe->user_data & (1ULL << 63))
            inflight--;
        io_uring_cqe_seen(&ring, cqe);
    }

    for (int i = 0; i < QUEUE_DEPTH; i++) free(bufs[i]);
    close(in_fd); close(out_fd);
    io_uring_queue_exit(&ring);
    return 0;
}

실전 패턴: 데이터베이스 WAL 쓰기

데이터베이스의 WAL(Write-Ahead Log)은 내구성(durability)을 보장하면서도 높은 쓰기 처리량을 달성해야 합니다. io_uring의 SQE 링크(write → fdatasync)를 활용하면 배치 WAL 쓰기를 원자적으로 수행할 수 있습니다.

#include <liburing.h>
#include <fcntl.h>

#define WAL_BATCH_MAX 32

struct wal_writer {
    struct io_uring ring;
    int             wal_fd;
    off_t           write_pos;
    int             pending;
};

int wal_init(struct wal_writer *w, const char *path) {
    io_uring_queue_init(256, &w->ring, 0);
    w->wal_fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0644);
    w->write_pos = 0;
    w->pending = 0;

    /* WAL fd를 고정 등록 → fget/fput 비용 제거 */
    io_uring_register_files(&w->ring, &w->wal_fd, 1);
    return 0;
}

/* WAL 배치 쓰기: write → fdatasync 링크 체인 */
int wal_write_batch(struct wal_writer *w,
                    struct iovec *entries, int nr_entries) {
    /* 1. 배치의 모든 엔트리를 하나의 writev로 작성 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(&w->ring);
    io_uring_prep_writev(sqe, 0, entries, nr_entries, w->write_pos);
    sqe->flags |= IOSQE_FIXED_FILE | IOSQE_IO_LINK;
    sqe->user_data = 1;  /* write 식별 */

    /* 2. fdatasync로 디스크 플러시 보장 (링크: write 완료 후 실행) */
    sqe = io_uring_get_sqe(&w->ring);
    io_uring_prep_fsync(sqe, 0, IORING_FSYNC_DATASYNC);
    sqe->flags |= IOSQE_FIXED_FILE;
    sqe->user_data = 2;  /* fsync 식별 */

    /* 3. 제출 및 fsync 완료 대기 */
    io_uring_submit(&w->ring);

    struct io_uring_cqe *cqe;
    int remaining = 2;
    while (remaining > 0) {
        io_uring_wait_cqe(&w->ring, &cqe);
        if (cqe->res < 0) {
            fprintf(stderr, "WAL %s error: %s\n",
                    cqe->user_data == 1 ? "write" : "fsync",
                    strerror(-cqe->res));
            return -1;
        }
        if (cqe->user_data == 1)
            w->write_pos += cqe->res;
        io_uring_cqe_seen(&w->ring, cqe);
        remaining--;
    }
    return 0;  /* fsync 완료 = 내구성 보장 */
}
WAL 쓰기 방식syscall 수fsync 빈도처리량
동기 write + fsync2N (N = 트랜잭션 수)매 트랜잭션기준선
그룹 커밋 (sync write + 배치 fsync)N + 1배치당 1회3~10배
io_uring 링크 (write→fsync)1 (io_uring_enter)배치당 1회5~15배
io_uring SQPOLL + 링크0배치당 1회10~20배
ℹ️

RocksDB의 io_uring 활용: RocksDB는 MultiGet() 연산에서 여러 SST 파일의 읽기를 io_uring 배치로 제출하여, 동기 읽기 대비 랜덤 읽기 지연을 30~50% 감소시킵니다. WAL 쓰기에도 io_uring 적용이 논의되고 있습니다.

실전 패턴: 프록시 서버 (splice)

io_uring의 IORING_OP_SPLICE를 활용하면 사용자 공간 버퍼를 거치지 않고 커널 내에서 직접 데이터를 전달하는 제로카피 프록시를 구현할 수 있습니다.

splice 기반 제로카피 프록시 Client Socket User Buffer 복사 2회 Server Socket 기존 방식: recv() → send() Client Socket Pipe 커널 내부 Server Socket splice splice io_uring splice: 사용자 공간 복사 0회 성능 효과 CPU 사용량: 40~60% 감소 메모리 대역폭: 50% 절약 캐시 오염: 제거 (커널 내 전달) 대용량 데이터 프록시에 최적 splice는 비동기로 2개 SQE 필요
기존 recv/send vs io_uring splice 프록시 데이터 경로 비교
#include <liburing.h>
#include <fcntl.h>

/* splice 기반 프록시: client_fd → pipe → server_fd */
void proxy_splice(struct io_uring *ring,
                  int client_fd, int server_fd,
                  int pipe_rd, int pipe_wr) {
    struct io_uring_sqe *sqe;

    /* 1단계: client → pipe (SPLICE_F_MOVE로 페이지 이동) */
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_splice(sqe, client_fd, -1,
                         pipe_wr, -1, 65536,
                         SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    sqe->flags |= IOSQE_IO_LINK;
    sqe->user_data = 1;  /* client→pipe */

    /* 2단계: pipe → server (링크: 1단계 완료 후 실행) */
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_splice(sqe, pipe_rd, -1,
                         server_fd, -1, 65536,
                         SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    sqe->user_data = 2;  /* pipe→server */

    io_uring_submit(ring);
}
ℹ️

TEE 연산: IORING_OP_TEE를 사용하면 pipe의 데이터를 소비하지 않고 복제할 수 있습니다. 로깅이나 모니터링을 위해 프록시 데이터를 분기(tee)하는 패턴에 유용합니다.

io_uring Restrictions (샌드박싱)

IORING_REGISTER_RESTRICTIONS를 사용하면 허용되는 opcode, 플래그, 등록 연산을 제한할 수 있습니다.

struct io_uring_params params = {
    .flags = IORING_SETUP_R_DISABLED,
};
int ring_fd = io_uring_setup(256, &params);

struct io_uring_restriction res[] = {
    { .opcode = IORING_RESTRICTION_SQE_OP,
      .sqe_op = IORING_OP_READ },
    { .opcode = IORING_RESTRICTION_SQE_OP,
      .sqe_op = IORING_OP_WRITE },
    { .opcode = IORING_RESTRICTION_SQE_FLAGS_ALLOWED,
      .sqe_flags = IOSQE_FIXED_FILE },
    { .opcode = IORING_RESTRICTION_REGISTER_OP,
      .register_op = IORING_REGISTER_FILES },
};
io_uring_register(ring_fd, IORING_REGISTER_RESTRICTIONS,
                  res, sizeof(res) / sizeof(res[0]));

/* ring 활성화 (이후 restriction 변경 불가) */
io_uring_register(ring_fd, IORING_REGISTER_ENABLE_RINGS,
                  NULL, 0);

io_uring 디버깅

/proc/PID/fdinfo

# io_uring fd의 상세 정보 확인
cat /proc/<pid>/fdinfo/<uring_fd>
# SqSize, CqSize, SqThreadCpu, UserFiles, UserBufs 등 출력

io_uring tracepoints

# 사용 가능한 io_uring tracepoint 목록
ls /sys/kernel/tracing/events/io_uring/
# io_uring_create, io_uring_submit_sqe, io_uring_complete,
# io_uring_queue_async_work, io_uring_poll_arm, io_uring_task_add

# ftrace로 io_uring 이벤트 추적
echo 1 > /sys/kernel/tracing/events/io_uring/enable
cat /sys/kernel/tracing/trace_pipe

bpftrace 원라이너

# io_uring SQE 제출 추적 (opcode별 카운트)
bpftrace -e 'tracepoint:io_uring:io_uring_submit_sqe {
    @ops[args->opcode] = count();
}'

# io_uring 완료 지연 시간 히스토그램
bpftrace -e '
tracepoint:io_uring:io_uring_submit_sqe {
    @start[args->req] = nsecs;
}
tracepoint:io_uring:io_uring_complete /@start[args->req]/ {
    @latency_us = hist((nsecs - @start[args->req]) / 1000);
    delete(@start[args->req]);
}'

# CQE 오버플로 감지
bpftrace -e 'kprobe:io_cqring_overflow_flush {
    @overflow = count();
}'

문제 해결 가이드

일반적인 오류와 해결

증상원인해결 방법
-ENOMEM (메모리 부족) RLIMIT_MEMLOCK 제한 ulimit -l unlimited 또는 /etc/security/limits.conf에서 memlock 증가
-EINVAL (setup 실패) 잘못된 플래그 조합 SQPOLL + IOPOLL은 O_DIRECT 필수, 커널 버전별 지원 플래그 확인
-EPERM (SQPOLL) CAP_SYS_NICE 권한 없음 sudo 사용 또는 setcap cap_sys_nice+ep <binary>
CQE res = -EAGAIN 리소스 일시 부족 (논블로킹 소켓) 재시도 로직 추가 또는 블로킹 모드로 전환
CQE 오버플로 (IORING_CQ_OVERFLOW) CQ 링 크기 부족, 처리 지연 CQ 크기 증가 (io_uring_params.cq_entries) 또는 CQE 처리 속도 향상
SQPOLL 스레드가 슬립 반복 워크로드 불규칙, sq_thread_idle 너무 짧음 sq_thread_idle 값 증가 (예: 5000ms) 또는 기본 모드로 전환

성능 저하 진단

1. 시스템 콜 오버헤드 확인

# io_uring_enter() 호출 빈도 측정
perf stat -e 'syscalls:sys_enter_io_uring_enter' ./my_app

# SQPOLL 모드가 제대로 동작하는지 확인 (0회여야 함)

2. I/O 지연 프로파일링

# SQE 제출부터 CQE 완료까지 지연 시간 분포
bpftrace -e '
tracepoint:io_uring:io_uring_submit_sqe {
    @start[args->req] = nsecs;
}
tracepoint:io_uring:io_uring_complete /@start[args->req]/ {
    @latency_us = hist((nsecs - @start[args->req]) / 1000);
    delete(@start[args->req]);
}
interval:s:10 {
    print(@latency_us);
    clear(@latency_us);
}'

3. CQ 처리 병목 확인

/* CQ 처리 루프에서 한 번에 처리하는 CQE 수 측정 */
unsigned int count = 0, head;
struct io_uring_cqe *cqe;

io_uring_for_each_cqe(ring, head, cqe) {
    count++;
    /* 처리 */
}
printf("Batch size: %u\n", count);  /* 1에 가까우면 배치 효과 없음 */

4. 워커 스레드 포화 확인

# io-wq 워커 스레드 생성 추적
bpftrace -e 'kprobe:io_wq_create { @wq_creates = count(); }'

# 대기 중인 작업 수 (높으면 워커 부족)
cat /proc/<pid>/fdinfo/<uring_fd> | grep SqThreadIdle

성능 최적화 체크리스트

💡

성능 향상 우선순위:

  1. 배치 제출: 여러 SQE를 한 번에 제출 (1회 io_uring_enter()로 N개 처리)
  2. Fixed Files/Buffers: io_uring_register_files()로 fd 조회 오버헤드 제거
  3. SQPOLL (고부하 전용): 지속적인 워크로드에서만 활성화, 저부하 시 기본 모드 사용
  4. IOPOLL (NVMe 전용): O_DIRECT + 고속 스토리지에서만 효과, 일반 디스크는 역효과
  5. Provided Buffers: 네트워크 I/O에서 버퍼 복사 제거
  6. 링크 체인: 순차 작업(write→fsync)을 하나의 체인으로 처리

피해야 할 안티 패턴

안티 패턴문제점권장 패턴
매 I/O마다 io_uring_enter() 호출 시스템 콜 오버헤드, 동기 I/O와 차이 없음 배치 제출 (예: 32개씩 모아서 제출)
저부하 환경에서 SQPOLL 사용 CPU 낭비 (유휴 시에도 100% 사용) 고부하에서만 SQPOLL, 일반적으로 기본 모드
HDD에서 IOPOLL 사용 폴링이 인터럽트보다 비효율적 NVMe SSD 전용으로 제한
CQ 크기 = SQ 크기 연결 폭증 시 CQE 오버플로 CQ 크기를 SQ의 2배 이상으로 설정
모든 작업에 링크 사용 병렬성 감소, 하나 실패 시 전체 취소 순차 의존성이 있는 작업만 링크
CQE 처리 지연 CQ 오버플로, 백프레셔 발생 IORING_SETUP_CQSIZE로 큐 확장 또는 즉시 처리

NUMA 최적화

다중 소켓 서버에서 io_uring 성능을 극대화하려면 NUMA(Non-Uniform Memory Access) 토폴로지를 고려한 설정이 필수입니다. 메모리 접근 지연이 로컬 노드 대비 원격 노드에서 2~3배 차이 나므로, 링 버퍼·워커·버퍼 모두 동일 NUMA 노드에 배치해야 합니다.

NUMA 인식 io_uring 배치 NUMA Node 0 App Thread CPU 0-7 고정 io_uring Ring SQ/CQ 로컬 메모리 SQPOLL Thread SQ_AFF → CPU 6 io-wq Workers IOWQ_AFF: 0-7 Fixed Buffers (numa_alloc_onnode) 로컬 메모리에 할당 NVMe SSD (PCIe 버스) NUMA 0에 연결된 디바이스 NUMA Node 1 App Thread CPU 8-15 고정 io_uring Ring 별도 링 인스턴스 SQPOLL Thread SQ_AFF → CPU 14 io-wq Workers IOWQ_AFF: 8-15 Fixed Buffers (numa_alloc_onnode) 로컬 메모리에 할당 NVMe SSD (PCIe 버스) NUMA 1에 연결된 디바이스 원격 접근 시 지연 2~3배 증가
NUMA 노드별 io_uring 인스턴스와 리소스 격리 배치
#define _GNU_SOURCE
#include <liburing.h>
#include <numa.h>
#include <sched.h>

int setup_numa_ring(struct io_uring *ring, int numa_node, int sqpoll_cpu) {
    /* 1. 현재 스레드를 NUMA 노드에 바인딩 */
    struct bitmask *mask = numa_allocate_nodemask();
    numa_bitmask_setbit(mask, numa_node);
    numa_bind(mask);
    numa_bitmask_free(mask);

    /* 2. SQPOLL 스레드를 로컬 CPU에 고정 */
    struct io_uring_params params = {
        .flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF
               | IORING_SETUP_SINGLE_ISSUER,
        .sq_thread_cpu  = sqpoll_cpu,
        .sq_thread_idle = 2000,
    };
    int ret = io_uring_queue_init_params(256, ring, &params);
    if (ret < 0) return ret;

    /* 3. io-wq 워커도 같은 NUMA 노드 CPU에 제한 */
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    for (int c = numa_node * 8; c < (numa_node + 1) * 8; c++)
        CPU_SET(c, &cpuset);
    io_uring_register_iowq_aff(ring, sizeof(cpuset), &cpuset);

    /* 4. 고정 버퍼를 로컬 NUMA 노드에 할당 */
    void *buf = numa_alloc_onnode(4096 * 64, numa_node);
    struct iovec iov = { .iov_base = buf, .iov_len = 4096 * 64 };
    io_uring_register_buffers(ring, &iov, 1);

    return 0;
}
NUMA 설정 항목방법효과
앱 스레드 CPU 고정sched_setaffinity(), taskset일관된 NUMA 메모리 접근
SQPOLL CPU 고정IORING_SETUP_SQ_AFF + sq_thread_cpu폴링 스레드의 캐시 미스 감소
io-wq 워커 CPU 제한IORING_REGISTER_IOWQ_AFF워커가 원격 NUMA 노드에서 실행 방지
버퍼 NUMA 할당numa_alloc_onnode()DMA 버퍼의 로컬 메모리 보장
NVMe 디바이스 선택NUMA 노드에 연결된 PCIe 디바이스 사용PCIe 크로스-소켓 오버헤드 제거
per-NUMA ring노드별 별도 io_uring 인스턴스완전한 로컬리티 보장
⚠️

NVMe 디바이스 NUMA 확인: cat /sys/block/nvme0n1/device/numa_node으로 디바이스가 어느 NUMA 노드에 연결되어 있는지 확인하세요. 디바이스와 다른 NUMA 노드에서 I/O를 수행하면 PCIe 크로스-소켓 전송으로 지연이 증가합니다.

메모리 배리어와 순서 보장

io_uring의 SQ/CQ 링은 lock-free SPSC(Single-Producer Single-Consumer) 큐입니다. 뮤텍스 대신 메모리 배리어(memory barrier)만으로 생산자-소비자 간 데이터 가시성을 보장합니다. 배리어를 잘못 사용하면 데이터 손실이나 스톨이 발생할 수 있으므로 정확한 이해가 필요합니다.

SQ/CQ 링 메모리 배리어 배치 SQ (Submission Queue) User: SQE 작성 smp_store_release sq->tail++ 가시성 Kernel: SQ 확인 smp_load_acquire tail 읽기 → SQE 처리 Kernel: sq->head++ User: head 읽기 SQ: User가 tail 생산, Kernel이 head 소비 CQ (Completion Queue) Kernel: CQE 작성 smp_store_release cq->tail++ User: CQ 확인 smp_load_acquire tail 읽기 → CQE 처리 smp_store_release cq->head++ CQ: Kernel이 tail 생산, User가 head 소비 배리어 규칙 요약 생산자: 데이터 작성 후 → smp_store_release(tail) 소비자: smp_load_acquire(tail) → 데이터 읽기 소비 완료: smp_store_release(head) → 슬롯 반환 x86 TSO: store_release는 no-op (자동 순서 보장) 배리어 누락 시 문제 SQ tail 배리어 누락 → 커널이 빈 SQE 읽음 CQ tail 배리어 누락 → 사용자가 미완성 CQE 읽음 head 배리어 누락 → 슬롯이 해제되지 않아 큐 가득참 ARM/RISC-V에서 실제 버그 발생 (약한 메모리 모델)
SQ/CQ 링의 메모리 배리어 배치: 생산자-소비자 간 데이터 가시성 보장

아키텍처별 배리어 매핑

io_uring 배리어 함수의미x86-64ARM64RISC-V
smp_store_release(&ptr, val)이전 쓰기 완료 후 ptr에 val 저장MOV (no-op, TSO 보장)STL (store-release)fence rw,w; sw
smp_load_acquire(&ptr)ptr 읽은 후 이후 읽기/쓰기 보장MOV (no-op, TSO 보장)LDA (load-acquire)lw; fence r,rw
smp_mb()전체 메모리 배리어MFENCE / LOCKDMB ISHfence rw,rw
/* 올바른 원시(raw) SQ 제출 코드 (liburing 없이) */

/* 1. SQE 작성 — 데이터가 먼저 기록되어야 함 */
struct io_uring_sqe *sqe = &sq->sqes[sq->tail & sq->ring_mask];
sqe->opcode    = IORING_OP_READ;
sqe->fd        = fd;
sqe->addr      = (unsigned long)buf;
sqe->len       = len;
sqe->user_data = my_id;

/* 2. 간접 인덱스 배열 설정 (NO_SQARRAY 아닌 경우) */
sq->array[sq->tail & sq->ring_mask] = sq->tail & sq->ring_mask;

/* 3. store_release: SQE 내용이 반드시 tail 갱신 전에 가시적이어야 함
 *    이 배리어가 없으면 커널이 아직 기록되지 않은 SQE를 읽을 수 있음 */
io_uring_smp_store_release(sq->tail_ptr, sq->tail + 1);

/* 4. 커널 통지 (SQPOLL이 아닌 경우) */
io_uring_enter(ring_fd, 1, 0, 0, NULL);

/* === CQE 수확 === */

/* 5. load_acquire: tail 값을 읽은 후에야 CQE 데이터에 접근
 *    이 배리어가 없으면 커널이 아직 기록 중인 CQE를 읽을 수 있음 */
unsigned tail = io_uring_smp_load_acquire(cq->tail_ptr);
unsigned head = *cq->head_ptr;

while (head != tail) {
    struct io_uring_cqe *cqe = &cq->cqes[head & cq->ring_mask];
    process(cqe->user_data, cqe->res);
    head++;
}

/* 6. store_release: CQE 읽기 완료 후 head 갱신
 *    이 배리어가 없으면 커널이 아직 읽는 중인 슬롯을 재사용할 수 있음 */
io_uring_smp_store_release(cq->head_ptr, head);
💡

liburing 사용 권장: 위의 배리어 처리는 liburingio_uring_get_sqe(), io_uring_submit(), io_uring_cqe_seen() 등이 내부적으로 자동 처리합니다. 직접 원시 링을 조작해야 하는 경우(예: 커널 개발, 특수 최적화)가 아니면 liburing을 사용하세요.

보안 고려사항

io_uring의 강력한 기능은 보안 관점에서 주의가 필요합니다. 공유 메모리를 통한 시스템 콜 우회로 seccomp 필터링이 어렵고, 커널 공격 표면이 넓습니다.

이슈설명대응
seccomp 우회SQPOLL에서 커널 스레드가 I/O를 수행하므로 seccomp 모델이 복잡해질 수 있음커널 버전별 동작 차이를 전제로 seccomp/LSM 정책을 재검증하고, 기본적으로 io_uring_disabled 또는 syscall 차단 정책을 우선 적용
권한 상승복잡한 커널 코드 → CVE 다수 발생io_uring_disabled sysctl로 비활성화
리소스 소진대량 SQE 제출 → 메모리/CPU 소비RLIMIT_MEMLOCK으로 mmap 크기 제한
# io_uring 사용 제한 (Linux 5.12.4+)
# 0: 모든 사용자 허용 (기본)
# 1: 권한 없는 사용자 비활성화
# 2: 모든 사용자 비활성화
sysctl -w kernel.io_uring_disabled=1

# Docker/Kubernetes: seccomp 프로파일에서 io_uring_* 차단
LSM 보안 훅 (v6.15+): 커널 6.15에서 io_uring 전용 LSM 훅(security_uring_sqe(), security_uring_cmd())이 추가되어, SELinux/AppArmor 등의 MAC 정책이 io_uring 연산에도 적용됩니다. 이전에는 io_uring이 시스템 콜 기반 LSM 훅을 우회할 수 있었으나, 전용 훅을 통해 이 구조적 보안 문제가 해결되었습니다.
⚠️

Google, Chromium, Docker 등에서 기본 seccomp 프로파일에 io_uring 시스템 콜을 차단하고 있습니다. 컨테이너 환경에서는 io_uring_disabled=1 설정을 권장하며, 필요한 경우 IORING_REGISTER_RESTRICTIONS로 최소 권한만 부여하세요.

io_uring 주요 보안 취약점 사례

io_uring은 Linux 5.1에서 도입된 이후 빠른 기능 확장과 함께 다수의 심각한 보안 취약점이 발견되었습니다. 복잡한 비동기 상태 관리, 커널 스레드 기반의 SQPOLL, 다양한 opcode의 조합 등이 공격 면적을 크게 확장시킵니다. 2021~2023년에 CVE가 집중 발생하여, Google이 Android/ChromeOS에서 io_uring을 완전 비활성화하는 결정을 내리기도 했습니다.

CVE-2021-41073 — io_uring 타입 혼동으로 권한 상승 (CVSS 7.8):

io_uring의 파일 등록 메커니즘에서 IORING_REGISTER_FILESIORING_REGISTER_FILES_UPDATE의 처리 과정에서 파일 디스크립터 타입 검증이 누락되어, 일반 파일 디스크립터를 특수 파일(예: 커널 내부 파일)로 교체할 수 있었습니다. 이를 통해 권한 검사를 우회하고 임의 코드를 실행할 수 있습니다.

CVE-2022-29582 — io_uring timeout UAF (CVSS 7.0):

io_uringIORING_OP_LINK_TIMEOUT 처리에서, 링크된 요청이 완료된 후에도 timeout 요청의 io_kiocb가 해제되지 않은 채 타이머 콜백에서 참조되어 Use-After-Free가 발생합니다. 타이머 만료와 요청 완료 사이의 경쟁 조건이 근본 원인입니다.

CVE-2023-2598 — io_uring 고정 버퍼 범위 초과 (CVSS 7.8):

IORING_REGISTER_BUFFERS로 등록된 고정 버퍼(fixed buffer)의 경계 검사가 불충분하여, coalesced 버퍼에서 범위 밖 읽기/쓰기가 가능합니다. 물리적으로 연속된 페이지를 병합하는 과정에서 길이 계산 오류가 발생합니다.

/* io_uring 취약점 타임라인 (주요 항목) */

/*
 * 2021:
 * CVE-2021-20226  — io_uring close 연산에서 UAF
 * CVE-2021-41073  — 파일 등록 타입 혼동 → 권한 상승
 * CVE-2021-3491   — io_uring PROVIDE_BUFFERS OOB 쓰기
 *
 * 2022:
 * CVE-2022-29582  — LINK_TIMEOUT UAF (타이머 경쟁 조건)
 * CVE-2022-1043   — io_uring sendmsg/recvmsg UAF
 * CVE-2022-2602   — io_uring + Unix socket GC UAF
 *
 * 2023:
 * CVE-2023-2598   — 고정 버퍼 경계 초과
 * CVE-2023-2235   — io_uring timer의 이중 해제
 * CVE-2023-21400  — io_uring 파일 테이블 오프셋 UAF
 *
 * 근본 원인 분류:
 * - Use-After-Free: ~60% (비동기 생명주기 관리 실패)
 * - 경쟁 조건: ~20% (완료/취소/타임아웃 간 race)
 * - 범위 초과: ~15% (버퍼/인덱스 경계 검사 누락)
 * - 타입 혼동: ~5% (파일/소켓 타입 검증 누락)
 */

/* io_uring 비동기 생명주기 관리의 복잡성 */
struct io_kiocb {
    /* 하나의 요청(SQE)에 대한 커널 제어 블록
     * 생명주기:
     *   submit → queued → in_flight → completed → freed
     *
     * 위험 지점:
     * 1. cancel과 complete가 동시에 발생 (race)
     * 2. linked request 체인에서 중간 요청 실패 시 후속 정리
     * 3. SQPOLL 커널 스레드와 사용자 스레드의 동시 접근
     * 4. timeout과 target 요청의 상호 참조 해제
     */
    struct io_ring_ctx *ctx;     /* io_uring 인스턴스 */
    u8 opcode;                    /* 연산 종류 */
    struct io_kiocb *link;       /* 다음 연결된 요청 */
    struct io_tw_state tw;       /* task work 상태 */
    atomic_t refs;                /* 참조 카운트 */
};

/* io_uring 보안 설정 권장 */
# 시스템 전체에서 io_uring 비활성화 (보안 우선 환경)
sysctl -w kernel.io_uring_disabled=2  # 0=허용, 1=비특권 차단, 2=완전 차단

# 비특권 사용자만 차단 (일반 서버)
sysctl -w kernel.io_uring_disabled=1

레거시 Linux AIO

Linux AIO(Asynchronous I/O)는 Linux 2.5(2002)에 도입된 비동기 I/O 인터페이스로, io_uring(Linux 5.1, 2019)의 전신입니다. O_DIRECT 필수, 파일 I/O 전용, 복잡한 API 등의 한계로 현재는 유지보수 모드이며 새 프로젝트는 io_uring을 권장합니다.

동기 I/O vs 비동기 I/O

항목동기 I/O (read/write)비동기 I/O (AIO)
호출 방식read(), write()io_submit()
반환 시점I/O 완료 후즉시 (제출만 완료)
블로킹O (기다림)X (논블로킹)
완료 확인반환값io_getevents()

Linux AIO 시스템 콜

Linux AIO는 5개의 시스템 콜로 구성됩니다.

시스템 콜역할주요 파라미터
io_setup()AIO 컨텍스트 생성maxevents, ctxp
io_submit()I/O 요청 제출ctx, nr, iocbpp
io_getevents()완료된 I/O 수집ctx, min_nr, nr, events, timeout
io_cancel()I/O 요청 취소ctx, iocb, result
io_destroy()AIO 컨텍스트 파괴ctx

libaio 예제

#include <libaio.h>

io_context_t ctx = 0;
struct iocb *iocbs[DEPTH];
struct io_event events[DEPTH];

/* 1. AIO 컨텍스트 생성 */
io_setup(DEPTH, &ctx);

/* 2. O_DIRECT로 파일 열기 (필수!) */
int fd = open("datafile.bin", O_RDONLY | O_DIRECT);

/* 3. IOCB 초기화 및 제출 */
io_prep_pread(iocbs[0], fd, buffer, 4096, 0);
io_submit(ctx, 1, iocbs);

/* 4. 완료 대기 */
io_getevents(ctx, 1, DEPTH, events, NULL);
io_destroy(ctx);

O_DIRECT 요구사항

Linux AIO는 O_DIRECT 필수! Buffered I/O에서 io_submit()이 블로킹될 수 있습니다. 진짜 비동기 처리를 위해서는 반드시 O_DIRECT 플래그가 필요합니다. io_uring은 Buffered I/O에서도 비동기 동작을 지원하는 것이 핵심적인 개선점입니다.

Linux AIO의 한계

성능 벤치마크

방식IOPS (4KB, QD=32)Latency (avg)CPU
동기 I/O (1 thread)10K0.1 ms5%
동기 I/O (32 threads)80K0.4 ms35%
Linux AIO (QD=32)200K0.16 ms15%
io_uring (QD=32)350K0.09 ms10%

Linux AIO 사용 시점

  • 레거시 시스템: 커널 5.1 미만 (io_uring 없음)
  • 기존 코드베이스: 이미 AIO를 사용 중이고 잘 동작함
  • 단순 파일 I/O: Direct I/O만 필요하고 복잡도 낮음

새 프로젝트는 io_uring 사용을 권장합니다!

io_uring 채택 현황

io_uring은 도입 이후 빠르게 주요 인프라 프로젝트에 채택되고 있습니다. 다음은 io_uring을 적극적으로 활용하거나 실험하고 있는 대표적인 프로젝트입니다.

프로젝트분류io_uring 활용 방식주요 사용 기능
fio벤치마크--ioengine=io_uring으로 스토리지 성능 측정SQPOLL, IOPOLL, fixed files/buffers
RocksDB데이터베이스MultiGet 비동기 읽기, compaction I/O 가속배치 제출, fixed buffers
PostgreSQL데이터베이스v16+ 실험적 비동기 I/O 백엔드buffered read, AIO 대체
ScyllaDB / Seastar데이터베이스Seastar 프레임워크의 핵심 I/O 백엔드SQPOLL, IOPOLL, zero-copy
Ceph분산 스토리지BlueStore의 io_uring 백엔드배치 제출, fixed files
SPDK스토리지 프레임워크NVMe passthrough 대안으로 io_uring_cmd 지원io_uring_cmd, IOPOLL
QEMU가상화virtio-blk/scsi의 io_uring AIO 백엔드기본 모드, 배치 제출
nginx웹 서버실험적 io_uring 이벤트 모듈read, sendfile 대체
Tokio (Rust)런타임tokio-uring 크레이트로 비동기 I/O 백엔드 제공multishot, provided buffers
io-uring (Rust)라이브러리안전한 Rust 래퍼로 io_uring 전체 기능 노출전체 opcode 지원
netty (Java)네트워크io_uring transport (incubator-transport-io_uring)multishot accept/recv
libuv이벤트 루프실험적 io_uring 백엔드 (Node.js 기반)파일 I/O 가속

언어별 생태계

언어라이브러리/프레임워크상태특징
Cliburing공식 안정Jens Axboe 직접 관리, 사실상 표준
Rustio-uring, tokio-uring, monoio안정/활발소유권 모델과 잘 맞음, completion-based
Goiceber/iouring-go, godzie/gouring실험적goroutine 모델과 통합 어려움
Javanetty-incubator-transport-io_uring인큐베이터JNI 기반, Netty 채널 추상화
Pythonliburing (cffi 바인딩)실험적GIL로 인해 효과 제한적
C++liburing (직접 사용), Boost.Asio 실험적안정C API 그대로 사용 가능
💡

채택 트렌드: io_uring은 특히 Rust 생태계에서 가장 빠르게 채택되고 있습니다. Rust의 소유권 모델이 io_uring의 completion-based 비동기 모델(버퍼 소유권 이전)과 자연스럽게 맞기 때문입니다. Go는 goroutine 스케줄러와의 통합이 어려워 채택이 느린 편입니다.

io_uring_register 전체 연산 참조

io_uring_register() 시스템 콜은 리소스 사전 등록, 기능 조회, 워커 제어 등 io_uring 인스턴스의 설정을 관리합니다. 다음은 전체 등록 연산 목록입니다.

Opcode이름설명도입
0IORING_REGISTER_BUFFERS고정 버퍼 등록 (페이지 핀, GUP 비용 제거)5.1
1IORING_UNREGISTER_BUFFERS고정 버퍼 해제5.1
2IORING_REGISTER_FILES고정 파일 디스크립터 등록 (fget/fput 비용 제거)5.1
3IORING_UNREGISTER_FILES고정 파일 디스크립터 해제5.1
4IORING_REGISTER_EVENTFD완료 알림용 eventfd 등록5.2
5IORING_UNREGISTER_EVENTFDeventfd 해제5.2
6IORING_REGISTER_FILES_UPDATE등록된 파일 테이블 부분 업데이트5.5
7IORING_REGISTER_EVENTFD_ASYNC비동기 완료 시에만 eventfd 시그널5.6
8IORING_REGISTER_PROBE지원 opcode 조회 (기능 탐지)5.6
9IORING_REGISTER_PERSONALITY크리덴셜 등록 (다른 사용자 권한으로 I/O)5.6
10IORING_UNREGISTER_PERSONALITY크리덴셜 해제5.6
11IORING_REGISTER_RESTRICTIONS허용 opcode/플래그 제한 (샌드박싱)5.13
12IORING_REGISTER_ENABLE_RINGSR_DISABLED 상태의 ring 활성화5.10
13IORING_REGISTER_FILES_UPDATE2파일 업데이트 확장 (태그 지원)5.13
14IORING_REGISTER_BUFFERS2버퍼 등록 확장 (태그 지원)5.13
15IORING_REGISTER_BUFFERS_UPDATE등록된 버퍼 부분 업데이트5.13
16IORING_REGISTER_IOWQ_AFFio-wq 워커의 CPU affinity 설정5.14
17IORING_UNREGISTER_IOWQ_AFFio-wq CPU affinity 해제5.14
18IORING_REGISTER_IOWQ_MAX_WORKERSio-wq 최대 워커 수 설정 [bounded, unbounded]5.15
19IORING_REGISTER_RING_FDSring fd를 테이블에 등록 (close-on-exec 없이)5.18
20IORING_UNREGISTER_RING_FDSring fd 테이블 해제5.18
22IORING_REGISTER_PBUF_RINGProvided buffer ring 등록5.19
23IORING_UNREGISTER_PBUF_RINGProvided buffer ring 해제5.19
24IORING_REGISTER_SYNC_CANCEL동기적 요청 취소6.0
25IORING_REGISTER_FILE_ALLOC_RANGE파일 테이블 할당 범위 지정6.0
27IORING_REGISTER_NAPIbusy-poll NAPI 등록 (네트워크 지연 최적화)6.9
28IORING_UNREGISTER_NAPINAPI 등록 해제6.9

기능 탐지: IORING_REGISTER_PROBE

런타임에 커널이 지원하는 opcode를 확인할 수 있습니다. 다양한 커널 버전에서 동작해야 하는 애플리케이션에 필수적입니다.

#include <liburing.h>
#include <stdio.h>

void probe_io_uring(struct io_uring *ring) {
    struct io_uring_probe *probe = io_uring_get_probe_ring(ring);
    if (!probe) {
        perror("probe not supported");
        return;
    }

    const char *op_names[] = {
        [IORING_OP_NOP]       = "NOP",
        [IORING_OP_READV]     = "READV",
        [IORING_OP_WRITEV]    = "WRITEV",
        [IORING_OP_READ]      = "READ",
        [IORING_OP_WRITE]     = "WRITE",
        [IORING_OP_SEND_ZC]  = "SEND_ZC",
        [IORING_OP_URING_CMD] = "URING_CMD",
    };

    for (int i = 0; i < probe->ops_len; i++) {
        if (probe->ops[i].flags & IO_URING_OP_SUPPORTED) {
            printf("  op %d (%s): supported\n",
                   i, i < sizeof(op_names)/sizeof(*op_names)
                      ? op_names[i] : "unknown");
        }
    }
    io_uring_free_probe(probe);
}

/* 특정 opcode 지원 여부 간편 확인 */
bool supports_op(struct io_uring *ring, int op) {
    struct io_uring_probe *p = io_uring_get_probe_ring(ring);
    bool ok = p && io_uring_opcode_supported(p, op);
    io_uring_free_probe(p);
    return ok;
}
💡

이식성 팁: io_uring_get_probe()는 새 ring을 생성하여 탐지하고, io_uring_get_probe_ring()은 기존 ring에서 탐지합니다. 애플리케이션 초기화 시 한 번만 호출하고 결과를 캐시하세요.

NAPI Busy-Poll 통합

커널 6.9에서 도입된 IORING_REGISTER_NAPI는 io_uring과 네트워크 NAPI(New API) 폴링을 통합하여, 패킷 수신 시 인터럽트 대신 busy-poll을 사용해 네트워크 지연을 극적으로 줄입니다. 일반적인 인터럽트 경로에서 10~50μs 걸리는 패킷 수신이, busy-poll을 통해 2~5μs로 단축됩니다.

NAPI Busy-Poll vs 인터럽트 경로 기존: 인터럽트 경로 (10~50μs) NIC 수신 하드웨어 IRQ softirq NET_RX NAPI poll 드라이버 소켓 큐 도착 io_uring CQE 게시 IRQ → softirq 전환 지연 + 스케줄링 지연 + 컨텍스트 스위치 coalesce 대기 + IRQ affinity 미스매치 시 추가 지연 NAPI Busy-Poll 경로 (2~5μs) NIC 수신 io_uring_enter() → napi_busy_loop() 드라이버 poll 직접 호출 CQE 즉시 게시 IRQ/softirq 생략 IRQ 비활성 → 사용자 컨텍스트에서 직접 NIC 드라이버 poll → 제로 컨텍스트 스위치 io_uring이 GETEVENTS 대기 중 NIC를 직접 폴링하여 패킷 수확 지연 시간 비교 (P99) 인터럽트: 25~50μs → Busy-Poll: 3~8μs HFT/실시간 거래에서 최대 10배 지연 감소 적합 워크로드 HFT, 실시간 거래, 게임 서버, RDMA 대안 ⚠ CPU 100% 사용 — 저부하 시 비효율
NAPI Busy-Poll: 인터럽트 경로를 제거하고 사용자 컨텍스트에서 직접 NIC 폴링
#include <liburing.h>

/* NAPI busy-poll 등록 (커널 6.9+) */
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

struct io_uring_napi napi = {
    .busy_poll_to = 100,    /* busy-poll 타임아웃: 100μs */
    .prefer_busy_poll = 1,  /* busy-poll 우선 */
};
io_uring_register_napi(&ring, &napi);

/* recv SQE 등록 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, sock_fd, buf, buf_len, 0);
io_uring_submit(&ring);

/* io_uring_enter(GETEVENTS) 시 내부적으로 napi_busy_loop() 호출
 * → NIC 드라이버의 poll 함수를 직접 호출하여 패킷 수확
 * → IRQ/softirq 경로를 완전히 우회 */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);  /* 여기서 busy-poll 발생 */

/* 해제 */
io_uring_unregister_napi(&ring, &napi);
설정 항목설명권장값
busy_poll_tobusy-poll 타임아웃 (μs). 이 시간 동안 NIC를 폴링50~200μs (워크로드에 따라 조정)
prefer_busy_poll1이면 인터럽트보다 busy-poll 우선1 (지연 중시 환경)
시스템 sysctlnet.core.busy_poll, net.core.busy_readio_uring NAPI 사용 시 0 (io_uring이 직접 관리)
⚠️

NIC 드라이버 요건: NAPI busy-poll은 드라이버가 ndo_busy_poll을 지원해야 합니다. Intel i40e, ixgbe, ice, Mellanox mlx5 등 주요 10GbE+ 드라이버가 지원합니다. ethtool -k <iface> | grep busy로 확인하세요.

Direct Descriptors (직접 파일 할당)

Direct Descriptors는 accept(), open() 등으로 생성되는 파일 디스크립터를 프로세스 fd 테이블에 설치하지 않고, io_uring의 fixed file 테이블에 직접 등록하는 기능입니다. 프로세스 fd 테이블의 잠금(fdget()/fdput())과 fd 번호 할당 오버헤드를 완전히 제거합니다.

전통적 fd 경로 vs Direct Descriptor 기존 accept() 경로 accept() 완료 fd 번호 할당 fd 테이블 삽입 spin_lock 필요 CQE.res = fd I/O: SQE.fd = N fdget(N) RCU + refcount 실제 I/O 수행 fdput(N) 매 I/O마다 반복 fd 할당 + fdget/fdput = 고빈도 잠금 경합 수만 연결 시 fd 테이블 리사이즈 비용 close() 호출 별도 필요 Direct Descriptor 경로 accept_direct() 완료 fixed file 슬롯 직접 설치 fd 테이블 우회 CQE.res = 0 (성공) I/O: FIXED_FILE fd = slot index 배열 직접 참조 실제 I/O 수행 fd 테이블 잠금 제거 → 경합 없음 fdget/fdput 생략 → I/O당 오버헤드 감소 close() 불필요 → CLOSE_DIRECT로 슬롯 해제 MSG_RING_FD로 다른 ring에 fd 전달 가능
기존 fd 경로(잠금 경합) vs Direct Descriptor(잠금 프리) 비교
#include <liburing.h>

/* 1. Fixed file 테이블 초기 할당 (빈 슬롯) */
int fds[1024];
memset(fds, -1, sizeof(fds));  /* -1 = 빈 슬롯 */
io_uring_register_files(&ring, fds, 1024);

/* 또는: 할당 범위 지정 (6.0+) */
struct io_uring_file_index_range range = {
    .off = 0, .len = 1024,
};
io_uring_register_file_alloc_range(&ring, range.off, range.len);

/* 2. Direct accept: 프로세스 fd 테이블을 거치지 않고 직접 fixed file에 설치 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept_direct(sqe, listen_fd, NULL, NULL, 0);
/* IORING_FILE_INDEX_ALLOC → 커널이 빈 슬롯 자동 선택 */

/* 3. CQE에서 fixed file index 확인 */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int file_index = cqe->res;  /* fixed file 슬롯 인덱스 */

/* 4. Fixed file로 I/O 수행 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, file_index, buf, len, 0);
sqe->flags |= IOSQE_FIXED_FILE;  /* 필수: fixed file 사용 표시 */

/* 5. 연결 종료 시: CLOSE_DIRECT로 슬롯만 해제 (process fd 테이블 미사용) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_close_direct(sqe, file_index);

/* 6. 필요 시 fixed file을 프로세스 fd로 변환 (6.12+) */
sqe = io_uring_get_sqe(&ring);
sqe->opcode = IORING_OP_FIXED_FD_INSTALL;
sqe->fd = file_index;
sqe->flags = IOSQE_FIXED_FILE;
/* CQE.res = 프로세스 fd 번호 (일반 fd처럼 사용 가능) */
연산Direct 변형설명
acceptio_uring_prep_accept_direct()새 소켓을 fixed file 슬롯에 직접 설치
multishot acceptio_uring_prep_multishot_accept_direct()multishot + 자동 슬롯 할당
openatio_uring_prep_openat_direct()파일을 fixed file 슬롯에 직접 열기
openat2io_uring_prep_openat2_direct()고급 플래그 + 직접 설치
socketio_uring_prep_socket_direct()소켓 생성 + 직접 설치
closeio_uring_prep_close_direct()fixed file 슬롯 해제 (fd close 불필요)
💡

대규모 연결 서버에서 필수: 수만 개의 동시 연결을 처리하는 서버에서 Direct Descriptors는 fd 테이블 잠금 경합을 완전히 제거합니다. 특히 SINGLE_ISSUER + multishot_accept_direct + IOSQE_FIXED_FILE 조합은 연결 수락부터 I/O까지 프로세스 fd 테이블을 전혀 사용하지 않는 완전 잠금-프리 경로를 구현합니다.

CQE32 / SQE128 확장 포맷

기본 SQE는 64바이트, CQE는 16바이트입니다. IORING_SETUP_SQE128IORING_SETUP_CQE32 플래그를 사용하면 각각 128바이트, 32바이트로 확장하여 추가 데이터를 전달할 수 있습니다. NVMe passthrough(io_uring_cmd)에서 주로 사용됩니다.

SQE / CQE 바이트 레이아웃 SQE (64 바이트 = 1 캐시라인) opcode flags ioprio fd off (u64) addr (u64) len (u32) rw_flags user_data (u64) buf_index / personality 오프셋 0~63: 표준 SQE 필드 SQE128 확장 영역: cmd[0..79] (80바이트) NVMe passthrough: nvme_uring_cmd 구조체 저장 CQE (16 바이트) user_data (u64) res (s32) flags (u32) CQE32 확장: extra1, extra2 (각 u64) NVMe: status code, result 추가 반환 SQE128 / CQE32 사용 사례 NVMe Passthrough SQE128: NVMe 커맨드 (cdw10-15) CQE32: NVMe 완료 상태 + 결과 IORING_OP_URING_CMD 필수 대용량 결과 반환 CQE32 extra1: 전송 바이트 수 (64비트) CQE32 extra2: 타임스탬프/상태 res(32비트) 범위 초과 결과용 메모리 영향 SQE128: 메모리 2배 (entries×128) CQE32: 메모리 2배 (entries×32) 불필요 시 사용하지 말 것 SQE 64B = 정확히 1 캐시라인 (L1d 최적) / SQE128 = 2 캐시라인 (캐시 효율 감소) CQE 16B = 캐시라인 4개 CQE 배치 가능 / CQE32 = 캐시라인 2개 CQE 설정: io_uring_params.flags |= IORING_SETUP_SQE128 | IORING_SETUP_CQE32
SQE(64/128바이트) 및 CQE(16/32바이트) 바이트 레이아웃과 확장 영역
/* SQE128 + CQE32 링 설정 (NVMe passthrough용) */
struct io_uring_params params = {
    .flags = IORING_SETUP_SQE128   /* SQE를 128바이트로 확장 */
           | IORING_SETUP_CQE32,   /* CQE를 32바이트로 확장 */
};
io_uring_queue_init_params(64, &ring, &params);

/* SQE128 mmap 크기: sq_entries × 128 (기본의 2배) */
/* CQE32 mmap 크기: cq_entries × 32 (기본의 2배) */

/* NVMe passthrough 예시 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
sqe->opcode   = IORING_OP_URING_CMD;
sqe->fd       = nvme_ns_fd;
sqe->cmd_op   = NVME_URING_CMD_IO;

/* 확장 영역(offset 64~127)에 NVMe 커맨드 배치 */
struct nvme_uring_cmd *cmd = (struct nvme_uring_cmd *)sqe->cmd;
cmd->opcode   = nvme_cmd_read;
cmd->nsid     = 1;
cmd->addr     = (__u64)buffer;
cmd->data_len = 4096;
cmd->cdw10    = lba & 0xFFFFFFFF;
cmd->cdw11    = lba >> 32;
cmd->cdw12    = (4096 / 512) - 1;  /* 블록 수 */

io_uring_submit(&ring);

/* CQE32에서 확장 결과 읽기 */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
__u64 extra1 = io_uring_cqe_get_extra1(cqe);  /* NVMe 결과 DW0 */
__u64 extra2 = io_uring_cqe_get_extra2(cqe);  /* NVMe 결과 DW1 */

Buffered I/O 내부 경로

io_uring이 기존 Linux AIO와 결정적으로 다른 점 중 하나는 Buffered I/O를 진정한 비동기로 처리할 수 있다는 것입니다. 페이지 캐시 히트 시 즉시 완료하고, 캐시 미스 시 자동으로 io-wq 워커에 위임하여 블로킹 없이 처리합니다.

io_uring Buffered Read 내부 경로 io_read() — IORING_OP_READ O_DIRECT 아닌 일반 파일 kiocb.ki_flags |= IOCB_NOWAIT 논블로킹 시도: vfs_read(NOWAIT) 페이지 캐시 히트 페이지 캐시 미스 Fast Path (즉시 완료) 페이지 캐시에서 데이터 복사 kiocb 즉시 완료 반환 io_req_complete() CQE 즉시 게시 시스템 콜 컨텍스트에서 완료 io-wq 워커 불필요 컨텍스트 스위치 0회 Slow Path (io-wq 위임) -EAGAIN 반환 NOWAIT 실패 → 블로킹 필요 io_queue_async() io-wq bounded worker에 전달 워커: vfs_read() (블로킹 허용) 디스크 I/O + readahead 수행 task_work로 CQE 게시 제출자 태스크 컨텍스트에서 완료 워커 스레드에서 블로킹 I/O 제출자는 블로킹되지 않음 readahead로 후속 히트율 증가 핵심 차이 Linux AIO는 buffered I/O 미지원!
Buffered Read 경로: 페이지 캐시 히트(Fast Path) vs 미스(Slow Path → io-wq)
경로페이지 캐시워커 사용지연발생 비율
Fast Path히트없음 (인라인 완료)1~5μs워킹셋 내: 90%+
Slow Path미스io-wq bounded worker50~500μs (디스크)콜드 데이터: 가변
Readahead 히트히트 (예측)없음1~5μs순차 읽기: 95%+
ℹ️

NOWAIT 시도 메커니즘: io_uring은 모든 buffered I/O를 먼저 IOCB_NOWAIT 플래그로 시도합니다. 페이지가 캐시에 있으면 즉시 반환되고, 없으면 -EAGAIN이 반환되어 io-wq로 넘어갑니다. 이 "먼저 시도하고, 안 되면 위임" 전략이 buffered I/O의 비동기 처리 핵심입니다. 파일시스템이 FMODE_NOWAIT을 지원해야 하며, ext4, XFS, btrfs 등이 지원합니다.

Registered Ring FD 최적화

IORING_REGISTER_RING_FDS(5.18+)는 io_uring 링 자체의 fd를 커널 내부 테이블에 등록하여, io_uring_enter() 호출 시 fdget()/fdput() 오버헤드를 제거합니다. 고빈도로 io_uring_enter()를 호출하는 환경에서 유효합니다.

#include <liburing.h>

struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

/* Ring fd를 커널에 등록 → enter_ring_fd 사용 가능 */
int ret = io_uring_register_ring_fd(&ring);
if (ret == 1) {
    /* 이후 io_uring_enter()는 등록된 인덱스를 사용
     * → fdget/fdput 스킵 → 시스템 콜당 ~200ns 절약
     * liburing이 자동으로 enter_ring_fd 사용 */
}

/* 일반 I/O 수행 — 내부적으로 최적화된 enter 경로 사용 */
io_uring_submit(&ring);

/* 해제 */
io_uring_unregister_ring_fd(&ring);
최적화제거 비용절약량적합 환경
Registered Ring FDio_uring_enter()의 fdget/fdput~200ns/콜기본 모드 (빈번한 enter 호출)
Fixed FilesI/O 대상 fd의 fdget/fdput~150ns/I/O모든 환경 (필수 최적화)
Fixed BuffersGUP (get_user_pages) 비용~500ns/I/O대용량/고빈도 I/O
SQPOLLio_uring_enter() 시스템 콜 자체~1μs/콜극한 지연 요구
💡

liburing 자동 처리: liburing 2.3+에서 io_uring_queue_init() 시 자동으로 io_uring_register_ring_fd()를 호출합니다. 직접 원시 시스템 콜을 사용하는 경우에만 수동 등록이 필요합니다.

kTLS + io_uring 통합

커널 TLS(kTLS)는 TLS 암호화/복호화를 커널 공간에서 수행하여, 사용자 공간 TLS 라이브러리(OpenSSL 등)의 복사 오버헤드를 제거합니다. io_uring의 send/recv와 결합하면 TLS 전송도 비동기 + 제로카피로 처리할 수 있습니다.

kTLS + io_uring 데이터 경로 기존: OpenSSL + send() 평문 데이터 OpenSSL 암호화 (유저 공간 복사) 암호문 버퍼 (추가 복사) send() 커널 복사 (3번째 복사) NIC 데이터 복사 3회 + 시스템 콜 1회 + 사용자 공간 암호화 CPU 소비 kTLS + io_uring SEND_ZC 평문 데이터 io_uring SEND_ZC 평문 직접 전달 커널 kTLS 암호화 AES-NI / 하드웨어 오프로드 NIC 직접 전송 제로카피 데이터 복사 0회 + 시스템 콜 0회(SQPOLL) + 커널 공간 AES-NI 암호화 성능 효과 (10GbE, HTTPS) 처리량 +40~60%, CPU 사용 -30~50% 하드웨어 TLS 오프로드 (선택) Mellanox ConnectX-6+, Intel E810: NIC에서 암호화
기존 OpenSSL 경로(3회 복사) vs kTLS + io_uring(제로카피) 비교
#include <linux/tls.h>
#include <liburing.h>

/* 1. TCP 소켓에 kTLS 설정 (OpenSSL 핸드셰이크 후) */
struct tls12_crypto_info_aes_gcm_128 crypto_info;
crypto_info.info.version     = TLS_1_2_VERSION;
crypto_info.info.cipher_type = TLS_CIPHER_AES_GCM_128;
/* iv, key, salt, rec_seq 설정 (OpenSSL에서 추출) */

setsockopt(sock_fd, SOL_TCP, TCP_ULP, "tls", 3);
setsockopt(sock_fd, SOL_TLS, TLS_TX,
           &crypto_info, sizeof(crypto_info));

/* 2. io_uring으로 TLS 전송 (평문을 보내면 커널이 암호화) */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, sock_fd, plaintext, len, 0);
/* 커널 kTLS 레이어가 자동으로 AES-GCM 암호화 수행 */

/* 3. 제로카피 TLS 전송 (대용량 전송 시) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, sock_fd, plaintext, len, 0, 0);
/* kTLS + SEND_ZC: 최소 복사로 최대 처리량 */

io_uring_submit(&ring);
ℹ️

kTLS 적용 사례: nginx 1.21.4+에서 ssl_conf_command Options KTLS 설정으로 kTLS를 활성화할 수 있습니다. Envoy, HAProxy도 kTLS 지원을 추가하고 있으며, io_uring 이벤트 루프와 결합하면 HTTPS 프록시 성능이 크게 향상됩니다. kTLS 지원 확인: cat /proc/net/tls_stat

io_uring 내부 잠금 전략

io_uring의 성능 핵심 중 하나는 잠금을 최소화하는 설계입니다. SQ/CQ 링 자체는 lock-free이지만, 내부 자원 관리(io_ring_ctx)에는 다양한 잠금이 사용되며, 커널 버전에 따라 세분화되어 왔습니다.

io_uring 잠금 전략 진화 Phase 1: 5.1~5.x ctx→uring_lock (큰 뮤텍스) SQE 파싱 → 잠금 CQE 게시 → 잠금 리소스 관리 → 잠금 Phase 2: 5.10~6.0 잠금 분리 (CQ/SQ 독립) SQE: uring_lock CQE: completion_lock (스핀락) 리소스: 별도 RCU Phase 3: 6.1+ (최적) SINGLE_ISSUER + DEFER_TASKRUN SQE: 잠금 없음 (단일 제출자) CQE: 잠금 없음 (task_work 일괄) 경합 제로 → 최고 확장성 io_ring_ctx 주요 잠금 구조 uring_lock (mutex) SQE 파싱, 등록 연산 제출 경로 보호 completion_lock CQE 게시, 오버플로 spinlock_t timeout_lock 타이머 관리 spinlock_t cancel_lock 취소 요청 관리 spinlock_t SINGLE_ISSUER: uring_lock → 불필요 (단일 제출자 보장) DEFER_TASKRUN: completion_lock → 불필요 (일괄 처리, 경합 없음) → 핫 경로에서 잠금 0개: 제출 → I/O → CQE 게시까지 lock-free
io_uring 잠금 전략 진화: 큰 뮤텍스 → 분리 → 완전 lock-free
잠금보호 대상SINGLE_ISSUER 시DEFER_TASKRUN 시
uring_lockSQE 파싱, 리소스 등록/해제제출 경로에서 생략 가능동일
completion_lockCQE 게시, 오버플로 리스트여전히 필요 (다중 완료 소스)불필요 (일괄 처리)
timeout_lock타이머 리스트 관리여전히 필요여전히 필요
cancel_lock취소 해시 테이블여전히 필요여전히 필요
SQ/CQ 링 포인터head/tail 포인터항상 lock-free (메모리 배리어만 사용)

시스템 파라미터 튜닝 가이드

io_uring 성능과 안정성에 영향을 미치는 커널 파라미터, 리소스 제한, 시스템 설정을 종합적으로 정리합니다.

리소스 제한 (rlimits)

파라미터영향기본값권장값설정 방법
RLIMIT_MEMLOCKmmap 고정 메모리 상한 (SQ/CQ 링, 고정 버퍼)64KB (대부분)256MB+ (고성능 서버)ulimit -l unlimited 또는 /etc/security/limits.conf
RLIMIT_NPROCio-wq bounded 워커 최대 수프로세스 제한워크로드에 따라ulimit -u 65535
RLIMIT_NOFILEfd 테이블 크기 (Direct Descriptor 사용 시 덜 중요)102465536+ulimit -n 65536

커널 sysctl 파라미터

sysctl 경로설명기본값권장
kernel.io_uring_disabledio_uring 사용 제어0 (허용)프로덕션: 0, 컨테이너: 1 또는 2
kernel.io_uring_groupio_uring 사용 허용 GID (6.1+)-1 (비활성)특정 그룹만 허용 시 설정
fs.file-max시스템 전체 fd 상한시스템 의존고성능 서버: 2097152+
fs.nr_open프로세스별 fd 상한1048576일반적으로 충분
vm.locked_vm(간접) mmap 고정 페이지MEMLOCK 기반io_uring 크기에 따라
net.core.busy_poll소켓 busy-poll 타임아웃 (μs)0io_uring NAPI 사용 시 0 (io_uring이 관리)
net.core.busy_read소켓 busy-read 타임아웃 (μs)0io_uring NAPI 사용 시 0
net.core.somaxconnlisten 백로그 최대값4096고연결 서버: 65535

io-wq 워커 제어

/* io-wq 워커 수 제한 */
unsigned int workers[2] = {
    16,   /* [0] bounded: buffered I/O 워커 (RLIMIT_NPROC 이하) */
    8,    /* [1] unbounded: 네트워크/폴 대기 워커 */
};
io_uring_register_iowq_max_workers(&ring, workers);
/* 반환: workers[]에 이전 값이 저장됨 */

/* io-wq 워커 CPU affinity 설정 */
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
for (int i = 0; i < 8; i++)
    CPU_SET(i, &cpuset);
io_uring_register_iowq_aff(&ring, sizeof(cpuset), &cpuset);

프로덕션 배포 체크리스트

💡

프로덕션 환경 설정 순서:

  1. RLIMIT_MEMLOCK 증가: 링 크기 + 고정 버퍼 크기를 수용하도록 설정
  2. 커널 버전 확인: 최소 5.11+ (SQPOLL 개선), 권장 6.1+ (DEFER_TASKRUN)
  3. 보안 설정: 컨테이너에서는 io_uring_disabled=1, 필요 시 RESTRICTIONS 적용
  4. CPU 바인딩: SQPOLL CPU, io-wq affinity, NUMA 노드 일치 확인
  5. 모니터링: /proc/PID/fdinfo, tracepoint, bpftrace로 CQ 오버플로/워커 포화 감시
  6. 성능 측정: fio로 기준선 측정 후 최적화 효과 검증
# 프로덕션 서버 io_uring 설정 스크립트

# 1. 리소스 제한 설정
cat <<EOF >> /etc/security/limits.conf
*    soft    memlock    unlimited
*    hard    memlock    unlimited
*    soft    nofile     65536
*    hard    nofile     65536
EOF

# 2. sysctl 설정
cat <<EOF >> /etc/sysctl.d/99-io-uring.conf
# io_uring 허용 (비특권 사용자 차단)
kernel.io_uring_disabled = 1

# 파일 시스템 제한
fs.file-max = 2097152
fs.nr_open = 1048576

# 네트워크 (io_uring NAPI 사용 시)
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 50000
net.ipv4.tcp_max_syn_backlog = 30000
EOF

sysctl -p /etc/sysctl.d/99-io-uring.conf

# 3. 커널 버전 및 io_uring 기능 확인
uname -r
cat /proc/sys/kernel/io_uring_disabled

# 4. SQPOLL 권한 부여 (특정 바이너리에만)
setcap cap_sys_nice+ep /usr/local/bin/my_server

# 5. fio 기준선 측정
fio --ioengine=io_uring --direct=1 --bs=4k --iodepth=64 \
    --rw=randread --filename=/dev/nvme0n1 --name=baseline \
    --fixedbufs=1 --registerfiles=1 --sqthread_poll=1

커널 버전 호환성 매트릭스

io_uring 기능은 커널 버전에 따라 크게 달라집니다. 다음은 주요 기능별 최소 요구 커널 버전을 종합한 호환성 매트릭스입니다.

기능 범주기능최소 커널안정 권장비고
기본io_uring 기본 (setup/enter/register)5.15.4+초기 버전은 버그 다수
SQ/CQ 통합 mmap5.45.4+IORING_FEAT_SINGLE_MMAP
liburing 호환5.15.10+liburing 2.x는 5.10+ 권장
모드SQPOLL5.15.11+5.11에서 안정성 대폭 개선
IOPOLL5.15.1+O_DIRECT 필수
SINGLE_ISSUER6.06.0+내부 잠금 제거
DEFER_TASKRUN6.16.1+SINGLE_ISSUER 필수
리소스 등록Fixed files5.15.5+5.5에서 업데이트 지원
Fixed buffers5.15.1+GUP 비용 제거
Provided buffer ring5.195.19+mmap 방식, 이전 PROVIDE_BUFFERS 대체
Registered ring fd5.185.18+enter() 시 fdget 비용 제거
네트워크accept/connect/send/recv5.55.6+기본 네트워크 연산
Multishot accept5.195.19+ACCEPT_MULTISHOT
Multishot recv6.06.0+RECV_MULTISHOT + provided buffers
SEND_ZC (제로카피 전송)6.06.0+64KB+ 대용량에 효과적
NAPI busy-poll6.96.9+초저지연 네트워크
고급MSG_RING5.185.18+링 간 통신
MSG_RING_FD6.36.3+fd 전달
io_uring_cmd (NVMe passthrough)6.06.0+SQE128 필요
파일시스템open/close/statx5.65.6+비동기 메타데이터 연산
Direct descriptors5.156.0+FILE_INDEX_ALLOC은 6.0+
FUSE io_uring6.146.14+FUSE 성능 대폭 향상
보안io_uring_disabled sysctl5.12.45.12.4+0/1/2 레벨
RESTRICTIONS5.135.13+opcode/플래그 제한
NO_SQARRAY6.76.7+메모리 절약
LSM 훅6.156.15+SELinux/AppArmor 통합
⚠️

최소 권장 버전: 새 프로젝트에서 io_uring을 사용한다면 최소 커널 6.1+을 권장합니다. SINGLE_ISSUER + DEFER_TASKRUN 조합이 가능하고, 다수의 보안 패치와 성능 개선이 포함되어 있습니다. 5.x 커널에서는 CVE가 다수 존재하므로, 반드시 최신 안정 패치를 적용하세요. IORING_REGISTER_PROBE로 런타임에 기능을 탐지하면 다양한 커널 버전에서 안전하게 동작할 수 있습니다.

io_uring과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.