Linux io_uring 비동기 I/O 심화

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 커널 인프라에 대한 이해가 더욱 깊어질 것입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux