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 대비 선택 기준, 보안 제약과 취약점 대응 전략, 운영 환경에서의 계측·튜닝·장애 분석 절차까지 고성능 서비스 개발에 필요한 핵심 내용을 종합적으로 다룹니다.
핵심 요약
- io_uring — Linux 5.1에서 도입된 고성능 비동기 I/O 인터페이스입니다.
- SQ / CQ — Submission Queue(제출 큐)와 Completion Queue(완료 큐). 사용자-커널 간 공유 링 버퍼입니다.
- SQPOLL — 커널 스레드가 SQ를 폴링하여 시스템 콜 없이 I/O를 처리하는 모드입니다.
- liburing — io_uring을 쉽게 사용하기 위한 사용자 공간 라이브러리입니다.
- 제로카피 — 데이터 복사 없이 네트워크 전송/수신을 수행하는 고급 기능입니다.
단계별 이해
- 왜 필요한가 — 기존
read()/write()는 매번 시스템 콜 전환이 필요하고, POSIX AIO는 제한적입니다.NVMe SSD처럼 수백만 IOPS 디바이스에서는 시스템 콜 오버헤드가 병목이 됩니다.
- 링 버퍼 이해 — 사용자가 SQE(Submission Queue Entry)를 SQ에 넣으면, 커널이 처리 후 CQE(Completion Queue Entry)를 CQ에 넣습니다.
공유 메모리이므로 데이터 복사 없이 포인터만 이동합니다.
- liburing 체험 —
io_uring_queue_init()으로 링을 초기화하고,io_uring_prep_readv()로 읽기를 준비합니다.io_uring_submit()으로 제출,io_uring_wait_cqe()로 완료를 기다립니다. - 성능 확인 —
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 발전 역사
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.2 | POLL_REMOVE, io-wq 워커 풀 도입 |
| 5.3 | TIMEOUT, SQE 링크(IOSQE_IO_LINK) |
| 5.4 | TIMEOUT_REMOVE, ACCEPT, ASYNC_CANCEL, LINK_TIMEOUT |
| 5.5 | CONNECT, FALLOCATE, OPENAT, CLOSE, STATX, PROVIDE_BUFFERS |
| 5.6 | READ/WRITE (단순화), SPLICE, TEE, SQPOLL CPU affinity 개선 |
| 5.7 | EPOLL_CTL, MADVISE, OPENAT2 |
| 5.11 | SHUTDOWN, RENAMEAT, UNLINKAT, MKDIRAT |
| 5.12 | SYMLINKAT, LINKAT, io_uring_disabled sysctl 보안 옵션 |
| 5.15 | MSG_RING (ring-to-ring 메시징) |
| 5.18 | SOCKET (소켓 생성), 등록 파일 업데이트 |
| 5.19 | SEND_ZC (제로카피 전송), provided buf ring mmap API |
| 6.0 | SEND_ZC 안정화, io_uring_cmd (NVMe passthrough) |
| 6.1 | IORING_SETUP_SINGLE_ISSUER, IORING_SETUP_DEFER_TASKRUN |
| 6.2 | RECV_ZC (제로카피 수신), 멀티 CQE32 |
| 6.3 | WAITID, IORING_REGISTER_RESTRICTIONS |
| 6.7 | IORING_SETUP_NO_SQARRAY (SQ 배열 제거로 메모리 절약) |
| 6.9 | FUTEX_WAIT/WAKE, 네이티브 futex 지원, IORING_REGISTER_NAPI (busy-poll) |
| 6.10 | FUTEX_WAITV, clock 소스 지원, pbuf_ring 증분 소비(incremental), io-wq 해시 최적화 |
| 6.11 | IORING_SETUP_NO_MMAP, 커널 측 ring 할당, 번들(bundle) SQE 실험적 지원 |
| 6.12 | FIXED_FD_INSTALL opcode, 소켓 직접 설치, 등록 잠금 최적화 |
| 6.13 | CLONE 관련 정리, per-ring NAPI 개선, 대기 영역(wait region) 실험적 |
| 6.14 | FUSE io_uring 지원, 번들 recv/send 안정화, 성능 카운터 통합 |
| 6.15 | io_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 영역을 공유하여 메모리를 절약합니다.
/* 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_RING | 0x00000000 | SQ Ring + CQ Ring (공유) | max(SQ 링 크기, CQ 링 크기) |
IORING_OFF_CQ_RING | 0x08000000 | CQ Ring (5.4 이전 별도 매핑) | 5.4+에서는 SQ_RING과 동일 |
IORING_OFF_SQES | 0x10000000 | SQE 배열 | sq_entries × 64 (또는 128) |
IORING_OFF_PBUF_RING | 0x80000000 | Provided buffer ring | 등록 시 결정 |
SQ/CQ 통합 매핑 (5.4+): 커널 5.4 이전에는 SQ Ring과 CQ Ring이 별도로 mmap 되었으나, 이후 하나의 mmap 호출로 통합되었습니다. IORING_OFF_CQ_RING으로 mmap하면 IORING_OFF_SQ_RING과 동일한 주소를 반환합니다. params.features에 IORING_FEAT_SINGLE_MMAP 비트가 설정되어 있으면 통합 매핑이 지원됩니다.
SQE 간접 인덱싱 상세
SQ Ring에는 SQE를 직접 저장하지 않고, sq.array[]라는 간접 인덱스 배열이 있습니다. 이 배열의 각 항목은 별도 mmap된 sqes[] 배열의 인덱스를 가리킵니다.
/* 간접 인덱싱 (기본): 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/O | IORING_OP_READ | 파일 읽기 (고정 버퍼 지원) | 5.6 |
IORING_OP_WRITE | 파일 쓰기 (고정 버퍼 지원) | 5.6 | |
IORING_OP_READV / WRITEV | Scatter-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 / RECVMSG | msghdr 기반 소켓 송수신 | 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_INSTALL | fixed file을 프로세스 fd 테이블에 설치 | 6.12 | |
| Epoll 호환 | IORING_OP_EPOLL_CTL | epoll_ctl 비동기 실행 | 5.6 |
IORING_OP_FILES_UPDATE | 등록된 파일 테이블 업데이트 | 5.6 |
io_uring_params 플래그 상세
io_uring_setup() 호출 시 io_uring_params.flags에 설정하는 플래그들은 링의 동작 방식을 결정합니다.
| 플래그 | 도입 | 설명 |
|---|---|---|
IORING_SETUP_IOPOLL | 5.1 | 완료를 인터럽트 대신 폴링으로 확인. O_DIRECT 전용 |
IORING_SETUP_SQPOLL | 5.1 | 커널 스레드가 SQ를 폴링. 시스템 콜 없이 I/O 제출 |
IORING_SETUP_SQ_AFF | 5.1 | SQPOLL 스레드를 sq_thread_cpu에 바인딩 |
IORING_SETUP_CQSIZE | 5.5 | CQ 크기를 cq_entries로 별도 지정 |
IORING_SETUP_ATTACH_WQ | 5.6 | 기존 ring의 io-wq 워커 풀을 공유 |
IORING_SETUP_R_DISABLED | 5.10 | ring을 비활성 상태로 생성. ENABLE_RINGS로 활성화 |
IORING_SETUP_COOP_TASKRUN | 5.19 | task_work를 협력적으로 처리. io_uring_enter() 진입 시에만 완료 |
IORING_SETUP_SQE128 | 5.19 | SQE를 128바이트로 확장 (NVMe passthrough 등) |
IORING_SETUP_CQE32 | 5.19 | CQE를 32바이트로 확장 |
IORING_SETUP_SINGLE_ISSUER | 6.0 | 단일 태스크만 제출 보장. 내부 잠금 최적화 |
IORING_SETUP_DEFER_TASKRUN | 6.1 | SINGLE_ISSUER 필요. task_work를 io_uring_enter() 시 일괄 처리 |
IORING_SETUP_NO_SQARRAY | 6.7 | SQ 간접 인덱스 배열 생략. 메모리 절약 |
최고 성능 조합: 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, ¶ms);
io_uring_enter(ring_fd, 1, 1, IORING_ENTER_GETEVENTS, NULL);
SQPOLL 모드 (커널 폴링)
커널 스레드(io_uring-sq)가 SQ를 지속적으로 폴링합니다. 시스템 콜 없이 SQ tail만 갱신하면 커널이 자동으로 처리합니다.
struct io_uring_params params = {
.flags = IORING_SETUP_SQPOLL,
.sq_thread_idle = 2000, /* 2초 유휴 시 스레드 슬립 */
};
int ring_fd = io_uring_setup(256, ¶ms);
/* 커널 스레드가 슬립했다면 깨워야 함 */
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) */
고급 기능
SQE 링크 (Chaining)
/* 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=취소됨 */
IORING_OP_LINK_TIMEOUT
/* 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 서브타입 | 도입 | 설명 | 전달 데이터 |
|---|---|---|---|
IORING_MSG_DATA | 5.18 | 임의 데이터를 CQE로 전달 | sqe->len → CQE.res, sqe->addr → CQE.user_data |
IORING_MSG_SEND_FD | 6.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을 사용하면 시스템 콜 오버헤드와 컨텍스트 스위칭을 크게 줄일 수 있습니다.
| 경로 | 시스템 콜 수 (요청당) | 컨텍스트 스위칭 |
|---|---|---|
| 기존 FUSE | 2 (read + write) | 2회 이상 |
| FUSE + io_uring | 0 (SQ/CQ 공유 메모리) | 최소화 (SQPOLL 시 0) |
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 링 메타데이터 필드 | 오프셋 | 크기 | 기록자 | 읽기자 | 설명 |
|---|---|---|---|---|---|
cq.head | IORING_OFF_CQ_RING + 0 | 4B | 유저 | 커널 | 다음 소비할 CQE 위치 |
cq.tail | IORING_OFF_CQ_RING + 4 | 4B | 커널 | 유저 | 다음 기록할 CQE 위치 |
cq.ring_mask | io_cqring_offsets.ring_mask | 4B | 커널 | 유저 | entries - 1 |
cq.ring_entries | io_cqring_offsets.ring_entries | 4B | 커널 | 유저 | CQ 엔트리 총 개수 |
cq.overflow | io_cqring_offsets.overflow | 4B | 커널 | 유저 | 오버플로 발생 누적 횟수 |
cq.cqes[] | io_cqring_offsets.cqes | entries × 16B | 커널 | 유저 | CQE 배열 (CQE32는 ×32B) |
cq.flags | io_cqring_offsets.flags | 4B | 커널 | 유저 | 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, -EAGAIN | short read 가능 |
IORING_OP_WRITE | 쓴 바이트 수 | (해당 없음) | -ENOSPC, -EIO | short write 가능 |
IORING_OP_READV | 읽은 총 바이트 | EOF | -EFAULT | 벡터 I/O |
IORING_OP_WRITEV | 쓴 총 바이트 | (해당 없음) | -ENOSPC | 벡터 I/O |
IORING_OP_ACCEPT | 새 소켓 fd | (해당 없음) | -ECONNABORTED | multishot: F_MORE |
IORING_OP_CONNECT | (해당 없음) | 성공 | -ECONNREFUSED | res == 0이 성공 |
IORING_OP_RECV | 수신 바이트 수 | 연결 종료 | -ENOTCONN | multishot: F_MORE |
IORING_OP_SEND | 송신 바이트 수 | (해당 없음) | -EPIPE, -ECONNRESET | short send 가능 |
IORING_OP_POLL_ADD | 발생한 poll 이벤트 | (해당 없음) | -ECANCELED | 비트마스크 |
IORING_OP_TIMEOUT | (해당 없음) | -ETIME(만료) | -ECANCELED | 만료 시 -ETIME |
IORING_OP_NOP | (해당 없음) | 성공 | (없음) | 항상 0 |
IORING_OP_OPENAT | 새 파일 fd | (해당 없음) | -ENOENT, -EACCES | direct: 고정 fd 인덱스 |
IORING_OP_CLOSE | (해당 없음) | 성공 | -EBADF | |
IORING_OP_FSYNC | (해당 없음) | 성공 | -EIO | |
IORING_OP_SENDMSG | 송신 바이트 | (해당 없음) | -ENOBUFS | |
IORING_OP_RECVMSG | 수신 바이트 | 연결 종료 | -ENOTCONN | multishot 지원 |
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_BUFFER | 0 | 0x1 | Provided buffer 사용됨; 상위 16비트 = 버퍼 ID | 5.7 |
IORING_CQE_F_MORE | 1 | 0x2 | Multishot: 추가 CQE가 더 올 예정 | 5.13 |
IORING_CQE_F_SOCK_NONEMPTY | 2 | 0x4 | 소켓에 아직 읽을 데이터 있음 | 5.19 |
IORING_CQE_F_NOTIF | 3 | 0x8 | 제로카피 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()입니다.
/* 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
| liburing API | 동작 | 블로킹 | 반환 | 사용 시나리오 |
|---|---|---|---|---|
io_uring_peek_cqe() | CQ 확인, 없으면 즉시 반환 | 아니오 | 0 또는 -EAGAIN | busy-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/O | 64 | 2× | 128 | 2 KB | 기본값 충분, 순차 I/O |
| 웹 서버 (epoll 대체) | 256 | 4× | 1024 | 16 KB | 다중 연결, 간헐적 burst |
| DB WAL 쓰기 | 512 | 4× | 2048 | 32 KB | fsync 지연 중 쓰기 누적 |
| Multishot recv 서버 | 256 | 8×~16× | 2048~4096 | 32~64 KB | 단일 SQE → 다수 CQE |
| 고성능 스토리지 (NVMe) | 1024 | 4× | 4096 | 64 KB | 높은 QD, 빠른 완료 |
| 프록시/게이트웨이 | 512 | 8× | 4096 | 64 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, ¶ms);
/* 커널이 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 사용량 특성이 다릅니다. 워크로드에 맞는 대기 전략 선택이 중요합니다.
/* 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)에 보관합니다. 이 리스트의 생명주기와 플러시/드롭 메커니즘을 상세히 분석합니다.
/* 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_OVERFLOW | sq_flags (bit 1) | 오버플로 리스트에 CQE 존재 |
cq.overflow | io_rings | 드롭된 CQE 누적 카운터 (GFP_ATOMIC 실패 시) |
cq_overflow_list | io_ring_ctx | 커널 내부 연결 리스트 |
IORING_SETUP_NOCLAMP | params.flags | CQ 크기를 SQ 크기로 클램핑하지 않음 (5.8+) |
교차참조: 기본 오버플로 처리 개요는 CQE Overflow 처리, 오버플로 예방을 위한 CQ 크기 전략은 CQ 크기 선택 전략 섹션을 참고하세요.
CQE 순서 보장과 완료 의미론
io_uring은 기본적으로 CQE 순서를 보장하지 않습니다. I/O가 완료되는 순서대로 CQE가 게시되므로, 빠른 연산이 느린 연산보다 먼저 완료될 수 있습니다. 링크와 드레인 플래그로 순서를 강제할 수 있습니다.
| 플래그 | 순서 보장 | 실패 동작 | 사용 사례 |
|---|---|---|---|
| (없음) 독립 SQE | 없음 | 개별 처리 | 병렬 I/O, 최대 처리량 |
IOSQE_IO_LINK | 체인 순서 보장 | 실패 시 후속 -ECANCELED | read → 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 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()를 호출하면 메모리 배리어 비용을 최소화할 수 있습니다.
DEFER_TASKRUN과 CQ 성능
IORING_SETUP_DEFER_TASKRUN | IORING_SETUP_SINGLE_ISSUER 조합은 CQE 게시를 io_uring_enter() 호출 시점으로 지연시킵니다. 이를 통해 task_work 인터럽트 없이 예측 가능한 지연시간을 제공합니다.
| CQ 성능 튜닝 파라미터 | 효과 | 트레이드오프 |
|---|---|---|
배치 소비 (cq_advance) | store_release 횟수 감소 | 소비 지연 증가 |
DEFER_TASKRUN | 인터럽트 제거, 예측 가능 | 평균 지연 소폭 증가 |
SINGLE_ISSUER | lock-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/성공), 음수(에러)의 세 가지 범주와 각 에러 코드별 복구 전략을 정리합니다.
| 에러 코드 | 카테고리 | 원인 | 복구 전략 |
|---|---|---|---|
-EAGAIN | 일반 | 자원 일시 부족 | 재제출 (IOSQE_ASYNC 또는 지연 후) |
-ECANCELED | 일반 | 요청 취소됨 (CANCEL/링크 실패) | 정리, 필요 시 재제출 |
-ETIME | 타임아웃 | 타임아웃 만료 | 타임아웃 정상 처리 |
-EINVAL | 일반 | 잘못된 파라미터 | SQE 파라미터 검증 (프로그래밍 오류) |
-EBADF | 파일 I/O | 잘못된 fd | fd 유효성 검증 |
-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 등록 + 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;
};
task_work 메커니즘: io_uring은 완료 처리를 제출자 태스크의 컨텍스트에서 수행하기 위해 task_work_add()를 사용합니다. DEFER_TASKRUN 플래그를 사용하면 task_work가 io_uring_enter() 호출 시에만 일괄 실행되어 효율이 높아집니다.
io-wq 워커 스레드 풀
즉시 완료되지 않는 (블로킹) 요청은 io-wq 커널 워커 스레드 풀로 넘겨집니다.
| 워커 유형 | 용도 | 최대 수 |
|---|---|---|
| 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 실행 시점 | IPI | 적합 상황 |
|---|---|---|---|---|
| 기본 | (없음) | 커널→유저 전환 시 즉시 | 발생 | 범용, 단순 사용 |
| COOP_TASKRUN | IORING_SETUP_COOP_TASKRUN | io_uring_enter() 호출 시 | 없음 | 폴링 루프 앱 |
| DEFER_TASKRUN | IORING_SETUP_DEFER_TASKRUN | io_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, ¶ms);
/* 이벤트 루프: 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.c | 핵심: ring 생성/파괴, SQE 디스패치, CQE 게시 | io_uring_setup(), io_submit_sqes() |
sqpoll.c | SQPOLL 커널 스레드 관리 | io_sq_thread(), 슬립/웨이크업 |
io-wq.c | 비동기 워커 스레드 풀 | io_wq_enqueue(), 워커 생성/소멸 |
rsrc.c | 파일/버퍼 등록, 리소스 수명 관리 | io_register_files(), io_register_buffers() |
kbuf.c | Provided buffer ring 관리 | io_provide_buffers() |
rw.c | read/write/readv/writev 핸들러 | io_read(), io_write() |
net.c | send/recv/accept/connect 핸들러 | io_sendmsg(), io_accept() |
poll.c | poll_add/poll_remove 핸들러 | io_poll_add(), multishot poll |
timeout.c | timeout/link_timeout 핸들러 | io_timeout() |
cancel.c | async_cancel 핸들러 | io_async_cancel() |
msg_ring.c | MSG_RING 링 간 통신 | io_msg_ring() |
uring_cmd.c | io_uring_cmd passthrough | io_uring_cmd() |
splice.c | splice/tee 핸들러 | io_splice(), io_tee() |
openclose.c | open/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(¶ms, 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, ¶ms);
}
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 최적화 등의 이점을 얻을 수 있습니다.
/* 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 패턴(준비 상태 통지)과 근본적으로 다릅니다.
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/write | 1 | 블로킹 시 발생 | 단순, 저처리량 |
| 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) | 385 | 332 | 82 | O_DIRECT 필수 |
| io_uring (기본) | 472 | 271 | 78 | +22.6% IOPS |
| io_uring (SQPOLL) | 531 | 241 | 92 | +37.9% IOPS, 1 CPU 전용 |
| io_uring (SQPOLL+IOPOLL) | 624 | 205 | 145 | +62.1% IOPS, 인터럽트 제거 |
| io_uring (SQPOLL+IOPOLL+FIXEDFILE) | 698 | 183 | 148 | +81.3% IOPS, fd 조회 제거 |
순차 읽기 성능 (128KB, QD=32, buffered I/O):
| I/O 방식 | 처리량 (GB/s) | 시스템 콜 수/초 | 특징 |
|---|---|---|---|
| read() 동기 | 2.1 | 16,800 | 스레드 풀 필요 |
| libaio | N/A | - | buffered I/O 미지원 |
| io_uring (기본) | 4.8 | 1,200 | 배치 제출 효과 |
| io_uring (SQPOLL) | 5.6 | 0 | Zero syscall |
벤치마크 해석 주의: SQPOLL+IOPOLL은 전용 CPU 코어를 100% 소비하므로, 실제 애플리케이션에서는 총 시스템 처리량과 CPU 효율성을 함께 고려해야 합니다. 워크로드가 충분히 높지 않으면 오히려 비효율적일 수 있습니다.
io_uring vs epoll 상세 비교
| 비교 항목 | epoll | io_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, ®, 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 + fsync | 2N (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를 활용하면 사용자 공간 버퍼를 거치지 않고 커널 내에서 직접 데이터를 전달하는 제로카피 프록시를 구현할 수 있습니다.
#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, ¶ms);
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
성능 최적화 체크리스트
성능 향상 우선순위:
- 배치 제출: 여러 SQE를 한 번에 제출 (1회
io_uring_enter()로 N개 처리) - Fixed Files/Buffers:
io_uring_register_files()로 fd 조회 오버헤드 제거 - SQPOLL (고부하 전용): 지속적인 워크로드에서만 활성화, 저부하 시 기본 모드 사용
- IOPOLL (NVMe 전용): O_DIRECT + 고속 스토리지에서만 효과, 일반 디스크는 역효과
- Provided Buffers: 네트워크 I/O에서 버퍼 복사 제거
- 링크 체인: 순차 작업(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 노드에 배치해야 합니다.
#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, ¶ms);
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)만으로 생산자-소비자 간 데이터 가시성을 보장합니다. 배리어를 잘못 사용하면 데이터 손실이나 스톨이 발생할 수 있으므로 정확한 이해가 필요합니다.
아키텍처별 배리어 매핑
| io_uring 배리어 함수 | 의미 | x86-64 | ARM64 | RISC-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 / LOCK | DMB ISH | fence 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 사용 권장: 위의 배리어 처리는 liburing의 io_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_* 차단
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을 완전 비활성화하는 결정을 내리기도 했습니다.
io_uring의 파일 등록 메커니즘에서 IORING_REGISTER_FILES와 IORING_REGISTER_FILES_UPDATE의 처리 과정에서 파일 디스크립터 타입 검증이 누락되어, 일반 파일 디스크립터를 특수 파일(예: 커널 내부 파일)로 교체할 수 있었습니다. 이를 통해 권한 검사를 우회하고 임의 코드를 실행할 수 있습니다.
io_uring의 IORING_OP_LINK_TIMEOUT 처리에서, 링크된 요청이 완료된 후에도 timeout 요청의 io_kiocb가 해제되지 않은 채 타이머 콜백에서 참조되어 Use-After-Free가 발생합니다. 타이머 만료와 요청 완료 사이의 경쟁 조건이 근본 원인입니다.
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의 한계
- O_DIRECT 강제: Buffered I/O에서 비효율적
- 파일 I/O만 지원: 네트워크 소켓, 파이프 등 미지원
- 복잡한 API: 시스템 콜 직접 사용 어려움
- 메타데이터 연산 미지원: open(), stat(), mkdir() 등 블로킹
성능 벤치마크
| 방식 | IOPS (4KB, QD=32) | Latency (avg) | CPU |
|---|---|---|---|
| 동기 I/O (1 thread) | 10K | 0.1 ms | 5% |
| 동기 I/O (32 threads) | 80K | 0.4 ms | 35% |
| Linux AIO (QD=32) | 200K | 0.16 ms | 15% |
| io_uring (QD=32) | 350K | 0.09 ms | 10% |
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 가속 |
언어별 생태계
| 언어 | 라이브러리/프레임워크 | 상태 | 특징 |
|---|---|---|---|
| C | liburing | 공식 안정 | Jens Axboe 직접 관리, 사실상 표준 |
| Rust | io-uring, tokio-uring, monoio | 안정/활발 | 소유권 모델과 잘 맞음, completion-based |
| Go | iceber/iouring-go, godzie/gouring | 실험적 | goroutine 모델과 통합 어려움 |
| Java | netty-incubator-transport-io_uring | 인큐베이터 | JNI 기반, Netty 채널 추상화 |
| Python | liburing (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 | 이름 | 설명 | 도입 |
|---|---|---|---|
| 0 | IORING_REGISTER_BUFFERS | 고정 버퍼 등록 (페이지 핀, GUP 비용 제거) | 5.1 |
| 1 | IORING_UNREGISTER_BUFFERS | 고정 버퍼 해제 | 5.1 |
| 2 | IORING_REGISTER_FILES | 고정 파일 디스크립터 등록 (fget/fput 비용 제거) | 5.1 |
| 3 | IORING_UNREGISTER_FILES | 고정 파일 디스크립터 해제 | 5.1 |
| 4 | IORING_REGISTER_EVENTFD | 완료 알림용 eventfd 등록 | 5.2 |
| 5 | IORING_UNREGISTER_EVENTFD | eventfd 해제 | 5.2 |
| 6 | IORING_REGISTER_FILES_UPDATE | 등록된 파일 테이블 부분 업데이트 | 5.5 |
| 7 | IORING_REGISTER_EVENTFD_ASYNC | 비동기 완료 시에만 eventfd 시그널 | 5.6 |
| 8 | IORING_REGISTER_PROBE | 지원 opcode 조회 (기능 탐지) | 5.6 |
| 9 | IORING_REGISTER_PERSONALITY | 크리덴셜 등록 (다른 사용자 권한으로 I/O) | 5.6 |
| 10 | IORING_UNREGISTER_PERSONALITY | 크리덴셜 해제 | 5.6 |
| 11 | IORING_REGISTER_RESTRICTIONS | 허용 opcode/플래그 제한 (샌드박싱) | 5.13 |
| 12 | IORING_REGISTER_ENABLE_RINGS | R_DISABLED 상태의 ring 활성화 | 5.10 |
| 13 | IORING_REGISTER_FILES_UPDATE2 | 파일 업데이트 확장 (태그 지원) | 5.13 |
| 14 | IORING_REGISTER_BUFFERS2 | 버퍼 등록 확장 (태그 지원) | 5.13 |
| 15 | IORING_REGISTER_BUFFERS_UPDATE | 등록된 버퍼 부분 업데이트 | 5.13 |
| 16 | IORING_REGISTER_IOWQ_AFF | io-wq 워커의 CPU affinity 설정 | 5.14 |
| 17 | IORING_UNREGISTER_IOWQ_AFF | io-wq CPU affinity 해제 | 5.14 |
| 18 | IORING_REGISTER_IOWQ_MAX_WORKERS | io-wq 최대 워커 수 설정 [bounded, unbounded] | 5.15 |
| 19 | IORING_REGISTER_RING_FDS | ring fd를 테이블에 등록 (close-on-exec 없이) | 5.18 |
| 20 | IORING_UNREGISTER_RING_FDS | ring fd 테이블 해제 | 5.18 |
| 22 | IORING_REGISTER_PBUF_RING | Provided buffer ring 등록 | 5.19 |
| 23 | IORING_UNREGISTER_PBUF_RING | Provided buffer ring 해제 | 5.19 |
| 24 | IORING_REGISTER_SYNC_CANCEL | 동기적 요청 취소 | 6.0 |
| 25 | IORING_REGISTER_FILE_ALLOC_RANGE | 파일 테이블 할당 범위 지정 | 6.0 |
| 27 | IORING_REGISTER_NAPI | busy-poll NAPI 등록 (네트워크 지연 최적화) | 6.9 |
| 28 | IORING_UNREGISTER_NAPI | NAPI 등록 해제 | 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로 단축됩니다.
#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_to | busy-poll 타임아웃 (μs). 이 시간 동안 NIC를 폴링 | 50~200μs (워크로드에 따라 조정) |
prefer_busy_poll | 1이면 인터럽트보다 busy-poll 우선 | 1 (지연 중시 환경) |
| 시스템 sysctl | net.core.busy_poll, net.core.busy_read | io_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 번호 할당 오버헤드를 완전히 제거합니다.
#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 변형 | 설명 |
|---|---|---|
accept | io_uring_prep_accept_direct() | 새 소켓을 fixed file 슬롯에 직접 설치 |
multishot accept | io_uring_prep_multishot_accept_direct() | multishot + 자동 슬롯 할당 |
openat | io_uring_prep_openat_direct() | 파일을 fixed file 슬롯에 직접 열기 |
openat2 | io_uring_prep_openat2_direct() | 고급 플래그 + 직접 설치 |
socket | io_uring_prep_socket_direct() | 소켓 생성 + 직접 설치 |
close | io_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_SQE128과 IORING_SETUP_CQE32 플래그를 사용하면 각각 128바이트, 32바이트로 확장하여 추가 데이터를 전달할 수 있습니다. NVMe passthrough(io_uring_cmd)에서 주로 사용됩니다.
/* 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, ¶ms);
/* 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 워커에 위임하여 블로킹 없이 처리합니다.
| 경로 | 페이지 캐시 | 워커 사용 | 지연 | 발생 비율 |
|---|---|---|---|---|
| Fast Path | 히트 | 없음 (인라인 완료) | 1~5μs | 워킹셋 내: 90%+ |
| Slow Path | 미스 | io-wq bounded worker | 50~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 FD | io_uring_enter()의 fdget/fdput | ~200ns/콜 | 기본 모드 (빈번한 enter 호출) |
| Fixed Files | I/O 대상 fd의 fdget/fdput | ~150ns/I/O | 모든 환경 (필수 최적화) |
| Fixed Buffers | GUP (get_user_pages) 비용 | ~500ns/I/O | 대용량/고빈도 I/O |
| SQPOLL | io_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 전송도 비동기 + 제로카피로 처리할 수 있습니다.
#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)에는 다양한 잠금이 사용되며, 커널 버전에 따라 세분화되어 왔습니다.
| 잠금 | 보호 대상 | SINGLE_ISSUER 시 | DEFER_TASKRUN 시 |
|---|---|---|---|
uring_lock | SQE 파싱, 리소스 등록/해제 | 제출 경로에서 생략 가능 | 동일 |
completion_lock | CQE 게시, 오버플로 리스트 | 여전히 필요 (다중 완료 소스) | 불필요 (일괄 처리) |
timeout_lock | 타이머 리스트 관리 | 여전히 필요 | 여전히 필요 |
cancel_lock | 취소 해시 테이블 | 여전히 필요 | 여전히 필요 |
| SQ/CQ 링 포인터 | head/tail 포인터 | 항상 lock-free (메모리 배리어만 사용) | |
시스템 파라미터 튜닝 가이드
io_uring 성능과 안정성에 영향을 미치는 커널 파라미터, 리소스 제한, 시스템 설정을 종합적으로 정리합니다.
리소스 제한 (rlimits)
| 파라미터 | 영향 | 기본값 | 권장값 | 설정 방법 |
|---|---|---|---|---|
RLIMIT_MEMLOCK | mmap 고정 메모리 상한 (SQ/CQ 링, 고정 버퍼) | 64KB (대부분) | 256MB+ (고성능 서버) | ulimit -l unlimited 또는 /etc/security/limits.conf |
RLIMIT_NPROC | io-wq bounded 워커 최대 수 | 프로세스 제한 | 워크로드에 따라 | ulimit -u 65535 |
RLIMIT_NOFILE | fd 테이블 크기 (Direct Descriptor 사용 시 덜 중요) | 1024 | 65536+ | ulimit -n 65536 |
커널 sysctl 파라미터
| sysctl 경로 | 설명 | 기본값 | 권장 |
|---|---|---|---|
kernel.io_uring_disabled | io_uring 사용 제어 | 0 (허용) | 프로덕션: 0, 컨테이너: 1 또는 2 |
kernel.io_uring_group | io_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) | 0 | io_uring NAPI 사용 시 0 (io_uring이 관리) |
net.core.busy_read | 소켓 busy-read 타임아웃 (μs) | 0 | io_uring NAPI 사용 시 0 |
net.core.somaxconn | listen 백로그 최대값 | 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);
프로덕션 배포 체크리스트
프로덕션 환경 설정 순서:
- RLIMIT_MEMLOCK 증가: 링 크기 + 고정 버퍼 크기를 수용하도록 설정
- 커널 버전 확인: 최소 5.11+ (SQPOLL 개선), 권장 6.1+ (DEFER_TASKRUN)
- 보안 설정: 컨테이너에서는
io_uring_disabled=1, 필요 시 RESTRICTIONS 적용 - CPU 바인딩: SQPOLL CPU, io-wq affinity, NUMA 노드 일치 확인
- 모니터링:
/proc/PID/fdinfo, tracepoint, bpftrace로 CQ 오버플로/워커 포화 감시 - 성능 측정: 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.1 | 5.4+ | 초기 버전은 버그 다수 |
| SQ/CQ 통합 mmap | 5.4 | 5.4+ | IORING_FEAT_SINGLE_MMAP | |
| liburing 호환 | 5.1 | 5.10+ | liburing 2.x는 5.10+ 권장 | |
| 모드 | SQPOLL | 5.1 | 5.11+ | 5.11에서 안정성 대폭 개선 |
| IOPOLL | 5.1 | 5.1+ | O_DIRECT 필수 | |
| SINGLE_ISSUER | 6.0 | 6.0+ | 내부 잠금 제거 | |
| DEFER_TASKRUN | 6.1 | 6.1+ | SINGLE_ISSUER 필수 | |
| 리소스 등록 | Fixed files | 5.1 | 5.5+ | 5.5에서 업데이트 지원 |
| Fixed buffers | 5.1 | 5.1+ | GUP 비용 제거 | |
| Provided buffer ring | 5.19 | 5.19+ | mmap 방식, 이전 PROVIDE_BUFFERS 대체 | |
| Registered ring fd | 5.18 | 5.18+ | enter() 시 fdget 비용 제거 | |
| 네트워크 | accept/connect/send/recv | 5.5 | 5.6+ | 기본 네트워크 연산 |
| Multishot accept | 5.19 | 5.19+ | ACCEPT_MULTISHOT | |
| Multishot recv | 6.0 | 6.0+ | RECV_MULTISHOT + provided buffers | |
| SEND_ZC (제로카피 전송) | 6.0 | 6.0+ | 64KB+ 대용량에 효과적 | |
| NAPI busy-poll | 6.9 | 6.9+ | 초저지연 네트워크 | |
| 고급 | MSG_RING | 5.18 | 5.18+ | 링 간 통신 |
| MSG_RING_FD | 6.3 | 6.3+ | fd 전달 | |
| io_uring_cmd (NVMe passthrough) | 6.0 | 6.0+ | SQE128 필요 | |
| 파일시스템 | open/close/statx | 5.6 | 5.6+ | 비동기 메타데이터 연산 |
| Direct descriptors | 5.15 | 6.0+ | FILE_INDEX_ALLOC은 6.0+ | |
| FUSE io_uring | 6.14 | 6.14+ | FUSE 성능 대폭 향상 | |
| 보안 | io_uring_disabled sysctl | 5.12.4 | 5.12.4+ | 0/1/2 레벨 |
| RESTRICTIONS | 5.13 | 5.13+ | opcode/플래그 제한 | |
| NO_SQARRAY | 6.7 | 6.7+ | 메모리 절약 | |
| LSM 훅 | 6.15 | 6.15+ | SELinux/AppArmor 통합 |
최소 권장 버전: 새 프로젝트에서 io_uring을 사용한다면 최소 커널 6.1+을 권장합니다. SINGLE_ISSUER + DEFER_TASKRUN 조합이 가능하고, 다수의 보안 패치와 성능 개선이 포함되어 있습니다. 5.x 커널에서는 CVE가 다수 존재하므로, 반드시 최신 안정 패치를 적용하세요. IORING_REGISTER_PROBE로 런타임에 기능을 탐지하면 다양한 커널 버전에서 안전하게 동작할 수 있습니다.
관련 문서
io_uring과 관련된 다른 주제를 더 깊이 이해하고 싶다면 다음 문서를 참고하세요.