io_uring이란? — 커널 비동기 I/O의 게임 체인저
Linux 5.1(2019)에 도입된 io_uring은 기존 epoll + read/write 조합이나 aio의 한계를 근본적으로 해결하는 커널 레벨 비동기 I/O 인터페이스입니다. 시스템 콜 오버헤드를 극적으로 줄이고, 단일 인터페이스로 파일·네트워크·타이머 등 모든 I/O를 처리합니다.
고성능 서버, 데이터베이스 엔진, 스토리지 시스템에서 io_uring 채택이 빠르게 늘고 있습니다. 이 글에서는 링 버퍼 구조부터 SQE/CQE 동작 원리, 고급 기능, 실전 벤치마크까지 깊이 있게 다룹니다.
기존 I/O 모델의 한계
io_uring을 이해하려면 먼저 기존 방식의 문제를 알아야 합니다.
- 동기 I/O (read/write): 호출마다 커널 진입(syscall) → 컨텍스트 스위칭 비용. 대량 I/O에서 CPU 시간의 상당 부분이 syscall 오버헤드에 소모됩니다.
- epoll + non-blocking: 이벤트 준비 알림은 효율적이지만, 실제 데이터 전송은 여전히
read()/write()syscall이 필요합니다. “준비됨”과 “전송”이 분리되어 있어 syscall 횟수가 2배입니다. - POSIX AIO (aio_read/aio_write): 유저스페이스 스레드 풀 기반으로, 진정한 비동기가 아닙니다. 스레드 관리 오버헤드가 크고 성능이 불안정합니다.
- Linux AIO (io_submit): 커널 비동기이지만
O_DIRECT파일에만 동작하고, 버퍼 캐시를 사용하면 동기로 폴백됩니다. 네트워크 I/O는 지원하지 않습니다.
io_uring은 이 모든 문제를 공유 메모리 링 버퍼라는 단일 메커니즘으로 해결합니다.
핵심 아키텍처: Submission Queue와 Completion Queue
io_uring의 핵심은 커널과 유저스페이스가 공유하는 두 개의 링 버퍼입니다.
- SQ (Submission Queue): 애플리케이션이 I/O 요청(SQE, Submission Queue Entry)을 넣는 큐
- CQ (Completion Queue): 커널이 완료된 결과(CQE, Completion Queue Entry)를 넣는 큐
┌─────────────────────────────────────────────────┐
│ User Space (Application) │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ SQE │──push──▶│ SQ │ │
│ │ (요청) │ │ (제출큐) │ │
│ └──────────┘ └────┬─────┘ │
│ │ │
│ ┌──────────┐ ┌────▼─────┐ │
│ │ CQE │◀──poll──│ CQ │ │
│ │ (결과) │ │ (완료큐) │ │
│ └──────────┘ └──────────┘ │
│ │
├──────── mmap 공유 메모리 ────────────────────────┤
│ │
│ Kernel Space │
│ - SQ에서 요청 가져와 실행 │
│ - 완료 시 CQ에 결과 기록 │
│ - SQPOLL 모드: 커널 스레드가 자동 폴링 │
└─────────────────────────────────────────────────┘
이 구조의 핵심 장점은 syscall 없이도 I/O 요청/완료를 처리할 수 있다는 점입니다. mmap으로 공유된 메모리에 직접 읽고 쓰기 때문에 커널 진입 비용이 제거됩니다.
세 가지 시스템 콜만으로 모든 것을 처리
io_uring은 단 3개의 syscall로 동작합니다.
// 1. 링 초기화
int io_uring_setup(unsigned entries, struct io_uring_params *p);
// 2. 요청 제출 (배치 가능)
int io_uring_enter(int fd, unsigned to_submit, unsigned min_complete,
unsigned flags, sigset_t *sig);
// 3. 링 등록 (버퍼/파일 사전 등록으로 추가 최적화)
int io_uring_register(int fd, unsigned opcode, void *arg,
unsigned nr_args);
io_uring_setup으로 초기화한 후, io_uring_enter 한 번으로 수백 개의 I/O 요청을 동시에 제출할 수 있습니다. SQPOLL 모드를 사용하면 io_uring_enter조차 호출하지 않아도 됩니다.
SQE 구조체 상세 분석
각 I/O 요청은 64바이트의 SQE(Submission Queue Entry)로 표현됩니다.
struct io_uring_sqe {
__u8 opcode; // IORING_OP_READ, IORING_OP_WRITE, IORING_OP_ACCEPT...
__u8 flags; // IOSQE_FIXED_FILE, IOSQE_IO_LINK, IOSQE_ASYNC...
__u16 ioprio; // I/O 우선순위
__s32 fd; // 파일 디스크립터
__u64 off; // 오프셋 (파일 I/O)
__u64 addr; // 버퍼 주소
__u32 len; // 길이
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u32 poll_events;
__u32 sync_range_flags;
__u32 msg_flags;
__u32 accept_flags;
};
__u64 user_data; // 완료 시 돌려받을 사용자 데이터 (콜백 식별자)
// ... 패딩 및 추가 필드
};
user_data 필드가 중요합니다. 이 값은 CQE에 그대로 복사되어, 어떤 요청의 완료인지 식별하는 키 역할을 합니다.
liburing으로 실전 코드 작성
커널 인터페이스를 직접 다루는 것은 복잡합니다. liburing 라이브러리가 이를 추상화합니다.
파일 읽기 예제
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#define QUEUE_DEPTH 64
#define BLOCK_SIZE 4096
int main() {
struct io_uring ring;
// 1. 링 초기화 (큐 깊이 64)
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
int fd = open("data.bin", O_RDONLY | O_DIRECT);
// 2. 정렬된 버퍼 할당 (O_DIRECT 요구사항)
void *buf;
posix_memalign(&buf, BLOCK_SIZE, BLOCK_SIZE);
// 3. SQE 획득 및 요청 준비
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, BLOCK_SIZE, 0);
io_uring_sqe_set_data(sqe, buf); // user_data 설정
// 4. 제출
io_uring_submit(&ring);
// 5. 완료 대기
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
if (cqe->res < 0) {
fprintf(stderr, "Read failed: %sn", strerror(-cqe->res));
} else {
printf("Read %d bytesn", cqe->res);
}
// 6. CQE 소비 완료 표시
io_uring_cqe_seen(&ring, cqe);
// 정리
io_uring_queue_exit(&ring);
close(fd);
free(buf);
return 0;
}
TCP Echo 서버 (네트워크 I/O)
#include <liburing.h>
#include <netinet/in.h>
#include <string.h>
enum event_type { ACCEPT, READ, WRITE };
struct request {
enum event_type type;
int client_fd;
char buf[1024];
};
void add_accept(struct io_uring *ring, int server_fd,
struct sockaddr_in *addr, socklen_t *addrlen) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_accept(sqe, server_fd,
(struct sockaddr *)addr, addrlen, 0);
struct request *req = malloc(sizeof(*req));
req->type = ACCEPT;
io_uring_sqe_set_data(sqe, req);
}
void add_read(struct io_uring *ring, int fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
struct request *req = malloc(sizeof(*req));
req->type = READ;
req->client_fd = fd;
io_uring_prep_recv(sqe, fd, req->buf, sizeof(req->buf), 0);
io_uring_sqe_set_data(sqe, req);
}
void add_write(struct io_uring *ring, int fd, char *buf, int len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
struct request *req = malloc(sizeof(*req));
req->type = WRITE;
req->client_fd = fd;
io_uring_prep_send(sqe, fd, buf, len, 0);
io_uring_sqe_set_data(sqe, req);
}
// 이벤트 루프
void event_loop(struct io_uring *ring, int server_fd) {
struct sockaddr_in addr;
socklen_t addrlen = sizeof(addr);
add_accept(ring, server_fd, &addr, &addrlen);
io_uring_submit(ring);
while (1) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(ring, &cqe);
struct request *req = io_uring_cqe_get_data(cqe);
switch (req->type) {
case ACCEPT:
add_read(ring, cqe->res);
add_accept(ring, server_fd, &addr, &addrlen);
break;
case READ:
if (cqe->res > 0)
add_write(ring, req->client_fd, req->buf, cqe->res);
break;
case WRITE:
add_read(ring, req->client_fd);
break;
}
io_uring_cqe_seen(ring, cqe);
io_uring_submit(ring);
free(req);
}
}
epoll 기반 에코 서버와 비교하면, accept → read → write 전체 체인이 하나의 인터페이스로 통합되어 코드가 훨씬 일관적입니다.
고급 기능 5가지
1. SQPOLL — Zero Syscall I/O
커널 스레드가 SQ를 지속적으로 폴링하여, 애플리케이션이 syscall 없이 I/O를 제출할 수 있습니다.
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 2초간 유휴 시 스레드 슬립
io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
SQPOLL 모드에서는 SQE를 링에 넣기만 하면 커널 폴러 스레드가 자동으로 가져갑니다. 초당 수백만 IOPS를 처리하는 NVMe SSD 환경에서 syscall 오버헤드를 완전히 제거합니다.
2. Fixed Buffers & Fixed Files
매번 커널이 유저스페이스 포인터를 검증하고 매핑하는 비용을 제거합니다.
// 버퍼 사전 등록
struct iovec iovecs[NUM_BUFFERS];
for (int i = 0; i < NUM_BUFFERS; i++) {
iovecs[i].iov_base = aligned_alloc(4096, BUF_SIZE);
iovecs[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iovecs, NUM_BUFFERS);
// 파일 디스크립터 사전 등록
int fds[NUM_FILES] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, fds, NUM_FILES);
// 등록된 버퍼/파일로 읽기
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, 0, iovecs[0].iov_base,
BUF_SIZE, 0, 0); // 인덱스 기반
sqe->flags |= IOSQE_FIXED_FILE;
벤치마크에서 Fixed Buffer는 일반 모드 대비 15~30% 처리량 향상을 보입니다.
3. Linked SQE — I/O 체인
여러 SQE를 순서대로 연결하여, 앞선 작업이 성공해야 다음 작업이 실행되게 합니다.
// read → process → write 체인
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, src_fd, buf, size, 0);
sqe1->flags |= IOSQE_IO_LINK; // 다음 SQE와 연결
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe2, dst_fd, buf, size, 0);
// 체인의 마지막은 LINK 플래그 없음
io_uring_submit(&ring);
파일 복사, 프록시 파이프라인 등 순차적 I/O 패턴에서 매우 유용합니다.
4. Multishot Accept & Recv
Linux 5.19+에서 도입된 기능으로, 한 번의 SQE로 여러 완료를 받을 수 있습니다.
// Multishot accept: SQE 하나로 계속 accept
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, server_fd, NULL, NULL, 0);
// 처리 시 CQE의 IORING_CQE_F_MORE 플래그 확인
if (cqe->flags & IORING_CQE_F_MORE) {
// 추가 완료가 더 올 예정 — SQE 재제출 불필요
}
고빈도 accept/recv 환경에서 SQE 재제출 오버헤드를 제거하여 성능을 크게 향상시킵니다.
5. io_uring_buf_ring — 커널 선택 버퍼
Linux 5.19+의 Provided Buffer Ring은 커널이 완료 시점에 버퍼를 자동 선택합니다.
// 버퍼 링 생성
struct io_uring_buf_ring *br;
br = io_uring_setup_buf_ring(&ring, NUM_BUFS, BUF_GROUP_ID, 0, &ret);
// 버퍼 추가
for (int i = 0; i < NUM_BUFS; i++) {
io_uring_buf_ring_add(br, bufs[i], BUF_SIZE, i,
io_uring_buf_ring_mask(NUM_BUFS), i);
}
io_uring_buf_ring_advance(br, NUM_BUFS);
// recv 시 버퍼 그룹 지정
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, NULL, 0, 0);
sqe->buf_group = BUF_GROUP_ID;
sqe->flags |= IOSQE_BUFFER_SELECT;
수천 개의 클라이언트 연결에서 각 연결마다 버퍼를 미리 할당하지 않아도 되어 메모리 효율이 크게 개선됩니다.
epoll vs io_uring 성능 벤치마크
동일 환경(NVMe SSD, 4KB 랜덤 읽기)에서의 비교입니다.
| 항목 | epoll + read | io_uring | io_uring SQPOLL |
|---|---|---|---|
| IOPS (4KB random read) | ~380K | ~520K | ~680K |
| syscall/요청 | 2회 | ~0.01회 (배치) | 0회 |
| P99 레이턴시 | ~45μs | ~28μs | ~18μs |
| CPU 사용률 | 85% | 62% | 70% (폴러 포함) |
| 네트워크 (HTTP RPS) | ~120K | ~165K | ~190K |
io_uring은 특히 높은 동시성 + 작은 I/O 환경에서 압도적인 성능 차이를 보입니다.
실전 채택 사례
- RocksDB / ScyllaDB: 스토리지 엔진의 비동기 I/O를 io_uring으로 전환하여 읽기 레이턴시 40% 감소
- Tokio (Rust):
tokio-uring크레이트로 io_uring 기반 비동기 런타임 제공 - NGINX Unit: io_uring 기반 파일 서빙으로 정적 파일 처리 성능 향상
- libev / libuv 대안:
liburing기반의 새로운 이벤트 루프 라이브러리들이 등장 - PostgreSQL: WAL 쓰기와 데이터 파일 I/O에 io_uring 지원 논의 진행 중
- Cloudflare: 프록시 서버에서 io_uring 채택으로 처리량 대폭 개선
보안 고려사항
io_uring은 강력한 만큼 보안 이슈도 있습니다.
- 커널 공격 표면 확대: io_uring 관련 CVE가 다수 보고되었습니다. 컨테이너 환경에서는 seccomp으로
io_uring_setup을 차단하는 것이 일반적입니다. - Google의 제한: ChromeOS, Android, Google 프로덕션 서버에서 io_uring을 기본 비활성화했습니다.
- Docker/K8s: 기본 seccomp 프로파일에서 io_uring syscall이 차단될 수 있습니다. 프로덕션 사용 시 명시적으로 허용해야 합니다.
# 커널 파라미터로 io_uring 제한 (Linux 6.1+)
# 0: 제한 없음, 1: unprivileged 사용 불가, 2: 완전 비활성화
sysctl -w kernel.io_uring_disabled=1
# seccomp 프로파일에서 허용하는 예시 (K8s)
apiVersion: v1
kind: Pod
metadata:
annotations:
seccomp.security.alpha.kubernetes.io/pod: localhost/io-uring-allow.json
io_uring 적용 판단 기준
모든 상황에서 io_uring이 최선은 아닙니다. 적용 판단 기준을 정리합니다.
- 적합한 경우: NVMe SSD 기반 고성능 스토리지, 수만 동시 연결 네트워크 서버, 데이터베이스 엔진, 프록시/로드밸런서
- 불필요한 경우: 낮은 동시성 웹 서비스, I/O보다 CPU 바운드인 작업, epoll로 충분한 성능이 나오는 환경
- 주의가 필요한 경우: 컨테이너/K8s 환경(seccomp 설정 필요), 보안이 최우선인 멀티테넌트 환경, 커널 5.10 미만(기능 불완전)
커널 버전별 주요 기능 타임라인
| 커널 버전 | 추가된 기능 |
|---|---|
| 5.1 | io_uring 최초 도입 (read/write/fsync) |
| 5.3 | IORING_OP_TIMEOUT, 타임아웃 지원 |
| 5.4 | 네트워크 I/O (accept, connect, send, recv) |
| 5.5 | SQPOLL, Fixed Files |
| 5.6 | Linked SQE, io_uring_probe |
| 5.7 | splice, tee, provide_buffers |
| 5.11 | IORING_OP_SHUTDOWN, Fixed Buffer 업데이트 |
| 5.15 | IORING_OP_MSG_RING (링 간 메시지) |
| 5.19 | Multishot accept/recv, buf_ring |
| 6.0 | IORING_OP_SEND_ZC (zero-copy send) |
| 6.1+ | kernel.io_uring_disabled sysctl, 보안 강화 |
실전 팁: 프로덕션 체크리스트
- 커널 5.15+ 권장 (안정성·기능 모두 충분)
io_uring_probe로 지원 opcode를 런타임에 확인- SQPOLL은 전용 CPU 코어가 있을 때만 사용 (유휴 시에도 CPU 소비)
- Fixed Buffer/File은 초기화 시 한 번 등록, 런타임에 변경 최소화
- CQ 크기를 SQ의 2배로 설정 (
IORING_SETUP_CQSIZE) — CQ 오버플로우 방지 IORING_SETUP_DEFER_TASKRUN(6.1+)으로 완료 처리를 호출 스레드에서 실행하여 캐시 효율 향상- 에러 처리: CQE의
res가 음수이면-errno. 반드시 체크
마무리
io_uring은 Linux I/O의 패러다임을 바꾸고 있습니다. 공유 메모리 링 버퍼라는 우아한 설계로 syscall 오버헤드를 제거하고, 파일·네트워크·타이머를 하나의 인터페이스로 통합했습니다.
다만 모든 기술이 그렇듯, 맥락에 맞게 사용해야 합니다. epoll로 충분한 환경에서 io_uring을 도입하면 복잡성만 늘어납니다. NVMe SSD 기반 고성능 스토리지, 수만 동시 연결 서버, 커스텀 데이터베이스 엔진 — 이런 환경에서 io_uring은 진정한 게임 체인저입니다.
관련 글로 Linux eBPF 관측성 심화와 Linux Container Runtime 심화도 함께 참고하시면 Linux 커널 인프라에 대한 이해가 더욱 깊어질 것입니다.