io_uring 네트워킹 (io_uring Networking)

io_uring을 활용한 고성능 네트워크 I/O를 심층 분석합니다. 멀티샷(Multishot) accept/recv, 제공 버퍼(Buffer) 링(Provided Buffer Ring), 제로카피(Zero-copy) 송수신, kTLS 통합, NAPI 바쁜 대기(Busy-poll), SQPOLL 네트워크 최적화, 직접 디스크립터(Direct Descriptor), 번들(Bundle) 연산 등 최신 커널 기능을 포괄합니다. epoll 기반 모델과의 비교, TCP echo 서버/HTTP 파이프라인(Pipeline)/프록시/UDP 고성능 처리 실전 예제, 커널 내부 네트워크 핸들러(Handler) 소스 분석, 성능 벤치마크와 튜닝 가이드까지 네트워크 서버 개발에 필요한 모든 핵심 내용을 다룹니다.

전제 조건: io_uring (Async I/O) 문서에서 SQE/CQE 링 버퍼(Ring Buffer) 구조, SQPOLL, liburing 기본 사용법을 먼저 읽으세요. 또한 TCP 프로토콜네트워크 스택(Network Stack) 개요 문서를 함께 참고하면 커널 네트워크 경로를 이해하는 데 도움이 됩니다.
일상 비유: 이 개념은 택배 접수 창구에 송장을 미리 묶음 제출하는 것과 비슷합니다. 요청서를 한 장씩 왕복하지 않고 한 번에 여러 건을 넘기듯이, io_uring 네트워킹은 제출/완료 큐를 통해 시스템 콜 왕복과 대기를 줄입니다.

핵심 요약

  • io_uring 네트워킹은 기존 epoll + read()/write() 모델을 대체하여 시스템 콜(System Call) 오버헤드(Overhead) 없이 네트워크 I/O를 비동기로 처리하는 프레임워크입니다.
  • 멀티샷(Multishot) accept와 recv는 하나의 SQE(Submission Queue Entry)로 여러 연결/수신 완료를 처리하여 SQE 제출 비용을 대폭 줄입니다.
  • 제공 버퍼 링(Provided Buffer Ring)은 커널이 수신 데이터를 직접 사용자 버퍼에 배치하여 별도의 버퍼 관리 시스템 콜을 제거합니다.
  • 제로카피(Zero-copy) SEND_ZC/RECV_ZC는 사용자 버퍼에서 NIC까지 데이터 복사 없이 전송하여 대용량 전송의 CPU 사용량을 크게 줄입니다.
  • NAPI 바쁜 대기(Busy-poll) 통합은 io_uring이 커널의 NAPI 폴링(Polling)과 직접 연동하여 수 마이크로초(Microsecond) 수준의 지연(Latency) 시간(Latency)을 달성합니다.

단계별 이해

  1. 기존 모델의 한계 인식 — epoll + recv()/send()는 이벤트(Event) 감지와 데이터 전송이 별도 시스템 콜이므로 고빈도 연결 환경에서 커널-사용자 공간(User Space) 전환 비용이 누적됩니다.

    1만 연결 × 초당 10회 I/O = 초당 20만 번의 시스템 콜이 발생합니다.

  2. io_uring 네트워크 파이프라인 이해 — io_uring은 accept, recv, send를 모두 SQE로 통합합니다. 하나의 io_uring_enter() 호출로 여러 네트워크 연산을 일괄 제출하고, CQE(Completion Queue Entry)로 완료를 수확합니다.

    SQPOLL 모드에서는 시스템 콜 자체가 불필요합니다.

  3. 멀티샷으로 효율 극대화 — 멀티샷 accept는 한 번의 SQE로 계속 새 연결을 수락합니다. 멀티샷 recv는 제공 버퍼 링과 결합하여 데이터가 도착할 때마다 자동으로 버퍼를 할당하고 CQE를 생성합니다.

    SQE 재제출 비용이 사라지므로 CPS(Connections Per Second)가 크게 향상됩니다.

  4. 실전 적용 — TCP echo 서버부터 시작하여 HTTP 파이프라인, 프록시/로드밸런서(Load Balancer), UDP 고성능 처리까지 단계적으로 확장합니다.

    각 패턴(Pattern)에서 멀티샷, 제공 버퍼, 제로카피를 조합하는 최적 전략을 다룹니다.

개요

io_uring 네트워킹은 Linux 5.4에서 IORING_OP_ACCEPT가 추가되면서 시작되었습니다. 이후 매 커널 릴리스마다 네트워크 전용 opcode와 최적화가 추가되어 현재는 소켓(Socket) 생성부터 종료까지 전체 네트워크 수명주기를 io_uring 파이프라인 내에서 처리할 수 있습니다.

관련 표준: POSIX.1-2024 (소켓 API), RFC 793 (TCP), RFC 9000 (QUIC) — io_uring은 기존 BSD 소켓 API의 비동기 래퍼로, 프로토콜 자체는 변경하지 않고 사용자-커널 인터페이스만 최적화합니다. 종합 목록은 참고자료 — 표준 & 규격 섹션을 참고하세요.

io_uring 네트워크 기능 발전 역사

io_uring의 네트워크 지원은 다음과 같이 단계적으로 확장되었습니다.

커널 버전네트워크 관련 추가 기능영향
5.4 (2019-11)ACCEPT, CONNECT, ASYNC_CANCEL기본 네트워크 연결 관리 가능
5.5SEND, RECV 기본 opcode 추가완전한 TCP 클라이언트/서버 구축 가능
5.6SENDMSG, RECVMSG 추가UDP, scatter/gather I/O 지원
5.7멀티샷 poll이벤트 기반 프로그래밍 개선
5.11SHUTDOWN opcode우아한 연결 종료
5.18SOCKET opcode소켓 생성부터 io_uring 통합
5.19멀티샷 accept, SEND_ZC고성능 서버의 핵심 기능
6.0멀티샷 recv, provided buffer ring 개선수신 경로 최적화
6.1SINGLE_ISSUER, DEFER_TASKRUN네트워크 서버 최적 플래그
6.2RECV_ZC수신 제로카피
6.7NO_SQARRAY메모리 절약
6.9IORING_REGISTER_NAPI초저지연 네트워킹
6.11BIND, LISTEN opcode서버 설정 완전 통합
6.14Bundle send/recv 안정화배치 전송 최적화

전체 아키텍처

io_uring 네트워킹 전체 아키텍처 User Space Application 서버 / 프록시 liburing SQE 헬퍼 API SQ Ring (공유) accept/recv/send SQE CQ Ring (공유) 완료 CQE 수확 Provided Buffer Ring recv 버퍼 풀 (mmap) mmap 공유 메모리 (시스템 콜 없이 접근) Kernel Space io_uring core SQE 파싱 / 디스패치 io_uring/net.c 네트워크 핸들러 SQPOLL 스레드 (선택적 커널 폴링) Socket Layer sock_sendmsg/recvmsg kTLS (선택적) TCP / UDP 프로토콜 스택 IP → Driver NIC DMA / NAPI HW TLS (선택) NAPI Busy-poll (6.9+): io_uring이 NIC를 직접 폴링
io_uring 네트워킹 전체 아키텍처: 사용자 공간 애플리케이션이 liburing을 통해 SQE를 제출하면, io_uring/net.c가 소켓 계층을 거쳐 TCP/UDP 스택과 NIC까지 연결됩니다. NAPI busy-poll은 NIC에서 io_uring으로의 직접 폴링 경로를 제공합니다.

핵심 자료구조

io_uring 네트워크 연산에서 사용되는 주요 커널 자료구조입니다.

/* io_uring/net.c — 네트워크 요청 자료구조 */

/* accept 요청 */
struct io_accept {
    struct file            *file;        /* listen 소켓 파일 */
    struct sockaddr __user *addr;        /* 클라이언트 주소 (출력) */
    int __user             *addr_len;
    int                     flags;       /* SOCK_NONBLOCK 등 */
    u32                     file_slot;   /* direct descriptor 슬롯 */
    unsigned long           nofile;      /* RLIMIT_NOFILE */
};

/* send/recv 요청 */
struct io_sr_msg {
    struct file            *file;        /* 소켓 파일 */
    union {
        struct compat_msghdr __user *umsg_compat;
        struct user_msghdr __user   *umsg;
        void __user                *buf;
    };
    unsigned                msg_flags;   /* MSG_DONTWAIT 등 */
    unsigned                len;         /* 버퍼 길이 */
    unsigned                done_io;     /* 처리된 바이트 */
    unsigned                nr_multishot_loops;
    u16                     addr_len;
    u16                     buf_group;   /* provided buf ring 그룹 */
};

/* zero-copy send 요청 */
struct io_sendzc {
    struct file            *file;
    void __user            *buf;
    unsigned                len;
    unsigned                msg_flags;
    unsigned                flags;       /* IORING_SENDZC_* */
    struct io_notif_data   *notif;      /* 알림 슬롯 */
};

epoll vs io_uring 네트워크 모델

전통적인 리눅스 고성능 네트워크 서버는 epoll + 비차단(Non-blocking) 소켓(Socket) 모델을 사용합니다. io_uring은 이 모델의 근본적인 한계를 해결합니다.

epoll 모델의 구조적 한계

epoll 기반 서버의 전형적인 이벤트 루프(Event Loop)는 다음과 같습니다.

/* 전통적인 epoll 이벤트 루프 */
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);  /* syscall #1 */
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == listen_fd) {
            int cfd = accept4(listen_fd, ...);               /* syscall #2 */
            epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);        /* syscall #3 */
        } else {
            ssize_t r = recv(events[i].data.fd, buf, ...);    /* syscall #4 */
            if (r > 0)
                send(events[i].data.fd, buf, r, 0);           /* syscall #5 */
        }
    }
}

이 루프에서는 하나의 연결 이벤트 처리에 최소 3~5번의 시스템 콜이 필요합니다. 각 시스템 콜은 사용자-커널 모드 전환, 레지스터(Register) 저장/복원, KPTI 페이지 테이블(Page Table) 전환 비용을 수반합니다.

io_uring 네트워크 파이프라인

io_uring 모델에서는 모든 네트워크 연산이 SQE로 통합되며, 하나의 시스템 콜(또는 SQPOLL에서는 0개)로 일괄 처리됩니다.

/* io_uring 네트워크 이벤트 루프 */
/* 1. 멀티샷 accept 한 번 등록 → 계속 새 연결 수락 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);

while (1) {
    io_uring_submit_and_wait(&ring, 1);               /* syscall 1개 (또는 0개) */
    struct io_uring_cqe *cqe;
    unsigned head;
    io_uring_for_each_cqe(&ring, head, cqe) {
        switch (get_op_type(cqe->user_data)) {
        case OP_ACCEPT:
            handle_accept(cqe->res);    /* → recv SQE 준비 */
            break;
        case OP_RECV:
            handle_recv(cqe);            /* → send SQE 준비 */
            break;
        case OP_SEND:
            handle_send(cqe);
            break;
        }
    }
    io_uring_cq_advance(&ring, count);
}
epoll 모델 epoll_wait() accept4() epoll_ctl(ADD) recv() send() syscall #1 syscall #2 syscall #3 syscall #4 syscall #5 루프 반복 io_uring 모델 SQE: multishot accept SQE: multishot recv SQE: send / send_zc submit_and_wait syscall 1개 커널: accept + recv + send 일괄 처리 단일 컨텍스트 전환 CQE 배치 수확 accept CQE + recv CQE + send CQE 루프 반복
epoll 모델(왼쪽)은 연결당 최소 5개 시스템 콜이 필요하지만, io_uring 모델(오른쪽)은 1개(SQPOLL이면 0개)로 동일한 작업을 처리합니다.

상세 비교 테이블

비교 항목epoll + recv/sendio_uring 네트워크
시스템 콜 오버헤드이벤트 감지 + I/O 별도 시스템 콜단일 io_uring_enter() 또는 SQPOLL에서 0개
배칭(Batching)불가 — 이벤트별 개별 처리수백 개 SQE 일괄 제출 가능
멀티샷(Multishot)미지원accept/recv에서 SQE 1개로 다중 완료
제로카피(Zero-copy)sendfile()/splice() 제한적SEND_ZC/RECV_ZC 네이티브 지원
버퍼 관리사용자 공간에서 직접 관리제공 버퍼 링으로 커널 자동 할당
파일 + 네트워크 통합별도 API (splice 등)동일 SQE 인터페이스로 통합
NAPI 통합SO_BUSY_POLL 소켓 옵션IORING_REGISTER_NAPI 링 수준 통합
타이머(Timer) 통합별도 timerfd + epoll_ctlTIMEOUT SQE로 통합
학습 난이도낮음중간~높음
커널 요구 버전2.6.28+5.1+ (네트워크 기능은 5.4+)

언제 무엇을 선택할까

epoll이 적합한 경우: 연결 수가 수천 이하이고, 이벤트 빈도가 낮으며, 레거시(Legacy) 호환성이 중요한 환경입니다. 대부분의 일반 웹 서버, 마이크로서비스(Microservice)에서 충분합니다.
io_uring이 적합한 경우: 연결 수가 수만 이상이거나 초당 수십만 이벤트를 처리해야 하는 환경, 마이크로초 수준 지연 시간이 요구되는 금융 거래 시스템, 대용량 데이터 전송으로 제로카피가 필수인 CDN/스트리밍(Streaming) 서버 등입니다.

네트워크 opcodes 상세

io_uring은 네트워크 I/O를 위해 전용 opcode를 제공합니다. 각 opcode는 대응하는 시스템 콜의 기능을 비동기 SQE로 감싼 것이며, 추가적으로 멀티샷, 제로카피, 고정 파일 등 고급 기능을 지원합니다.

io_uring 네트워크 Opcode 계층 연결 관리 SOCKET BIND / LISTEN ACCEPT (MS) CONNECT SHUTDOWN 데이터 전송 SEND RECV (MS) SENDMSG RECVMSG SEND_ZC RECV_ZC (MS) = Multishot 지원 고급 기능 Bundle Send Bundle Recv MSG_RING Direct FD 커널 (io_uring/net.c) io_accept() / io_connect() io_send() / io_recv() io_sendzc() / io_recvzc() io_socket() / io_shutdown() Socket Layer → TCP/UDP → IP → Device Driver → NIC
io_uring 네트워크 opcode 계층: 연결 관리, 데이터 전송, 고급 기능이 커널 io_uring/net.c를 통해 소켓 계층과 연결됩니다.

SOCKET — 소켓 생성

Linux 5.18에서 추가된 IORING_OP_SOCKETsocket(2) 시스템 콜을 비동기로 수행합니다. 소켓 생성부터 io_uring 파이프라인에 통합할 수 있습니다.

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_socket(sqe, AF_INET, SOCK_STREAM, 0, 0);
sqe->user_data = encode_user_data(OP_SOCKET, 0);

/* Direct Descriptor 모드: 파일 테이블 슬롯에 직접 할당 */
io_uring_prep_socket_direct(sqe, AF_INET, SOCK_STREAM, 0,
                             IORING_FILE_INDEX_ALLOC, 0);
/* CQE.res: 성공 시 소켓 fd (또는 direct descriptor인 경우 인덱스) */

ACCEPT — 연결 수락

IORING_OP_ACCEPTaccept4(2)를 비동기로 수행합니다. Linux 5.19부터 멀티샷(Multishot) 모드가 지원되어 하나의 SQE로 계속 새 연결을 수락합니다.

/* 단일 accept */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, listen_fd, (struct sockaddr *)&addr,
                     &addrlen, SOCK_NONBLOCK);
sqe->user_data = encode_user_data(OP_ACCEPT, 0);

/* 멀티샷 accept — SQE 1개로 무한 수락 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL,
                               SOCK_NONBLOCK);
sqe->user_data = encode_user_data(OP_ACCEPT, 0);

/* Direct descriptor + 멀티샷 accept */
io_uring_prep_multishot_accept_direct(sqe, listen_fd, NULL,
                                       NULL, SOCK_NONBLOCK);
/* CQE.res: 새 클라이언트 fd
 * CQE.flags & IORING_CQE_F_MORE: 멀티샷 계속 활성
 * CQE.flags & IORING_CQE_F_MORE가 없으면: 오류로 종료됨, 재등록 필요 */

CONNECT — 연결 시도

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_connect(sqe, sockfd, (struct sockaddr *)&addr,
                      sizeof(addr));
sqe->user_data = encode_user_data(OP_CONNECT, sockfd);
/* CQE.res: 성공 시 0, 실패 시 음수 errno */

SEND / RECV — 기본 데이터 전송

/* SEND */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, sockfd, buf, len, 0);
sqe->user_data = encode_user_data(OP_SEND, sockfd);

/* RECV */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, sockfd, buf, buf_size, 0);
sqe->user_data = encode_user_data(OP_RECV, sockfd);
/* CQE.res: 전송/수신 바이트 수, 0이면 연결 종료, 음수면 에러 */

SENDMSG / RECVMSG — 메시지 기반 전송

sendmsg(2)/recvmsg(2)를 비동기로 수행합니다. scatter/gather I/O, 보조 데이터(ancillary data), UDP 주소 지정에 유용합니다.

struct msghdr msg = {};
struct iovec iov = { .iov_base = buf, .iov_len = len };
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_name = &dest_addr;       /* UDP: 목적지 주소 */
msg.msg_namelen = sizeof(dest_addr);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_sendmsg(sqe, sockfd, &msg, 0);

/* RECVMSG with multishot + provided buffers (UDP에 특히 유용) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recvmsg_multishot(sqe, sockfd, &msg, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = BUF_GROUP_ID;

SEND_ZC / RECV_ZC — 제로카피 전송

제로카피(Zero-copy) 전송은 사용자 버퍼의 데이터를 복사하지 않고 직접 NIC로 전달합니다. 세부 사항은 제로카피 네트워킹 상세 섹션에서 다룹니다.

/* Zero-copy send */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, sockfd, buf, len, 0, 0);
sqe->user_data = encode_user_data(OP_SEND_ZC, sockfd);
/* 완료: CQE 2개 발생
 * 1번째 CQE: 전송 완료 (res = 바이트 수)
 * 2번째 CQE: 알림 (IORING_CQE_F_NOTIF) — 버퍼 재사용 가능 시점 */

고급 SQE 플래그 조합

네트워크 SQE에 적용할 수 있는 주요 플래그 조합입니다.

플래그용도네트워크 활용
IOSQE_FIXED_FILE고정 파일 테이블 사용Direct Descriptor로 fdget 비용 제거
IOSQE_IO_LINKSQE 연쇄 실행CONNECT → SEND 순서 보장(Ordering)
IOSQE_IO_HARDLINK강한 링크 (이전 실패해도 실행)타임아웃 후 정리 작업
IOSQE_ASYNC항상 io-wq에서 비동기 실행블로킹 위험이 있는 연산
IOSQE_BUFFER_SELECT제공 버퍼 링에서 버퍼 선택멀티샷 recv에 필수
IOSQE_CQE_SKIP_SUCCESS성공 CQE 생략파이프라인 중간 단계에서 CQ 절약
/* SQE 플래그 조합 예제: 고급 네트워크 파이프라인 */

/* 1. CONNECT + LINK + TIMEOUT: 연결 타임아웃 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_connect(sqe, fd, addr, addrlen);
sqe->flags |= IOSQE_IO_LINK;  /* 다음 SQE에 연결 */

sqe = io_uring_get_sqe(&ring);
struct __kernel_timespec ts = { .tv_sec = 5 };
io_uring_prep_link_timeout(sqe, &ts, 0);

/* 2. SEND + CQE_SKIP_SUCCESS + LINK → SEND → CLOSE
 * 중간 SEND 성공 CQE를 생략하여 CQ 공간 절약 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, fd, header, hdr_len, MSG_MORE);
sqe->flags |= IOSQE_IO_LINK | IOSQE_CQE_SKIP_SUCCESS;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, fd, body, body_len, 0);
/* 마지막 SQE만 CQE 생성 */

/* 3. FIXED_FILE + BUFFER_SELECT: 최적 recv */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, fixed_idx, NULL, 0, 0);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT;
sqe->buf_group = BUF_GROUP;

MSG_MORE를 활용한 코크(Cork) 패턴

HTTP 응답처럼 헤더(Header)와 본문(Body)을 연속으로 보내는 경우, MSG_MORE 플래그를 사용하면 커널이 세그먼트(Segment)를 합쳐서 전송합니다.

/* MSG_MORE: 헤더 + 본문 합치기 */
/* 헤더 전송 (아직 보내지 않음, 버퍼에 축적) */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, fd, http_header, hdr_len, MSG_MORE);
sqe->flags |= IOSQE_IO_LINK | IOSQE_CQE_SKIP_SUCCESS;

/* 본문 전송 (헤더 + 본문이 하나의 TCP 세그먼트로 전송) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, fd, http_body, body_len, 0);
/* MSG_MORE 없음 → 커널이 축적된 데이터를 flush */

/* 효과: TCP Nagle + Cork와 유사하지만
 * SQE 링크로 원자적 실행 보장
 * 2개의 작은 패킷 → 1개의 큰 패킷으로 병합
 * 네트워크 효율성 향상 (패킷 수 감소) */

BIND / LISTEN — 서버 바인드(Bind)와 리슨(Listen)

Linux 6.11에서 추가된 BIND와 LISTEN opcode는 서버 소켓 설정까지 io_uring 파이프라인에 통합합니다.

/* BIND */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_bind(sqe, sockfd, (struct sockaddr *)&addr,
                   sizeof(addr));

/* LISTEN */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_listen(sqe, sockfd, SOMAXCONN);
/* 링크(Link)를 활용하여 SOCKET → BIND → LISTEN 연쇄 실행 가능 */

SHUTDOWN — 연결 종료

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_shutdown(sqe, sockfd, SHUT_RDWR);
sqe->user_data = encode_user_data(OP_SHUTDOWN, sockfd);
/* 이후 CLOSE opcode로 fd를 해제합니다 */

io_uring 연결 수명주기

io_uring 기반 네트워크 서버에서 하나의 TCP 연결이 거치는 전체 수명주기를 단계별로 살펴봅니다.

수명주기 전체 흐름

/* TCP 연결 수명주기: io_uring 파이프라인 */

/* 단계 1: 서버 소켓 준비 (선택: SOCKET → BIND → LISTEN SQE 링크) */
struct io_uring_sqe *sqe;

/* SOCKET SQE */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_socket_direct(sqe, AF_INET, SOCK_STREAM | SOCK_NONBLOCK,
                            0, IORING_FILE_INDEX_ALLOC, 0);
sqe->flags |= IOSQE_IO_LINK;  /* 다음 SQE와 연쇄 */
sqe->user_data = make_user_data(OP_SOCKET, 0);

/* BIND SQE (링크) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_bind(sqe, 0, (struct sockaddr *)&addr, sizeof(addr));
sqe->flags |= IOSQE_IO_LINK | IOSQE_FIXED_FILE;
sqe->user_data = make_user_data(OP_BIND, 0);

/* LISTEN SQE (링크) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_listen(sqe, 0, SOMAXCONN);
sqe->flags |= IOSQE_FIXED_FILE;
sqe->user_data = make_user_data(OP_LISTEN, 0);

io_uring_submit(&ring);

/* 단계 2: 멀티샷 accept → 연결 수락 */
/* 단계 3: 멀티샷 recv + provided buffer → 데이터 수신 */
/* 단계 4: send / send_zc → 응답 전송 */
/* 단계 5: shutdown → close → 연결 종료 */

연결 상태 머신(State Machine)

/* 연결 상태 관리 */
enum conn_state {
    CONN_ACCEPTED,       /* accept 완료, recv 대기 */
    CONN_READING,        /* 멀티샷 recv 활성 */
    CONN_PROCESSING,     /* 요청 처리 중 */
    CONN_WRITING,        /* send 진행 중 */
    CONN_CLOSING,        /* shutdown 진행 중 */
    CONN_CLOSED,         /* close 완료 */
};

struct connection {
    int fd;                    /* 소켓 fd (또는 direct idx) */
    enum conn_state state;
    bool is_direct;            /* direct descriptor 여부 */
    uint64_t bytes_recv;       /* 총 수신 바이트 */
    uint64_t bytes_sent;       /* 총 송신 바이트 */
    struct timespec connected_at;
    struct connection *next;   /* 연결 리스트 */
};

/* 연결 풀 관리 */
struct conn_pool {
    struct connection *conns;  /* 사전 할당 배열 */
    int max_conns;
    int active_conns;
    struct connection *free_list;
};

static struct connection *conn_alloc(struct conn_pool *pool, int fd)
{
    if (!pool->free_list) return NULL;
    struct connection *c = pool->free_list;
    pool->free_list = c->next;
    c->fd = fd;
    c->state = CONN_ACCEPTED;
    c->bytes_recv = c->bytes_sent = 0;
    clock_gettime(CLOCK_MONOTONIC, &c->connected_at);
    pool->active_conns++;
    return c;
}

static void conn_free(struct conn_pool *pool, struct connection *c)
{
    c->state = CONN_CLOSED;
    c->next = pool->free_list;
    pool->free_list = c;
    pool->active_conns--;
}

user_data 인코딩 전략

CQE의 user_data 필드는 64비트이며, SQE를 제출할 때 설정한 값이 그대로 CQE에 전달됩니다. 이 필드를 활용하여 연산 타입(Type), 연결 fd, 추가 컨텍스트를 효율적으로 인코딩합니다.

/* user_data 인코딩 방식 1: 비트 필드 */
/* [63:56] op type (8비트) | [55:32] reserved | [31:0] fd/index */
static inline uint64_t encode_ud(uint8_t op, uint32_t fd)
{
    return ((uint64_t)op << 56) | fd;
}
static inline uint8_t decode_op(uint64_t ud)  { return ud >> 56; }
static inline uint32_t decode_fd(uint64_t ud) { return (uint32_t)ud; }

/* user_data 인코딩 방식 2: 포인터 (연결 객체 직접 참조) */
sqe->user_data = (uint64_t)(uintptr_t)conn;
/* CQE 처리 시: */
struct connection *conn = (struct connection *)(uintptr_t)cqe->user_data;
/* 주의: 포인터 방식은 멀티샷에서 동일 포인터가 여러 CQE에 나타남 */

Multishot Accept 상세

멀티샷(Multishot) accept는 io_uring 네트워크 프로그래밍의 핵심 최적화입니다. 전통적으로 accept를 받으려면 매번 새 SQE를 제출해야 했지만, 멀티샷에서는 한 번의 SQE 제출로 계속 새 연결을 수락합니다.

동작 메커니즘

멀티샷 accept의 핵심은 IORING_CQE_F_MORE 플래그(Flag)입니다. 커널이 CQE를 생성할 때 이 플래그가 설정되어 있으면 "아직 SQE가 활성 상태이며 더 많은 CQE가 올 수 있다"는 의미입니다.

Multishot Accept 수명주기 시간 SQE: multishot_accept 제출 CQE: fd=5 | flags=IORING_CQE_F_MORE 클라이언트 A 연결 CQE: fd=6 | flags=IORING_CQE_F_MORE 클라이언트 B 연결 CQE: fd=7 | flags=IORING_CQE_F_MORE 클라이언트 C 연결 ⋮ (계속 수락) CQE: res=-ENOMEM | flags=0 (F_MORE 없음) 오류 → 멀티샷 종료 SQE: multishot_accept 재등록 수동 재등록 필요 CQE: fd=8 | flags=IORING_CQE_F_MORE 정상 재개
멀티샷 accept 수명주기: SQE 1개로 계속 연결을 수락하다가 오류 발생 시 F_MORE 플래그가 제거되고, 애플리케이션이 수동으로 재등록합니다.

CQE 처리 코드

static void handle_accept_cqe(struct io_uring *ring,
                               struct io_uring_cqe *cqe)
{
    if (cqe->res < 0) {
        /* 오류 발생: EMFILE, ENOMEM, ECANCELED 등 */
        fprintf(stderr, "accept error: %s\n", strerror(-cqe->res));

        /* F_MORE가 없으면 멀티샷이 종료된 것 → 재등록 */
        if (!(cqe->flags & IORING_CQE_F_MORE)) {
            struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
            io_uring_prep_multishot_accept(sqe, listen_fd,
                                           NULL, NULL, SOCK_NONBLOCK);
            sqe->user_data = encode_user_data(OP_ACCEPT, 0);
        }
        return;
    }

    int client_fd = cqe->res;
    setup_connection(ring, client_fd);

    /* F_MORE가 설정되어 있으면 멀티샷이 계속 활성 — 재제출 불필요 */
    if (!(cqe->flags & IORING_CQE_F_MORE)) {
        /* 드물게 F_MORE 없이 성공 CQE가 올 수 있음 (CQ 오버플로우 등) */
        struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
        io_uring_prep_multishot_accept(sqe, listen_fd,
                                       NULL, NULL, SOCK_NONBLOCK);
        sqe->user_data = encode_user_data(OP_ACCEPT, 0);
    }
}

커널 구현: io_accept()

커널 소스 io_uring/net.c에서 io_accept() 함수는 다음과 같은 흐름으로 동작합니다.

/* io_uring/net.c — io_accept() 핵심 흐름 (Linux 6.12 기준) */
int io_accept(struct io_kiocb *req, unsigned int issue_flags)
{
    struct io_accept *accept = io_kiocb_to_cmd(req);
    bool force_nonblock = issue_flags & IO_URING_F_NONBLOCK;
    int ret, fd;

retry:
    /* fixed file table 사용 시 file_slot을 미리 예약 */
    if (req->flags & REQ_F_FIXED_FILE)
        fd = io_fixed_fd_install(req, issue_flags, ...);

    /* 핵심: __sys_accept4_file() 호출 → 소켓 계층 accept */
    ret = __sys_accept4_file(accept->file, accept->addr,
                             accept->addr_len, accept->flags, fd);

    if (ret == -EAGAIN && force_nonblock)
        return -EAGAIN;   /* io-wq로 전달 또는 poll arm */

    /* 멀티샷: 성공하면 다음 accept를 위해 재시도 */
    if (req->flags & REQ_F_APOLL_MULTISHOT) {
        /* CQE 게시 (F_MORE 플래그 포함) */
        if (io_req_post_cqe(req, ret, IORING_CQE_F_MORE))
            goto retry;   /* 대기 없이 즉시 다음 accept 시도 */
        /* CQ가 가득 차면 F_MORE 없이 종료 */
    }
    /* 단일 모드: CQE 게시 후 종료 */
    io_req_set_res(req, ret, 0);
    return IOU_OK;
}
성능 영향: 멀티샷 accept는 높은 CPS(Connections Per Second) 환경에서 SQE 제출 비용을 제거합니다. 벤치마크에서 멀티샷 accept는 단일 accept 대비 약 15~20% 높은 CPS를 달성합니다. 이는 SQE 할당, 초기화, 제출의 반복 비용이 사라지기 때문입니다.

Multishot Recv + Provided Buffer Ring

멀티샷 recv와 제공 버퍼 링(Provided Buffer Ring)의 조합은 io_uring 네트워크 프로그래밍에서 가장 강력한 최적화입니다. 이 두 기능은 함께 사용하도록 설계되었습니다.

제공 버퍼 링 설정

제공 버퍼 링은 사용자 공간에서 버퍼 풀(Pool)을 미리 등록하고, 커널이 데이터 수신 시 자동으로 버퍼를 선택하여 데이터를 채우는 메커니즘입니다.

#define BUF_RING_SIZE   1024
#define BUF_SIZE        4096
#define BUF_GROUP_ID    1

struct io_uring_buf_ring *br;
void *bufs;

/* 1. 버퍼 링 등록 */
br = io_uring_setup_buf_ring(&ring, BUF_RING_SIZE, BUF_GROUP_ID, 0, &ret);
if (!br) {
    perror("setup_buf_ring");
    return 1;
}

/* 2. 버퍼 할당 + 링에 추가 */
bufs = malloc(BUF_RING_SIZE * BUF_SIZE);
for (int i = 0; i < BUF_RING_SIZE; i++) {
    io_uring_buf_ring_add(br, bufs + i * BUF_SIZE, BUF_SIZE, i,
                          io_uring_buf_ring_mask(BUF_RING_SIZE), i);
}
io_uring_buf_ring_advance(br, BUF_RING_SIZE);

/* 3. 멀티샷 recv + 버퍼 선택 설정 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, client_fd, NULL, 0, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = BUF_GROUP_ID;
sqe->user_data = encode_user_data(OP_RECV, client_fd);

CQE 처리와 버퍼 반환

static void handle_recv_cqe(struct io_uring *ring,
                             struct io_uring_cqe *cqe)
{
    int fd = get_fd(cqe->user_data);

    if (cqe->res <= 0) {
        /* 0 = 연결 종료, 음수 = 에러 */
        close_connection(ring, fd);
        return;
    }

    /* 버퍼 ID 추출 (IORING_CQE_F_BUFFER 플래그 확인) */
    if (!(cqe->flags & IORING_CQE_F_BUFFER)) {
        fprintf(stderr, "recv without buffer flag\n");
        return;
    }
    int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    void *data = bufs + buf_id * BUF_SIZE;
    int data_len = cqe->res;

    /* 데이터 처리 */
    process_data(fd, data, data_len);

    /* 버퍼를 링에 반환 (재사용) */
    io_uring_buf_ring_add(br, data, BUF_SIZE, buf_id,
                          io_uring_buf_ring_mask(BUF_RING_SIZE), 0);
    io_uring_buf_ring_advance(br, 1);

    /* F_MORE가 없으면 멀티샷 종료 → 재등록 */
    if (!(cqe->flags & IORING_CQE_F_MORE)) {
        rearm_recv(ring, fd);
    }
}
Multishot Recv + Provided Buffer Ring User Space Provided Buffer Ring (buf_group=1) buf[0] buf[1] buf[2] buf[3] ... buf[N-1] CQ Ring CQE: res=1024, flags=F_MORE|F_BUFFER buf_id=0 → bufs + 0 * 4096 사용자가 처리 후 buf_ring_add()로 반환 Kernel Space io_recv() multishot + buf_select io_recv_buf_select() 버퍼 링에서 할당 sock_recvmsg() 소켓에서 데이터 수신 NIC RX DMA 수신 선택된 버퍼에 데이터 복사 사용 완료된 버퍼 반환 (buf_ring_add)
Multishot recv와 제공 버퍼 링의 데이터 흐름: NIC에서 수신된 데이터가 커널에서 버퍼 링의 빈 슬롯에 배치되고, CQE로 사용자에게 통지됩니다. 사용자는 처리 후 버퍼를 반환합니다.

증분 소비(Incremental Consumption)

Linux 6.10에서 도입된 증분 소비는 하나의 버퍼를 여러 recv 연산에 걸쳐 부분적으로 사용할 수 있게 합니다. 이전에는 버퍼가 선택되면 전체가 소비되었지만, 증분 소비에서는 사용된 만큼만 소비되고 나머지는 다음 recv에 재사용됩니다.

/* 증분 소비: 큰 버퍼를 효율적으로 사용 */
/* 예: 64KB 버퍼에 1KB 데이터 수신
 * 증분 소비 없음: 64KB 버퍼 전체 소비 (63KB 낭비)
 * 증분 소비 있음: 1KB만 소비, 나머지 63KB 다음 recv에 재사용 */

/* 버퍼 링 설정 시 증분 모드 활성화 */
/* (liburing의 최신 API 사용) */
struct io_uring_buf_reg reg = {
    .ring_addr = (unsigned long)br,
    .ring_entries = BUF_RING_SIZE,
    .bgid = BUF_GROUP_ID,
    .flags = IOU_PBUF_RING_INC,  /* 증분 소비 활성화 */
};
io_uring_register_buf_ring(&ring, ®, 0);

버퍼 링 크기 산정

제공 버퍼 링의 크기는 동시 활성 recv 연산 수, 처리 지연, 버스(Bus)트(Burst) 트래픽을 고려하여 결정합니다.

/* 버퍼 링 크기 산정 공식
 *
 * 필요 버퍼 수 = 동시_recv_연결 * 연결당_미완료_recv * 안전_계수
 *
 * 예: 5000 연결, 멀티샷 recv (연결당 1개 활성),
 *     CQE 처리 지연 고려 × 2배 안전
 *     = 5000 * 1 * 2 = 10000 → next_power_of_2 = 16384
 *
 * 버퍼 크기:
 * - HTTP 요청: 4KB (대부분의 HTTP 헤더 수용)
 * - 파일 전송: 64KB (처리량 최적화)
 * - UDP 패킷: MTU 크기 (1500B 또는 9000B for jumbo)
 * - 범용: 4~8KB
 */

#define calc_buf_ring_size(active_conns, safety_factor) \
    (next_power_of_2((active_conns) * (safety_factor)))

/* 메모리 사용량 계산 */
/* 총 메모리 = 버퍼_수 * 버퍼_크기 + 링_메타데이터 */
/* 예: 16384 * 4KB = 64MB */
/* 링 메타데이터: 16384 * sizeof(io_uring_buf) = ~256KB */

버퍼 반환 전략

주의: 버퍼 링이 고갈되면(모든 버퍼가 사용 중) 멀티샷 recv가 -ENOBUFS로 종료됩니다. 버퍼 반환을 지연하면 대규모 연결 환경에서 문제가 발생할 수 있습니다. 데이터를 처리하자마자 즉시 반환하거나, 충분히 큰 버퍼 링을 확보하세요.
/* 배치 반환 패턴: 여러 CQE를 처리한 후 한 번에 advance */
int returned = 0;
struct io_uring_cqe *cqe;
unsigned head;

io_uring_for_each_cqe(&ring, head, cqe) {
    if (get_op_type(cqe->user_data) == OP_RECV &&
        (cqe->flags & IORING_CQE_F_BUFFER)) {
        int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
        process_data(bufs + buf_id * BUF_SIZE, cqe->res);
        io_uring_buf_ring_add(br, bufs + buf_id * BUF_SIZE,
                              BUF_SIZE, buf_id,
                              io_uring_buf_ring_mask(BUF_RING_SIZE),
                              returned);
        returned++;
    }
}
if (returned)
    io_uring_buf_ring_advance(br, returned);

커널 구현: io_recv()

/* io_uring/net.c — io_recv() 핵심 경로 (Linux 6.12 기준) */
int io_recv(struct io_kiocb *req, unsigned int issue_flags)
{
    struct io_sr_msg *sr = io_kiocb_to_cmd(req);
    struct msghdr msg = {};
    struct socket *sock;
    int ret, min_ret;

    sock = sock_from_file(req->file);
    if (!sock)
        return -ENOTSOCK;

    /* 멀티샷 + 버퍼 선택 모드 */
    if (req->flags & REQ_F_APOLL_MULTISHOT) {
retry_multishot:
        /* 제공 버퍼 링에서 버퍼 할당 */
        if (req->flags & REQ_F_BUFFER_SELECT) {
            ret = io_recv_buf_select(req, &msg, &iov, issue_flags);
            if (ret)
                return ret;  /* -ENOBUFS: 버퍼 고갈 */
        }

        /* 소켓에서 데이터 수신 */
        ret = sock_recvmsg(sock, &msg, MSG_DONTWAIT);

        if (ret > 0) {
            /* CQE 게시: F_MORE + F_BUFFER 플래그 */
            if (io_req_post_cqe(req, ret,
                    IORING_CQE_F_MORE | cflags))
                goto retry_multishot;  /* 추가 데이터 즉시 수신 시도 */
        }
    }
    /* ... 단일 모드 처리 ... */
}

Zero-copy 네트워킹 상세

제로카피(Zero-copy) 네트워킹은 사용자 공간 버퍼의 데이터를 커널 버퍼로 복사하지 않고 직접 NIC에 전달하여 CPU 사용량과 메모리 대역폭(Bandwidth)을 절감합니다. io_uring은 SEND_ZC(Linux 5.19)와 RECV_ZC(Linux 6.2)를 통해 네이티브 제로카피를 지원합니다.

SEND_ZC: 2-CQE 패턴

제로카피 전송에서는 사용자 버퍼가 DMA로 NIC에 전달되는 동안 버퍼를 수정하면 안 됩니다. 이를 보장하기 위해 SEND_ZC는 CQE를 2개 생성합니다.

/* SEND_ZC 사용 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, sockfd, large_buf, 65536, 0, 0);
sqe->user_data = encode_user_data(OP_SEND_ZC, sockfd);

/* CQE 처리 */
static void handle_send_zc_cqe(struct io_uring_cqe *cqe)
{
    if (cqe->flags & IORING_CQE_F_NOTIF) {
        /* 2번째 CQE: 알림 — NIC가 데이터 전송 완료
         * 이 시점부터 사용자 버퍼를 안전하게 재사용 가능 */
        buffer_pool_return(cqe->user_data);
        return;
    }
    /* 1번째 CQE: 전송 제출 완료
     * res = 전송 요청된 바이트 수
     * 주의: 버퍼는 아직 재사용 불가! */
    if (cqe->res < 0) {
        fprintf(stderr, "send_zc error: %d\n", cqe->res);
    }
}
Zero-copy Send vs 일반 Send 데이터 경로 일반 Send (복사 발생) User Buffer 복사 Kernel sk_buff 복사 TX Ring Buffer DMA NIC SEND_ZC (복사 없음) User Buffer (page pinned) 페이지 참조 공유 (복사 없음) TX Ring Buffer (user page ref) DMA NIC NOTIF CQE → 버퍼 재사용 가능 일반: memcpy 2회 + CPU 부하 | ZC: memcpy 0회 + page pin/unpin 비용
일반 send는 사용자→커널→TX 링 2번 복사가 발생하지만, SEND_ZC는 사용자 페이지(Page)를 직접 참조하여 복사를 제거합니다. NOTIF CQE가 도착해야 버퍼를 안전하게 재사용할 수 있습니다.

제로카피 임계값(Threshold) 분석

제로카피는 항상 이득인 것은 아닙니다. 페이지 핀닝(Page Pinning), 참조 카운팅(Reference Counting), NOTIF CQE 처리 비용이 있으므로 작은 메시지에서는 오히려 느릴 수 있습니다.

메시지 크기일반 sendSEND_ZC권장
< 1 KB빠름오히려 느림 (pin 비용 > 복사 비용)일반 send
1~8 KB보통비슷하거나 약간 빠름워크로드에 따라 선택
8~64 KB느림 (memcpy 부하)빠름 (20~40% CPU 절감)SEND_ZC
> 64 KB매우 느림매우 빠름 (50%+ CPU 절감)SEND_ZC 필수
경험 법칙: 일반적으로 메시지 크기가 4 KB 이상이면 SEND_ZC가 유리합니다. 정확한 임계값은 CPU 아키텍처, 메모리 대역폭, NIC 드라이버에 따라 달라지므로 실제 워크로드로 벤치마크하세요.

RECV_ZC: 페이지 플립(Page Flip) 메커니즘

RECV_ZC(Linux 6.2)는 수신 경로에서 제로카피를 구현합니다. NIC가 DMA로 데이터를 수신한 페이지를 사용자 공간에 직접 매핑(Mapping)하는 페이지 플립 방식을 사용합니다.

/* RECV_ZC 사용 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, sockfd, NULL, 0, 0);
sqe->opcode = IORING_OP_RECV_ZC;
sqe->ioprio |= IORING_RECV_MULTISHOT;
sqe->user_data = encode_user_data(OP_RECV_ZC, sockfd);

/* CQE 처리 — 제로카피 수신 데이터 접근 */
static void handle_recv_zc_cqe(struct io_uring_cqe *cqe)
{
    /* CQE32 확장 필드에서 수신 데이터 페이지 정보 획득 */
    struct io_uring_recvmsg_out *o;
    o = io_uring_recvmsg_validate(data, cqe->res, &msg);
    /* 제로카피 데이터 직접 접근 (복사 없음) */
    process_zc_data(io_uring_recvmsg_payload(o, &msg),
                    io_uring_recvmsg_payload_length(o, cqe->res, &msg));
}
RECV_ZC 제약 사항: RECV_ZC는 NIC와 드라이버가 제로카피 수신을 지원해야 합니다(Mellanox ConnectX-5+, Intel E810 등). 지원하지 않는 NIC에서는 자동으로 일반 복사 경로로 폴백(Fallback)됩니다. 또한 TCP에서만 동작하며 UDP는 현재 미지원입니다.

제로카피 커널 내부 흐름

SEND_ZC의 커널 내부 동작을 단계별로 분석합니다.

/* SEND_ZC 커널 내부 흐름 */

/* 1. io_send_zc() → MSG_ZEROCOPY 플래그로 sock_sendmsg() 호출 */

/* 2. tcp_sendmsg_locked() → MSG_ZEROCOPY 감지 */
/*    → skb_zcopy_init() 호출 */
/*    → ubuf_info 구조체 설정 (완료 콜백 등록) */

/* 3. get_user_pages_fast() → 사용자 페이지를 커널에 pin */
/*    → page refcount 증가 */
/*    → skb_fill_page_desc()로 skb에 페이지 설정 */

/* 4. TCP 세그먼테이션 → skb가 NIC TX 큐에 전달 */
/*    → NIC DMA로 페이지 데이터를 네트워크에 전송 */

/* 5. 1번째 CQE: io_req_set_res()로 전송 바이트 보고 */

/* 6. NIC TX 완료 → skb 해제 → skb_zcopy_clear() */
/*    → ubuf_info->callback() → io_notif_complete() */
/*    → 2번째 CQE (NOTIF) 생성 */
/*    → 사용자 페이지 unpin (put_page) */

/* 전체 과정에서 memcpy는 0회 발생 */

고정 버퍼와 제로카피 결합

SEND_ZC는 io_uring_register_buffers()로 등록한 고정 버퍼와 결합할 수 있습니다. 고정 버퍼는 미리 페이지가 pin되어 있으므로 매 전송마다 get_user_pages() 비용이 사라집니다.

/* 고정 버퍼 등록 (미리 pin) */
struct iovec iovecs[4];
for (int i = 0; i < 4; i++) {
    iovecs[i].iov_base = aligned_alloc(4096, 65536);
    iovecs[i].iov_len = 65536;
}
io_uring_register_buffers(&ring, iovecs, 4);

/* SEND_ZC + 고정 버퍼: 최대 성능 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc_fixed(sqe, sockfd, iovecs[0].iov_base,
                            65536, 0, 0, 0);
/* 고정 버퍼 인덱스 0 사용 → get_user_pages 생략 */
/* NOTIF CQE는 여전히 발생 (NIC DMA 완료 알림) */

알림(Notification) 관리 전략

SEND_ZC의 2-CQE 패턴에서 NOTIF CQE 관리는 복잡성의 주요 원인입니다. 효율적인 관리 전략을 소개합니다.

/* NOTIF 관리 전략 1: 버퍼 풀 + 참조 카운팅 */
struct send_buffer {
    void *data;
    int len;
    int refcount;      /* 1: send CQE만 도착, 0: NOTIF도 도착 → 반환 */
};

static void handle_send_zc_result(struct io_uring_cqe *cqe)
{
    struct send_buffer *sb = get_send_buffer(cqe->user_data);

    if (cqe->flags & IORING_CQE_F_NOTIF) {
        /* NOTIF CQE: NIC 전송 완료 → 버퍼 해제 가능 */
        sb->refcount--;
        if (sb->refcount == 0)
            buffer_pool_return(sb);
    } else {
        /* 전송 완료 CQE: 아직 버퍼 재사용 불가 */
        if (cqe->res < 0) {
            /* 전송 실패: NOTIF CQE가 오지 않을 수 있음 */
            buffer_pool_return(sb);
        } else {
            sb->refcount = 1;  /* NOTIF 대기 */
        }
    }
}

/* NOTIF 관리 전략 2: 순차적 슬롯 */
/* 여러 SEND_ZC가 동시에 진행 중일 때
 * 각 전송에 고유 슬롯 번호를 할당
 * NOTIF CQE의 user_data로 슬롯 식별 */

kTLS + io_uring 통합

kTLS(Kernel TLS)는 TLS 핸드셰이크(Handshake)는 사용자 공간(OpenSSL 등)에서 수행하고, 데이터 암호화(Encryption)/복호화(Decryption)를 커널에 오프로드(Offload)하는 기술입니다. io_uring과 결합하면 비동기 TLS 네트워킹이 가능합니다.

kTLS 설정 흐름

/* 1. OpenSSL로 TLS 핸드셰이크 수행 */
SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION);
SSL *ssl = SSL_new(ctx);
SSL_set_fd(ssl, client_fd);
SSL_accept(ssl);  /* TLS 핸드셰이크 완료 */

/* 2. 커널에 TLS 오프로드 설정 */
int enable = 1;
setsockopt(client_fd, SOL_TCP, TCP_ULP, "tls", sizeof("tls"));

/* 3. 암호화 키 설정 (OpenSSL에서 추출) */
struct tls12_crypto_info_aes_gcm_256 crypto_info;
/* ... SSL_export_keying_material() 또는 SSL_get_key()로 키 추출 ... */
setsockopt(client_fd, SOL_TLS, TLS_TX, &crypto_info, sizeof(crypto_info));
setsockopt(client_fd, SOL_TLS, TLS_RX, &crypto_info, sizeof(crypto_info));

/* 4. 이후 io_uring send/recv → 커널이 자동 암호화/복호화 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, client_fd, plaintext, len, 0);
/* 커널이 AES-GCM 암호화 후 전송 */
kTLS + io_uring 데이터 경로 User Space Application (평문) io_uring SQE Kernel Space io_uring core io_send() kTLS (SW) AES-GCM 암호화 TCP 스택 세그먼테이션 Device Driver NIC HW TLS Offload (ConnectX-6 Dx, E810): kTLS 암호화를 NIC에서 수행
kTLS + io_uring 데이터 경로: 애플리케이션은 평문을 SQE로 제출하고, 커널의 kTLS 계층이 암호화를 수행합니다. HW TLS 지원 NIC에서는 암호화까지 하드웨어에서 처리됩니다.

kTLS 상세 설정 코드

kTLS를 io_uring과 함께 사용하기 위한 전체 설정 코드입니다. OpenSSL을 사용하여 핸드셰이크를 수행하고, 키를 커널에 전달하는 과정을 보여줍니다.

#include <linux/tls.h>
#include <openssl/ssl.h>
#include <liburing.h>

/* kTLS 설정 함수 */
static int setup_ktls(int fd, SSL *ssl)
{
    /* 1. TCP ULP으로 tls 설정 */
    if (setsockopt(fd, SOL_TCP, TCP_ULP, "tls",
                  sizeof("tls")) < 0) {
        perror("TCP_ULP tls");
        return -1;
    }

    /* 2. TLS 1.3 + AES-GCM-128 키 추출 */
    struct tls12_crypto_info_aes_gcm_128 crypto_info_tx;
    memset(&crypto_info_tx, 0, sizeof(crypto_info_tx));
    crypto_info_tx.info.version = TLS_1_3_VERSION;
    crypto_info_tx.info.cipher_type = TLS_CIPHER_AES_GCM_128;

    /* OpenSSL에서 TX 키 소재 추출 */
    /* 실제 구현은 OpenSSL 버전에 따라 다름
     * SSL_SESSION_get_master_key() 또는
     * SSL_export_keying_material() 사용 */
    unsigned char tx_key[16], tx_iv[12], tx_seq[8];
    /* ... 키 추출 코드 ... */

    memcpy(crypto_info_tx.key, tx_key, TLS_CIPHER_AES_GCM_128_KEY_SIZE);
    memcpy(crypto_info_tx.iv, tx_iv + 4, TLS_CIPHER_AES_GCM_128_IV_SIZE);
    memcpy(crypto_info_tx.salt, tx_iv, TLS_CIPHER_AES_GCM_128_SALT_SIZE);
    memcpy(crypto_info_tx.rec_seq, tx_seq, TLS_CIPHER_AES_GCM_128_REC_SEQ_SIZE);

    /* 3. TX 방향 설정 */
    if (setsockopt(fd, SOL_TLS, TLS_TX, &crypto_info_tx,
                  sizeof(crypto_info_tx)) < 0) {
        perror("TLS_TX");
        return -1;
    }

    /* 4. RX 방향도 동일하게 설정 (키만 다름) */
    struct tls12_crypto_info_aes_gcm_128 crypto_info_rx;
    /* ... RX 키 설정 ... */
    if (setsockopt(fd, SOL_TLS, TLS_RX, &crypto_info_rx,
                  sizeof(crypto_info_rx)) < 0) {
        perror("TLS_RX");
        return -1;
    }

    /* 이후: io_uring send/recv → 커널이 자동 암호화/복호화
     * 사용자 공간에서는 평문만 다루면 됨 */
    return 0;
}

/* kTLS + io_uring 통합 사용 */
static void ktls_io_uring_echo(struct io_uring *ring, int fd)
{
    /* recv: 커널이 TLS 복호화 후 평문을 전달 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_recv(sqe, fd, buf, BUF_SIZE, 0);
    /* buf에는 복호화된 평문이 들어옴 */

    /* send: 커널이 평문을 TLS 암호화하여 전송 */
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_send(sqe, fd, plaintext, len, 0);
    /* 커널이 AES-GCM 암호화 후 전송 */

    /* kTLS + SEND_ZC도 결합 가능 */
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_send_zc(sqe, fd, large_file, 65536, 0, 0);
    /* 커널: 제로카피 + TLS 암호화
     * 데이터 복사 0회, 암호화만 수행 */
}

kTLS + sendfile 통합

kTLS의 가장 강력한 활용은 sendfile과의 결합입니다. 파일 데이터를 사용자 공간으로 읽지 않고 커널에서 직접 TLS 암호화하여 전송합니다.

/* kTLS + io_uring sendfile: 최적의 파일 전송 */
/* splice를 사용하여 파일 → TLS 소켓 제로카피 전송 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_splice(sqe,
    file_fd, offset,         /* 소스: 파일 */
    tls_sockfd, -1,           /* 목적지: kTLS 소켓 */
    file_size,
    SPLICE_F_MOVE);
/* 경로: 파일 page cache → kTLS 암호화 → TCP → NIC
 * 사용자 공간 복사: 0회
 * 효과: nginx sendfile + TLS와 동일하지만 비동기 */

하드웨어(Hardware) TLS 오프로드

NICSW kTLSHW kTLS TXHW kTLS RX비고
Mellanox ConnectX-6 Dx지원지원지원TLS 1.2/1.3, AES-GCM-128/256
Intel E810지원지원지원 (6.1+)TLS 1.2/1.3
Broadcom BCM57508지원지원미지원TX만 오프로드
기타 NIC지원미지원미지원소프트웨어 kTLS만 가능

kTLS + io_uring 성능

성능 수치 (ConnectX-6 Dx, 100GbE): SW kTLS + io_uring send는 사용자 공간 OpenSSL 대비 약 2~3배 처리량(Throughput) 향상을 보입니다. HW kTLS 오프로드까지 활성화하면 CPU 사용량이 추가로 50% 감소하여, TLS 암호화 부담 없이 라인 레이트(Line Rate)에 근접합니다.

NAPI Busy-Poll 통합

Linux 6.9에서 도입된 IORING_REGISTER_NAPI는 io_uring이 커널의 NAPI 폴링과 직접 통합되어 패킷(Packet) 수신 지연 시간을 대폭 줄입니다.

인터럽트(Interrupt) vs 바쁜 대기 경로

일반적으로 패킷이 NIC에 도착하면 하드웨어 인터럽트 → softirq → NAPI poll → 소켓 큐 → 사용자 공간 알림 경로를 거칩니다. 이 과정에서 수십 마이크로초의 지연이 발생합니다. 바쁜 대기(Busy-poll)는 이 경로를 단축합니다.

인터럽트 경로 vs Busy-poll 경로 인터럽트 경로 (높은 지연) NIC RX HW IRQ softirq NAPI poll 소켓 큐 task_work 알림 CQE ~30μs Busy-poll 경로 (낮은 지연) NIC RX IRQ 없음 io_uring NAPI poll (napi_busy_loop) 소켓 큐 CQE ~2-5μs 설정 코드 struct io_uring_napi napi = { .busy_poll_to = 100, .prefer_busy_poll = 1 }; io_uring_register_napi(&ring, &napi); busy_poll_to: 바쁜 대기 시간(μs), prefer_busy_poll: 가능하면 항상 busy-poll 사용 주의: CPU 코어를 독점하므로, 전용 코어가 확보된 환경에서만 사용하세요.
인터럽트 경로는 HW IRQ → softirq → NAPI → 소켓 → task_work를 거쳐 ~30μs 지연이 발생합니다. Busy-poll은 io_uring이 직접 NAPI를 폴링하여 ~2-5μs로 단축합니다.

동작 원리

일반적인 수신 경로에서는 NIC가 패킷을 수신하면 하드웨어 인터럽트를 발생시키고, softirq 처리기가 NAPI poll 함수를 호출하여 패킷을 처리합니다. 이 과정에서 인터럽트 처리, 컨텍스트 전환, 스케줄링 지연이 누적됩니다.

io_uring NAPI 통합은 이 경로를 우회합니다. io_uring_enter()가 CQE를 기다리는 동안, 커널은 해당 소켓과 연결된 NAPI 인스턴스를 직접 폴링합니다. NIC의 수신 큐에서 패킷을 즉시 가져오므로 인터럽트 대기 시간(Latency)이 제거됩니다.

/* NAPI busy-poll 내부 동작 의사 코드 */
/* io_uring_enter() → io_uring_getevents() 내부 */
while (cqe_count < min_complete) {
    /* 1. CQ에 완료된 CQE가 있는지 확인 */
    if (io_cqring_events(ctx) >= min_complete)
        break;

    /* 2. NAPI busy-poll 등록된 경우 */
    if (ctx->napi_enabled) {
        /* 등록된 NAPI 인스턴스 직접 폴링 */
        list_for_each_entry(ne, &ctx->napi_list, list) {
            napi_busy_loop(ne->napi_id,
                          NULL,    /* loop_end 콜백 */
                          NULL,    /* loop_end 데이터 */
                          ctx->napi_prefer_busy_poll,
                          ctx->napi_busy_poll_to);
        }
        /* NAPI poll이 패킷을 처리 → 소켓 큐에 데이터 도착
         * → io_recv()의 poll arm이 트리거
         * → CQE 생성 */
    }

    /* 3. busy-poll 타임아웃 확인 */
    if (time_after(jiffies, deadline))
        break;

    /* 4. 다른 태스크에 CPU 양보 (선택적) */
    cond_resched();
}

소켓-NAPI 연관(Association)

io_uring이 어떤 NAPI 인스턴스를 폴링할지는 소켓이 수신하는 NIC 큐에 의해 결정됩니다. 각 소켓은 마지막으로 패킷을 수신한 NAPI 인스턴스의 ID를 sk_napi_id 필드에 기록합니다.

/* 소켓의 NAPI ID 확인 */
int napi_id;
socklen_t len = sizeof(napi_id);
getsockopt(sockfd, SOL_SOCKET, SO_INCOMING_NAPI_ID,
           &napi_id, &len);
printf("Socket NAPI ID: %d\n", napi_id);

/* NAPI ID → NIC RX 큐 매핑:
 * /sys/class/net/eth0/queues/rx-N/rps_cpus
 * 각 RX 큐는 하나의 NAPI 인스턴스에 대응 */

NAPI 바쁜 대기 설정

#include <liburing.h>

/* io_uring 인스턴스에 NAPI busy-poll 등록 */
struct io_uring_napi napi = {
    .busy_poll_to = 100,        /* 바쁜 대기 타임아웃(μs) */
    .prefer_busy_poll = 1,     /* 가능하면 항상 busy-poll */
};

int ret = io_uring_register_napi(&ring, &napi);
if (ret < 0) {
    fprintf(stderr, "NAPI register failed: %s (kernel 6.9+ 필요)\n",
            strerror(-ret));
}

/* 해제 */
io_uring_unregister_napi(&ring, &napi);
CPU 비용: NAPI 바쁜 대기는 지연 시간을 크게 줄이지만, 폴링 동안 CPU 코어를 100% 사용합니다. 지연 시간에 민감한 금융 거래, HFT(High-Frequency Trading) 시스템에서 전용 코어를 할당할 수 있는 경우에만 사용하세요.

고성능 서버 아키텍처 패턴

단일 스레드(Single Thread) 이벤트 루프

가장 단순한 패턴입니다. 하나의 스레드가 하나의 io_uring 인스턴스를 소유하고, 모든 네트워크 I/O를 처리합니다.

/* 단일 스레드 io_uring 서버 골격 */
int main(void)
{
    struct io_uring ring;
    struct io_uring_params params = {
        .flags = IORING_SETUP_SINGLE_ISSUER |
                 IORING_SETUP_DEFER_TASKRUN |
                 IORING_SETUP_COOP_TASKRUN,
        .cq_entries = 4096,
    };
    io_uring_queue_init_params(2048, &ring, ¶ms);

    /* 제공 버퍼 링 설정 */
    setup_buffer_ring(&ring);

    /* 리스닝 소켓 설정 + 멀티샷 accept 등록 */
    int listen_fd = setup_listen_socket(8080);
    arm_multishot_accept(&ring, listen_fd);

    /* 이벤트 루프 */
    while (1) {
        io_uring_submit_and_wait(&ring, 1);
        process_completions(&ring);
    }
}

멀티 링(Multi-ring) 스레드별 아키텍처

대규모 서버에서는 CPU 코어 수만큼 스레드를 생성하고, 각 스레드가 독립된 io_uring 인스턴스를 소유합니다. MSG_RING opcode를 사용하여 스레드 간 통신합니다.

멀티 링 스레드별 서버 아키텍처 Accept Thread io_uring ring[0] multishot accept Round-robin 분배 MSG_RING으로 fd 전달 Worker Thread 0 io_uring ring[1] recv/send 처리 Buffer Ring CPU 0에 affinity Worker Thread 1 io_uring ring[2] recv/send 처리 Buffer Ring CPU 1에 affinity Worker Thread N io_uring ring[N+1] recv/send 처리 CPU N에 affinity MSG_RING 각 Worker는 독립된 io_uring ring + Buffer Ring을 소유 Accept Thread가 새 연결을 MSG_RING으로 Worker에게 분배 SINGLE_ISSUER + DEFER_TASKRUN으로 최대 성능
멀티 링 아키텍처: Accept 전담 스레드가 멀티샷 accept로 연결을 수락하고, MSG_RING을 통해 Worker 스레드에게 라운드로빈으로 분배합니다.

Accept 분배 전략

멀티 스레드 서버에서 accept된 연결을 Worker 스레드에 분배하는 전략은 여러 가지가 있습니다.

전략방법장점단점
라운드로빈(Round-robin)순서대로 Worker에 할당구현 단순, 균등 분배워크로드 편차 무시
최소 연결(Least Connection)가장 적은 연결의 Worker에 할당부하 균형원자적(Atomic) 카운터 필요
SO_REUSEPORT각 Worker가 독립 listen커널이 분배, 확장성 좋음연결 해시(Hash)에 의존
전용 Accept 스레드1개 스레드가 accept, MSG_RING으로 분배accept 최적화 집중MSG_RING 비용

SO_REUSEPORT 패턴

/* 각 Worker 스레드가 독립적으로 listen + accept */
static void *worker_thread(void *arg)
{
    int core_id = *(int *)arg;
    struct io_uring ring;

    /* CPU affinity 설정 */
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);
    pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);

    /* 독립된 io_uring 인스턴스 */
    struct io_uring_params params = {
        .flags = IORING_SETUP_SINGLE_ISSUER |
                 IORING_SETUP_DEFER_TASKRUN,
    };
    io_uring_queue_init_params(2048, &ring, ¶ms);

    /* 독립된 listen 소켓 (SO_REUSEPORT) */
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(8080),
        .sin_addr.s_addr = INADDR_ANY,
    };
    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, SOMAXCONN);

    /* 멀티샷 accept */
    arm_multishot_accept(&ring, listen_fd);
    setup_buffer_ring(&ring);

    /* 이벤트 루프 */
    while (!shutdown_requested) {
        io_uring_submit_and_wait(&ring, 1);
        process_completions(&ring);
    }
    return NULL;
}

/* 메인: CPU 코어 수만큼 Worker 스레드 생성 */
int main(void)
{
    int ncpus = sysconf(_SC_NPROCESSORS_ONLN);
    pthread_t threads[ncpus];
    int core_ids[ncpus];

    for (int i = 0; i < ncpus; i++) {
        core_ids[i] = i;
        pthread_create(&threads[i], NULL, worker_thread, &core_ids[i]);
    }
    /* 커널이 SO_REUSEPORT 해시로 연결을 Worker에 분배 */
    for (int i = 0; i < ncpus; i++)
        pthread_join(threads[i], NULL);
    return 0;
}

MSG_RING을 이용한 연결 분배

/* Accept 스레드: 새 연결을 worker에게 MSG_RING으로 전달 */
static void distribute_connection(struct io_uring *accept_ring,
                                  int client_fd)
{
    static int worker_idx = 0;
    int target = worker_idx++ % num_workers;

    struct io_uring_sqe *sqe = io_uring_get_sqe(accept_ring);
    io_uring_prep_msg_ring(sqe, worker_ring_fds[target],
                           client_fd,                    /* data */
                           encode_user_data(OP_NEW_CONN, client_fd),
                           0);
    sqe->user_data = encode_user_data(OP_MSG_RING, target);
}

/* Worker 스레드: MSG_RING으로 받은 새 연결 처리 */
static void handle_new_conn(struct io_uring *ring,
                            struct io_uring_cqe *cqe)
{
    int client_fd = cqe->res;
    /* 멀티샷 recv + 제공 버퍼 설정 */
    arm_multishot_recv(ring, client_fd);
}

SQE 링크와 네트워크 파이프라인

SQE 링크(Link)는 여러 네트워크 연산을 순서대로 연쇄 실행할 수 있게 합니다. 네트워크 서버에서 자주 사용되는 링크 패턴을 정리합니다.

/* 패턴 1: SOCKET → BIND → LISTEN → ACCEPT 서버 초기화 체인 */
struct io_uring_sqe *sqe;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_socket_direct(sqe, AF_INET, SOCK_STREAM | SOCK_NONBLOCK,
                            0, IORING_FILE_INDEX_ALLOC, 0);
sqe->flags |= IOSQE_IO_LINK;
sqe->user_data = make_user_data(OP_SOCKET, 0);

sqe = io_uring_get_sqe(&ring);
io_uring_prep_bind(sqe, 0, (struct sockaddr *)&addr, sizeof(addr));
sqe->flags |= IOSQE_IO_LINK | IOSQE_FIXED_FILE | IOSQE_CQE_SKIP_SUCCESS;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_listen(sqe, 0, SOMAXCONN);
sqe->flags |= IOSQE_IO_LINK | IOSQE_FIXED_FILE | IOSQE_CQE_SKIP_SUCCESS;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept_direct(sqe, 0, NULL, NULL, SOCK_NONBLOCK);
sqe->flags |= IOSQE_FIXED_FILE;
/* SOCKET → BIND → LISTEN → ACCEPT가 순서대로 실행
 * 중간 단계는 CQE_SKIP_SUCCESS로 CQE 생략
 * 실패 시 체인이 중단되고 나머지 SQE는 -ECANCELED */

/* 패턴 2: RECV → 처리 → SEND 응답 파이프라인 */
/* 주의: recv/send는 보통 링크하지 않음
 * (recv 완료 후 데이터를 파싱해야 send 내용을 결정하므로)
 * 대신 CQE 기반 이벤트 루프에서 처리 */

/* 패턴 3: SEND + CLOSE 깨끗한 종료 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, fd, "HTTP/1.1 400 Bad Request\r\n\r\n", 28, 0);
sqe->flags |= IOSQE_IO_LINK;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_shutdown(sqe, fd, SHUT_RDWR);
sqe->flags |= IOSQE_IO_LINK;

sqe = io_uring_get_sqe(&ring);
io_uring_prep_close(sqe, fd);
/* 에러 응답 → 셧다운 → 클로즈 순서 보장 */

/* 패턴 4: CONNECT + LINK_TIMEOUT 프록시 백엔드 연결 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_connect(sqe, backend_fd, backend_addr, addrlen);
sqe->flags |= IOSQE_IO_LINK;

sqe = io_uring_get_sqe(&ring);
struct __kernel_timespec timeout = { .tv_sec = 3 };
io_uring_prep_link_timeout(sqe, &timeout, 0);
/* 3초 내 백엔드 연결 실패 시 → 자동 취소 */
/* 링크 체인 오류 시 CQE 패턴 */
/*
 * 성공 시: [SOCKET CQE(ok)] → [BIND CQE(skip)] → [LISTEN CQE(skip)] → [ACCEPT CQE(ok)]
 * BIND 실패 시: [SOCKET CQE(ok)] → [BIND CQE(err)] → [LISTEN CQE(-ECANCELED)] → [ACCEPT CQE(-ECANCELED)]
 *
 * 주의: CQE_SKIP_SUCCESS 사용 시:
 *   성공 CQE는 생략되지만, 실패 CQE는 항상 생성됨
 *   후속 SQE의 -ECANCELED CQE도 항상 생성됨
 */

/* 링크 오류 처리 코드 */
if (cqe->res == -ECANCELED) {
    /* 이전 링크 SQE 실패로 취소됨 */
    /* 정리 작업 수행 (할당된 리소스 해제 등) */
    int op = get_op(cqe->user_data);
    if (op == OP_ACCEPT) {
        /* SOCKET이나 BIND 실패 → 서버 시작 실패 */
        fprintf(stderr, "Server init chain failed\n");
    }
}
CQE_SKIP_SUCCESS와 멀티샷: IOSQE_CQE_SKIP_SUCCESS는 멀티샷 SQE에 사용하면 안 됩니다. 멀티샷은 성공할 때마다 CQE를 생성하는 것이 핵심이므로, 성공 CQE를 생략하면 데이터를 수신할 수 없습니다.
링크와 멀티샷 공존: 링크 체인의 마지막 SQE가 멀티샷이면, 체인이 완료된 후 멀티샷이 시작됩니다. 예를 들어 SOCKET → BIND → LISTEN → multishot_accept 체인에서, LISTEN이 완료되면 멀티샷 accept가 시작되어 계속 연결을 수락합니다.

실전 예제: TCP Echo 서버

멀티샷 accept + 제공 버퍼 링 + 멀티샷 recv를 결합한 완전한 TCP echo 서버 예제입니다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <liburing.h>

#define PORT            8080
#define SQ_DEPTH        2048
#define BUF_COUNT       4096
#define BUF_SIZE        4096
#define BUF_GROUP       0

enum { OP_ACCEPT, OP_RECV, OP_SEND, OP_CLOSE };

static struct io_uring ring;
static struct io_uring_buf_ring *buf_ring;
static char *buffers;

static uint64_t make_user_data(int op, int fd)
{
    return ((uint64_t)op << 32) | (uint32_t)fd;
}

static int get_op(uint64_t ud) { return ud >> 32; }
static int get_fd(uint64_t ud) { return (int)(ud & 0xFFFFFFFF); }

static void setup_buffers(void)
{
    int ret;
    buf_ring = io_uring_setup_buf_ring(&ring, BUF_COUNT,
                                        BUF_GROUP, 0, &ret);
    buffers = malloc(BUF_COUNT * BUF_SIZE);
    for (int i = 0; i < BUF_COUNT; i++) {
        io_uring_buf_ring_add(buf_ring, buffers + i * BUF_SIZE,
                              BUF_SIZE, i,
                              io_uring_buf_ring_mask(BUF_COUNT), i);
    }
    io_uring_buf_ring_advance(buf_ring, BUF_COUNT);
}

static void arm_accept(int listen_fd)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_multishot_accept(sqe, listen_fd,
                                   NULL, NULL, SOCK_NONBLOCK);
    sqe->user_data = make_user_data(OP_ACCEPT, listen_fd);
}

static void arm_recv(int fd)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_recv_multishot(sqe, fd, NULL, 0, 0);
    sqe->flags |= IOSQE_BUFFER_SELECT;
    sqe->buf_group = BUF_GROUP;
    sqe->user_data = make_user_data(OP_RECV, fd);
}

static void arm_send(int fd, void *data, int len)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_send(sqe, fd, data, len, 0);
    sqe->user_data = make_user_data(OP_SEND, fd);
}

static void return_buffer(int buf_id)
{
    io_uring_buf_ring_add(buf_ring, buffers + buf_id * BUF_SIZE,
                          BUF_SIZE, buf_id,
                          io_uring_buf_ring_mask(BUF_COUNT), 0);
    io_uring_buf_ring_advance(buf_ring, 1);
}

int main(void)
{
    /* io_uring 초기화 */
    struct io_uring_params params = {
        .flags = IORING_SETUP_SINGLE_ISSUER |
                 IORING_SETUP_DEFER_TASKRUN |
                 IORING_SETUP_COOP_TASKRUN,
    };
    io_uring_queue_init_params(SQ_DEPTH, &ring, ¶ms);
    setup_buffers();

    /* 리스닝 소켓 */
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = INADDR_ANY,
    };
    bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listen_fd, SOMAXCONN);

    /* 멀티샷 accept 등록 */
    arm_accept(listen_fd);

    /* 이벤트 루프 */
    while (1) {
        io_uring_submit_and_wait(&ring, 1);

        struct io_uring_cqe *cqe;
        unsigned head;
        int count = 0;

        io_uring_for_each_cqe(&ring, head, cqe) {
            int op = get_op(cqe->user_data);
            int fd = get_fd(cqe->user_data);

            switch (op) {
            case OP_ACCEPT:
                if (cqe->res >= 0) {
                    arm_recv(cqe->res);
                }
                if (!(cqe->flags & IORING_CQE_F_MORE))
                    arm_accept(fd);
                break;

            case OP_RECV:
                if (cqe->res > 0 && (cqe->flags & IORING_CQE_F_BUFFER)) {
                    int bid = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
                    /* Echo: 수신 데이터를 그대로 송신 */
                    arm_send(fd, buffers + bid * BUF_SIZE, cqe->res);
                    /* 주의: send 완료 후 버퍼 반환해야 함 */
                } else if (cqe->res <= 0) {
                    close(fd);
                }
                if (!(cqe->flags & IORING_CQE_F_MORE) && cqe->res > 0)
                    arm_recv(fd);
                break;

            case OP_SEND:
                /* send 완료 → 해당 버퍼 반환 */
                break;
            }
            count++;
        }
        io_uring_cq_advance(&ring, count);
    }
    return 0;
}

실전 예제: HTTP 파이프라인 서버

HTTP/1.1 keep-alive 연결에서 여러 요청을 파이프라인으로 처리하는 서버 패턴입니다. 제공 버퍼 링으로 요청을 수신하고, SEND_ZC로 응답을 전송합니다. 아래는 최소한의 HTTP/1.1 서버 구현으로, 실제 프로덕션에서는 더 견고한 파싱과 오류 처리가 필요합니다.

서버 구조

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

#define MAX_CONNS       10000
#define HTTP_BUF_SIZE   8192
#define RESP_BUF_SIZE   65536

enum http_state {
    HTTP_READING_REQUEST,
    HTTP_SENDING_RESPONSE,
    HTTP_IDLE,            /* keep-alive 대기 */
};

struct http_conn {
    int fd;
    enum http_state state;
    char req_buf[HTTP_BUF_SIZE];
    int req_len;
    char resp_buf[RESP_BUF_SIZE];
    int resp_len;
    bool keep_alive;
    int pipeline_depth;     /* 현재 파이프라인 깊이 */
};

/* 간단한 HTTP 요청 파서 */
static bool parse_http_request(struct http_conn *conn,
                                char **method, char **path)
{
    /* \r\n\r\n으로 헤더 끝 검출 */
    char *end = memmem(conn->req_buf, conn->req_len,
                       "\r\n\r\n", 4);
    if (!end) return false;  /* 아직 불완전 */

    /* 메서드(Method)와 경로 추출 */
    *method = conn->req_buf;
    char *sp1 = strchr(conn->req_buf, ' ');
    if (!sp1) return false;
    *sp1 = '\0';
    *path = sp1 + 1;
    char *sp2 = strchr(*path, ' ');
    if (sp2) *sp2 = '\0';

    /* Connection: keep-alive / close 파싱 */
    conn->keep_alive = !strcasestr(conn->req_buf,
                                   "Connection: close");
    return true;
}

/* HTTP 응답 생성 */
static int generate_http_response(struct http_conn *conn,
                                   const char *body, int body_len)
{
    int hdr_len = snprintf(conn->resp_buf, RESP_BUF_SIZE,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/plain\r\n"
        "Content-Length: %d\r\n"
        "Connection: %s\r\n"
        "\r\n",
        body_len,
        conn->keep_alive ? "keep-alive" : "close");
    memcpy(conn->resp_buf + hdr_len, body, body_len);
    conn->resp_len = hdr_len + body_len;
    return conn->resp_len;
}

수신-파싱-응답 흐름

/* HTTP 연결 상태 관리 */
struct http_conn {
    int fd;
    int state;               /* READING_HEADER, SENDING_RESPONSE */
    char header_buf[8192];   /* 헤더 파싱 버퍼 */
    int header_len;
    int keep_alive;
};

static void handle_http_recv(struct io_uring *ring,
                              struct io_uring_cqe *cqe)
{
    int fd = get_fd(cqe->user_data);
    int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    char *data = buffers + buf_id * BUF_SIZE;
    int len = cqe->res;

    /* HTTP 요청 파싱 */
    struct http_conn *conn = get_conn(fd);
    memcpy(conn->header_buf + conn->header_len, data, len);
    conn->header_len += len;

    /* 헤더 완료 확인 (\r\n\r\n) */
    if (memmem(conn->header_buf, conn->header_len,
              "\r\n\r\n", 4)) {
        /* 응답 생성 */
        char *resp = generate_response(conn);
        int resp_len = strlen(resp);

        /* SEND_ZC로 응답 전송 (대용량 파일 응답 시 유리) */
        struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
        if (resp_len > 4096) {
            io_uring_prep_send_zc(sqe, fd, resp, resp_len, 0, 0);
        } else {
            io_uring_prep_send(sqe, fd, resp, resp_len, 0);
        }
        sqe->user_data = make_user_data(OP_SEND, fd);

        conn->header_len = 0;  /* 파이프라인: 다음 요청 대비 */
    }

    /* 버퍼 반환 */
    return_buffer(buf_id);
}
Keep-alive 관리: HTTP/1.1 keep-alive 연결에서는 멀티샷 recv가 계속 활성이므로 별도의 연결 재등록이 필요 없습니다. 타임아웃(Timeout) 관리를 위해 TIMEOUT SQE를 연결별로 등록하면 유휴 연결을 자동 정리할 수 있습니다.

실전 예제: 프록시/로드밸런서

io_uring 기반 프록시(Proxy) 서버는 splice 연산을 활용하여 클라이언트와 백엔드(Backend) 간 데이터를 제로카피로 전달합니다. L4 프록시 / L7 로드밸런서(Load Balancer) 패턴을 모두 다룹니다.

프록시 아키텍처

L4 프록시: splice 기반 제로카피 전달 Clients Client A Client B Client C io_uring Proxy Accept Ring multishot accept Connection Pair client_fd ↔ backend_fd Pipe Pool splice용 파이프 Splice SQE 양방향 전달 Health Check TIMEOUT SQE + CONNECT SQE 주기적 백엔드 검사 데이터: splice (제로카피) | 제어: MSG_RING Backends Backend 1 Backend 2 Backend 3 (down) TCP splice
L4 프록시 아키텍처: 클라이언트 연결을 accept하고, 백엔드에 connect한 뒤, splice로 양방향 데이터를 제로카피 전달합니다.

연결 쌍(Connection Pair) 관리

/* 프록시 연결 쌍 관리 */
struct proxy_pair {
    int client_fd;
    int backend_fd;
    int pipe_fds[2];  /* splice용 파이프 */
};

/* splice 기반 제로카피 전달 */
static void setup_splice_forward(struct io_uring *ring,
                                  struct proxy_pair *pair)
{
    /* client_fd → pipe → backend_fd (제로카피) */
    struct io_uring_sqe *sqe;

    /* 1단계: client → pipe (splice read) */
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_splice(sqe, pair->client_fd, -1,
                         pair->pipe_fds[1], -1,
                         65536, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    sqe->flags |= IOSQE_IO_LINK;  /* 다음 SQE와 연결 */
    sqe->user_data = make_user_data(OP_SPLICE_READ, pair->client_fd);

    /* 2단계: pipe → backend (splice write) — 링크로 연쇄 */
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_splice(sqe, pair->pipe_fds[0], -1,
                         pair->backend_fd, -1,
                         65536, SPLICE_F_MOVE | SPLICE_F_NONBLOCK);
    sqe->user_data = make_user_data(OP_SPLICE_WRITE, pair->backend_fd);
}

/* 로드밸런싱: 라운드로빈 백엔드 선택 */
static int select_backend(void)
{
    static int idx = 0;
    int backend_fd = connect_to_backend(backends[idx % num_backends]);
    idx++;
    return backend_fd;
}

/* 헬스 체크: 주기적으로 백엔드 상태 확인 */
static void arm_health_check(struct io_uring *ring)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct __kernel_timespec ts = { .tv_sec = 5 };
    io_uring_prep_timeout(sqe, &ts, 0, 0);
    sqe->user_data = make_user_data(OP_HEALTH_CHECK, 0);
}

실전 예제: UDP 고성능 처리

UDP 워크로드에서는 RECVMSG 멀티샷이 특히 유용합니다. 각 데이터그램(Datagram)의 발신자 주소가 필요하므로 recvmsg 형태가 필수입니다.

/* UDP 멀티샷 수신 설정 */
struct msghdr msg = {};
struct iovec iov = { .iov_base = NULL, .iov_len = BUF_SIZE };
char control[CMSG_SPACE(sizeof(struct in_pktinfo))];

msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_name = &peer_addr;
msg.msg_namelen = sizeof(peer_addr);
msg.msg_control = control;
msg.msg_controllen = sizeof(control);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recvmsg_multishot(sqe, udp_fd, &msg, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = BUF_GROUP;

/* CQE 처리: 각 데이터그램의 주소와 데이터 추출 */
static void handle_udp_recv(struct io_uring_cqe *cqe)
{
    int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    void *buf = buffers + buf_id * BUF_SIZE;

    struct io_uring_recvmsg_out *o =
        io_uring_recvmsg_validate(buf, cqe->res, &msg);
    if (!o) return;

    /* 발신자 주소 */
    struct sockaddr_in *from =
        io_uring_recvmsg_name(o);

    /* 데이터 페이로드 */
    void *payload = io_uring_recvmsg_payload(o, &msg);
    unsigned int payload_len =
        io_uring_recvmsg_payload_length(o, cqe->res, &msg);

    /* DNS 서버, 게임 서버 등: 요청 처리 + 응답 */
    process_datagram(from, payload, payload_len);

    return_buffer(buf_id);
}

/* sendmsg 배칭: 여러 응답을 한 번에 제출 */
static void batch_sendmsg(struct io_uring *ring,
                           struct udp_response *resps, int count)
{
    for (int i = 0; i < count; i++) {
        struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
        io_uring_prep_sendmsg(sqe, udp_fd, &resps[i].msg, 0);
        sqe->user_data = make_user_data(OP_SEND, i);
    }
    /* 한 번의 submit으로 모든 응답 전송 */
}

DNS 서버 패턴

DNS 서버는 전형적인 UDP 요청-응답 워크로드입니다. io_uring의 멀티샷 recvmsg와 sendmsg 배칭이 이상적입니다.

/* DNS 서버 패턴: 멀티샷 recvmsg + 응답 배칭 */
#define DNS_BUF_SIZE    512    /* DNS 표준 UDP 크기 */
#define DNS_BUF_COUNT   16384  /* 대량 동시 쿼리 처리 */

struct dns_query {
    struct sockaddr_in from;
    uint16_t query_id;
    char qname[256];
    uint16_t qtype;
};

/* DNS 쿼리 수신 + 응답 생성 + 배치 전송 */
static void handle_dns_recv(struct io_uring *ring,
                             struct io_uring_cqe *cqe)
{
    int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    void *buf = buffers + buf_id * DNS_BUF_SIZE;

    struct io_uring_recvmsg_out *o =
        io_uring_recvmsg_validate(buf, cqe->res, &msg);
    if (!o) goto done;

    /* DNS 쿼리 파싱 */
    struct sockaddr_in *from = io_uring_recvmsg_name(o);
    void *payload = io_uring_recvmsg_payload(o, &msg);
    unsigned int plen = io_uring_recvmsg_payload_length(
        o, cqe->res, &msg);

    /* DNS 응답 생성 */
    char resp[512];
    int resp_len = dns_build_response(payload, plen, resp);

    /* sendmsg로 응답 전송 */
    struct msghdr reply_msg = {};
    struct iovec iov = { .iov_base = resp, .iov_len = resp_len };
    reply_msg.msg_name = from;
    reply_msg.msg_namelen = sizeof(*from);
    reply_msg.msg_iov = &iov;
    reply_msg.msg_iovlen = 1;

    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_sendmsg(sqe, udp_fd, &reply_msg, 0);
    sqe->user_data = make_user_data(OP_SEND, 0);

done:
    return_buffer(buf_id);
}

게임 서버 패턴

실시간(Real-time) 게임 서버는 작은 패킷(20~200바이트)을 초당 수만~수십만 건 처리합니다. io_uring의 NAPI busy-poll과 결합하면 마이크로초 단위 지연을 달성합니다.

/* 게임 서버: 작은 패킷 고빈도 처리 */
#define GAME_PKT_SIZE  256    /* 최대 게임 패킷 크기 */
#define TICK_RATE_MS   16     /* 60 FPS 틱 */

struct game_server {
    struct io_uring ring;
    int udp_fd;
    struct player players[MAX_PLAYERS];
    int active_players;
    struct __kernel_timespec tick_interval;
};

/* 게임 루프: TIMEOUT으로 틱 타이머 관리 */
static void game_loop(struct game_server *gs)
{
    /* 틱 타이머 등록 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(&gs->ring);
    gs->tick_interval.tv_nsec = TICK_RATE_MS * 1000000L;
    io_uring_prep_timeout(sqe, &gs->tick_interval, 0,
                          IORING_TIMEOUT_MULTISHOT);
    sqe->user_data = make_user_data(OP_TICK, 0);

    /* 멀티샷 recvmsg 등록 */
    sqe = io_uring_get_sqe(&gs->ring);
    io_uring_prep_recvmsg_multishot(sqe, gs->udp_fd, &msg, 0);
    sqe->flags |= IOSQE_BUFFER_SELECT;
    sqe->buf_group = BUF_GROUP;
    sqe->user_data = make_user_data(OP_RECV, gs->udp_fd);

    while (1) {
        io_uring_submit_and_wait(&gs->ring, 1);
        /* CQE 처리: OP_RECV → 플레이어 입력 수신
         *            OP_TICK → 게임 상태 업데이트 + 브로드캐스트
         *            OP_SEND → 상태 전송 완료 */
        process_game_cqes(gs);
    }
}

타임아웃과 취소 연산

네트워크 서버에서 연결 타임아웃 관리와 진행 중인 연산 취소는 필수적입니다. io_uring은 TIMEOUT과 ASYNC_CANCEL opcode를 통해 이를 통합적으로 처리합니다.

연결 타임아웃

/* 연결별 유휴 타임아웃 관리 */
#define IDLE_TIMEOUT_SEC  30

static void arm_idle_timeout(struct io_uring *ring, int fd)
{
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct __kernel_timespec ts = {
        .tv_sec = IDLE_TIMEOUT_SEC,
        .tv_nsec = 0,
    };
    io_uring_prep_timeout(sqe, &ts, 0, 0);
    sqe->user_data = make_user_data(OP_TIMEOUT, fd);
}

/* 타임아웃 CQE 처리 */
static void handle_timeout(struct io_uring *ring,
                            struct io_uring_cqe *cqe)
{
    int fd = get_fd(cqe->user_data);

    if (cqe->res == -ETIME) {
        /* 타임아웃 발생: 유휴 연결 종료 */
        fprintf(stderr, "Connection %d timed out\n", fd);

        /* 진행 중인 recv 취소 */
        struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
        io_uring_prep_cancel64(sqe,
            make_user_data(OP_RECV, fd), 0);

        /* 소켓 종료 */
        sqe = io_uring_get_sqe(ring);
        io_uring_prep_close(sqe, fd);
    } else if (cqe->res == -ECANCELED) {
        /* 타임아웃이 취소됨 (데이터 수신으로 갱신) */
    }
}

/* 타임아웃 갱신: 데이터 수신 시 기존 타임아웃 취소 후 재등록 */
static void refresh_timeout(struct io_uring *ring, int fd)
{
    /* 기존 타임아웃 취소 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_cancel64(sqe,
        make_user_data(OP_TIMEOUT, fd), 0);

    /* 새 타임아웃 등록 */
    arm_idle_timeout(ring, fd);
}

링크드 타임아웃(Linked Timeout)

SQE 링크를 활용하면 특정 연산에 타임아웃을 직접 부착할 수 있습니다. 연결 시도(connect)에 타임아웃을 설정하는 예시입니다.

/* CONNECT + LINK_TIMEOUT: 연결 타임아웃 */
struct io_uring_sqe *sqe;

/* 1. CONNECT SQE (링크 설정) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_connect(sqe, sockfd,
                      (struct sockaddr *)&backend_addr,
                      sizeof(backend_addr));
sqe->flags |= IOSQE_IO_LINK;  /* 다음 SQE와 링크 */
sqe->user_data = make_user_data(OP_CONNECT, sockfd);

/* 2. LINK_TIMEOUT SQE: CONNECT가 5초 내 완료되지 않으면 취소 */
sqe = io_uring_get_sqe(&ring);
struct __kernel_timespec ts = { .tv_sec = 5 };
io_uring_prep_link_timeout(sqe, &ts, 0);
sqe->user_data = make_user_data(OP_LINK_TIMEOUT, sockfd);

/* 결과:
 * - 5초 내 연결 성공: CONNECT CQE(성공) + TIMEOUT CQE(ECANCELED)
 * - 5초 초과: CONNECT CQE(ECANCELED) + TIMEOUT CQE(ETIME) */

진행 중인 연산 일괄 취소

/* 특정 fd의 모든 진행 중인 연산 취소 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_cancel_fd(sqe, fd, IORING_ASYNC_CANCEL_ALL);
sqe->user_data = make_user_data(OP_CANCEL, fd);
/* 해당 fd에 등록된 멀티샷 recv, 타임아웃 등 모두 취소 */

/* 모든 진행 중인 연산 취소 (서버 종료 시) */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_cancel64(sqe, 0, IORING_ASYNC_CANCEL_ANY |
                                IORING_ASYNC_CANCEL_ALL);

Bundle Send/Recv (v6.14+)

번들(Bundle) 연산은 Linux 6.14에서 안정화된 기능으로, 여러 개의 send 또는 recv 연산을 하나의 SQE로 묶어 처리합니다. 이는 멀티샷과 다른 접근 방식입니다.

번들의 개념

멀티샷이 "하나의 SQE가 여러 CQE를 생성"하는 것이라면, 번들은 "하나의 SQE가 여러 버퍼의 데이터를 한 번에 처리"하는 것입니다. 특히 bundle send는 제공 버퍼 링에 준비된 여러 버퍼의 데이터를 단일 시스템 콜로 전송합니다.

/* Bundle Send: 여러 버퍼를 한 번에 전송 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_bundle(sqe, sockfd, 0);
sqe->buf_group = SEND_BUF_GROUP;
/* 제공 버퍼 링에 미리 추가해 둔 여러 버퍼가 한 번에 전송됨 */

/* Bundle Recv: 여러 버퍼에 데이터 분산 수신 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_bundle(sqe, sockfd, 0);
sqe->buf_group = RECV_BUF_GROUP;
/* CQE에서 사용된 버퍼 수와 각 버퍼의 데이터 길이 확인 */

/* 번들 완료 처리 */
if (cqe->res > 0) {
    /* res: 총 전송/수신 바이트
     * IORING_CQE_F_BUF_MORE: 추가 버퍼가 사용됨
     * 각 버퍼의 실제 사용량은 순서대로 확인 */
    int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
    printf("Bundle: %d bytes, first buf=%d\n", cqe->res, buf_id);
}
번들 vs 멀티샷: 멀티샷은 이벤트 기반(새 연결, 새 데이터 도착 시마다 CQE)이고, 번들은 배치 기반(준비된 데이터를 한 번에 처리)입니다. TCP 스트림에서는 멀티샷이 더 자연스럽고, 대량의 소규모 메시지 배치 전송에는 번들이 효율적입니다.

번들 전송 예제

/* 번들 전송: 여러 작은 메시지를 하나의 SQE로 전송 */
#define SEND_BUF_GROUP   2

/* 1. 전송용 제공 버퍼 링 설정 */
struct io_uring_buf_ring *send_br;
send_br = io_uring_setup_buf_ring(&ring, 256, SEND_BUF_GROUP,
                                   0, &ret);

/* 2. 전송할 데이터를 버퍼 링에 추가 */
for (int i = 0; i < msg_count; i++) {
    io_uring_buf_ring_add(send_br, messages[i].data,
                          messages[i].len, i,
                          io_uring_buf_ring_mask(256), i);
}
io_uring_buf_ring_advance(send_br, msg_count);

/* 3. 번들 전송 SQE 제출 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_bundle(sqe, sockfd, 0);
sqe->buf_group = SEND_BUF_GROUP;

/* 4. CQE: 총 전송 바이트 + 사용된 버퍼 수 */
/* 한 번의 시스템 콜로 여러 메시지가 전송됨
 * TCP 스택에서 최적화된 세그먼테이션 적용 */

번들 수신 예제

/* 번들 수신: 여러 버퍼에 분산 수신 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_bundle(sqe, sockfd, 0);
sqe->buf_group = RECV_BUF_GROUP;

/* CQE 처리 */
if (cqe->res > 0) {
    int total_bytes = cqe->res;
    int first_buf = cqe->flags >> IORING_CQE_BUFFER_SHIFT;

    /* 번들에서 사용된 여러 버퍼를 순회 */
    int remaining = total_bytes;
    int buf_id = first_buf;
    while (remaining > 0) {
        int chunk = (remaining > BUF_SIZE) ? BUF_SIZE : remaining;
        process_chunk(buffers + buf_id * BUF_SIZE, chunk);
        remaining -= chunk;
        buf_id = (buf_id + 1) % BUF_RING_SIZE;
    }
}

번들 활용 시나리오

시나리오멀티샷번들권장
TCP 서버 데이터 수신적합 (이벤트 기반)비효율적멀티샷
대량 로그 메시지 전송가능하지만 SQE 다수 필요적합 (배치 전송)번들
브로드캐스트 전송불가적합번들
UDP 다중 데이터그램 수신적합 (recvmsg multishot)가능멀티샷
파이프라인 응답 전송불가적합번들

Direct Descriptors와 네트워크

직접 디스크립터(Direct Descriptor)는 io_uring의 고정 파일 테이블(Fixed File Table)에 파일 디스크립터(File Descriptor)를 등록하여 fdget()/fdput() 오버헤드를 제거하는 기능입니다. 네트워크 워크로드에서는 연결당 fd 조회 비용이 누적되므로 상당한 성능 향상을 가져옵니다.

/* 고정 파일 테이블 초기화 */
int file_table_size = 4096;
io_uring_register_files_sparse(&ring, file_table_size);

/* 멀티샷 accept + Direct Descriptor
 * accept된 fd가 자동으로 고정 파일 테이블에 등록됨 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept_direct(sqe, listen_fd,
                                       NULL, NULL, SOCK_NONBLOCK);
/* CQE.res: 고정 파일 테이블 인덱스 (일반 fd가 아님) */

/* Direct Descriptor로 recv/send */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fixed_idx, buf, len, 0);
sqe->flags |= IOSQE_FIXED_FILE;  /* fixed_idx는 fd가 아닌 테이블 인덱스 */

/* 연결 종료: 고정 파일 테이블에서 제거 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_close_direct(sqe, fixed_idx);
성능 영향: Direct Descriptor는 연결당 fdget()/fdput() 호출(RCU 읽기 잠금(Lock) + 원자적 참조 카운팅(Atomic Reference Counting))을 제거합니다. 수만 연결 환경에서 약 5~10%의 처리량 향상을 관찰할 수 있습니다.

고정 파일 테이블 관리

/* 고정 파일 테이블 초기화 (sparse 모드) */
#define MAX_FIXED_FILES  16384

int ret = io_uring_register_files_sparse(&ring, MAX_FIXED_FILES);
if (ret < 0) {
    fprintf(stderr, "register_files_sparse: %s\n", strerror(-ret));
    return 1;
}

/* 리스닝 소켓을 슬롯 0에 수동 등록 */
io_uring_register_files_update(&ring, 0, &listen_fd, 1);

/* 멀티샷 accept_direct: 새 연결이 빈 슬롯에 자동 할당 */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept_direct(sqe, 0,
                                       NULL, NULL, SOCK_NONBLOCK);
sqe->flags |= IOSQE_FIXED_FILE;
/* CQE.res = 할당된 고정 파일 인덱스 (fd가 아님!) */

/* recv/send 시: IOSQE_FIXED_FILE 플래그 필수 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, fixed_index, buf, len, 0);
sqe->flags |= IOSQE_FIXED_FILE;

/* 연결 종료: close_direct로 슬롯 해제 */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_close_direct(sqe, fixed_index);
/* 주의: 일반 close()를 사용하면 안 됨! */

일반 fd vs Direct Descriptor 비교

특성일반 fdDirect Descriptor
시스템 콜 호환성모든 시스템 콜에서 사용 가능io_uring SQE에서만 사용
fork/exec 전달상속됨상속 불가
fdget/fdput 비용매 I/O마다 RCU + atomic없음 (직접 참조)
파일 테이블 확장동적 확장등록 시 크기 고정
epoll 호환가능불가
최적 사용처일반적 용도io_uring 전용 고성능 서버
주의: Direct Descriptor를 사용하면 해당 소켓에 대해 read(), write(), epoll_ctl() 등 일반 시스템 콜을 사용할 수 없습니다. 디버깅(Debugging)이나 로깅(Logging)을 위해 일반 fd가 필요한 경우 FIXED_FD_INSTALL opcode로 일반 fd를 발급받을 수 있습니다(Linux 6.12+).

SQPOLL + 네트워크 워크로드

SQPOLL 모드에서는 커널 스레드(Kernel Thread)가 SQ를 지속적으로 폴링하므로, 사용자 공간에서 io_uring_enter() 시스템 콜 없이 SQE를 제출할 수 있습니다. 네트워크 워크로드에서 이는 완전한 시스템 콜 제거를 의미합니다.

/* SQPOLL 네트워크 서버 설정 */
struct io_uring_params params = {
    .flags = IORING_SETUP_SQPOLL |
             IORING_SETUP_SQ_AFF,  /* CPU affinity 지정 */
    .sq_thread_idle = 1000,        /* 1초 유휴 후 sleep */
    .sq_thread_cpu = 3,            /* CPU 3에 고정 */
};
io_uring_queue_init_params(4096, &ring, ¶ms);

/* SQPOLL에서는 고정 파일 등록 필수 (보안 요구사항) */
int fds[] = { listen_fd };
io_uring_register_files(&ring, fds, 1);

/* 이벤트 루프: submit 불필요 — 커널 스레드가 자동 폴링 */
while (1) {
    /* SQE를 준비하면 커널 스레드가 자동으로 감지하여 처리 */
    io_uring_get_sqe(&ring);
    /* ... SQE 준비 ... */

    /* CQE 대기만 수행 (시스템 콜 발생하지만
     * DEFER_TASKRUN과 조합하면 최적) */
    io_uring_wait_cqe(&ring, &cqe);
    process_cqe(cqe);
    io_uring_cqe_seen(&ring, cqe);
}

SQPOLL vs DEFER_TASKRUN 비교

특성SQPOLLDEFER_TASKRUN
시스템 콜 제거SQE 제출 시 완전 제거제출 시 필요하지만 task_work를 지연
CPU 비용전용 커널 스레드가 CPU 소비추가 CPU 비용 없음
최적 시나리오항상 SQE가 대기 중인 고부하 서버간헐적 I/O, 이벤트 루프 기반
지연 시간매우 낮음 (커널 스레드 즉시 감지)낮음 (enter() 호출 시 처리)
멀티코어 활용SQ 처리를 별도 코어에 분리동일 코어에서 처리
권장 조합: 대부분의 네트워크 서버에서는 SINGLE_ISSUER + DEFER_TASKRUN + COOP_TASKRUN이 최적입니다. SQPOLL은 초저지연이 필수이고 전용 CPU 코어를 확보할 수 있는 경우에만 사용하세요.

SQPOLL 네트워크 서버 완전 예제

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

/* SQPOLL + 고정 파일 + 멀티샷 accept 서버 */
int sqpoll_server(void)
{
    struct io_uring ring;
    struct io_uring_params params = {
        .flags = IORING_SETUP_SQPOLL |
                 IORING_SETUP_SQ_AFF |
                 IORING_SETUP_SINGLE_ISSUER,
        .sq_thread_idle = 2000,    /* 2초 유휴 후 슬립 */
        .sq_thread_cpu = 3,        /* 전용 CPU 코어 */
    };

    io_uring_queue_init_params(4096, &ring, ¶ms);

    /* SQPOLL에서 고정 파일 등록 필수 */
    int listen_fd = create_listen_socket(8080);

    /* 고정 파일 테이블: listen_fd + 최대 연결 수 */
    io_uring_register_files_sparse(&ring, 8192);
    io_uring_register_files_update(&ring, 0, &listen_fd, 1);

    /* 멀티샷 accept (고정 파일 인덱스 0 = listen_fd) */
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_multishot_accept_direct(sqe, 0,
                                           NULL, NULL,
                                           SOCK_NONBLOCK);
    sqe->flags |= IOSQE_FIXED_FILE;

    /* SQPOLL이므로 submit 불필요 — 커널 스레드가 자동 감지 */
    /* 단, 최초 1회는 submit하여 SQPOLL 스레드를 깨움 */
    io_uring_submit(&ring);

    /* 이벤트 루프: CQE 대기만 수행 */
    while (1) {
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);

        /* CQE 처리 ... */
        /* 새 SQE를 준비하면 SQPOLL 스레드가 자동 제출 */

        io_uring_cqe_seen(&ring, cqe);
    }
}

/* SQPOLL 스레드 상태 모니터링 */
static bool is_sqpoll_running(struct io_uring *ring)
{
    /* SQ 플래그 확인: SQPOLL 스레드 슬립 여부 */
    unsigned flags = io_uring_sq_ready(ring);
    return !(io_uring_sq_space_left(ring) == 0);
    /* IORING_SQ_NEED_WAKEUP 플래그:
     * 설정되면 SQPOLL 스레드가 유휴 슬립 중
     * io_uring_enter(IORING_ENTER_SQ_WAKEUP)으로 깨울 수 있음 */
}

SQPOLL 모니터링과 튜닝

# SQPOLL 스레드 확인
ps -eLf | grep iou-sqp
# 각 io_uring 인스턴스마다 SQPOLL 스레드 1개 생성

# SQPOLL 스레드 CPU 사용률 확인
top -H -p $(pidof my_server)
# iou-sqp-XXX 스레드의 CPU%가 100%에 가까우면 정상 (능동 폴링)

# sq_thread_idle 조정: 트래픽 패턴에 따라
# - 지속적 고부하: idle=0 (슬립 없이 계속 폴링)
# - 간헐적 부하: idle=1000~5000 (1~5초 후 슬립)
# - 유휴 시 CPU 절약: idle=100~500 (빠른 슬립)

커널 내부 구현: 네트워크 핸들러

io_uring의 네트워크 연산은 io_uring/net.c에 구현되어 있습니다. 각 opcode는 해당하는 소켓 계층 함수를 호출합니다.

io_send() 소스 분석

/* io_uring/net.c — io_send() 핵심 경로 (Linux 6.12 기준) */
int io_send(struct io_kiocb *req, unsigned int issue_flags)
{
    struct io_sr_msg *sr = io_kiocb_to_cmd(req);
    struct msghdr msg = {};
    struct socket *sock;
    int ret, min_ret = 0;

    sock = sock_from_file(req->file);
    if (!sock)
        return -ENOTSOCK;

    /* iovec 설정: 사용자 버퍼 → msghdr */
    msg.msg_flags = sr->msg_flags;
    if (issue_flags & IO_URING_F_NONBLOCK)
        msg.msg_flags |= MSG_DONTWAIT;

    /* 핵심: sock_sendmsg() 호출 → TCP/UDP 스택으로 전달 */
    ret = sock_sendmsg(sock, &msg);

    if (ret == -EAGAIN && (issue_flags & IO_URING_F_NONBLOCK)) {
        /* 비동기 폴백: io-wq로 이관 또는 poll arm */
        if (io_setup_async_msg(req, &msg, issue_flags))
            return -EAGAIN;
    }

    io_req_set_res(req, ret, 0);
    return IOU_OK;
}

io_socket() 소스 분석

/* io_uring/net.c — io_socket() */
int io_socket(struct io_kiocb *req, unsigned int issue_flags)
{
    struct io_socket *sock = io_kiocb_to_cmd(req);
    int ret, fd;

    /* fixed file 모드: io_uring 고정 파일 테이블에 직접 할당 */
    if (req->flags & REQ_F_FIXED_FILE) {
        fd = io_fixed_fd_install(req, issue_flags, ...);
    }

    /* __sys_socket_file()로 소켓 생성 */
    ret = __sys_socket_file(sock->domain, sock->type,
                            sock->protocol);
    if (ret < 0)
        return ret;

    io_req_set_res(req, ret, 0);
    return IOU_OK;
}

io_send_zc() 소스 분석

/* io_uring/net.c — io_send_zc() 제로카피 전송 핵심 경로 */
int io_send_zc(struct io_kiocb *req, unsigned int issue_flags)
{
    struct io_sr_msg *zc = io_kiocb_to_cmd(req);
    struct msghdr msg = {};
    struct socket *sock;
    int ret;

    sock = sock_from_file(req->file);
    if (!sock)
        return -ENOTSOCK;

    /* MSG_ZEROCOPY 플래그 설정 → 커널이 페이지를 pin */
    msg.msg_flags = zc->msg_flags | MSG_ZEROCOPY;
    if (issue_flags & IO_URING_F_NONBLOCK)
        msg.msg_flags |= MSG_DONTWAIT;

    /* 알림 슬롯 설정: 전송 완료 시 NOTIF CQE 생성 */
    msg.msg_ubuf = &zc->notif->uarg;

    /* sock_sendmsg() + MSG_ZEROCOPY:
     * 1. 사용자 페이지 get_user_pages()로 pin
     * 2. skb에 페이지 참조 설정 (복사 없음)
     * 3. TCP 스택에 전달
     * 4. NIC DMA 완료 후 → skb 해제 → ubuf 콜백 → NOTIF CQE */
    ret = sock_sendmsg(sock, &msg);

    if (ret >= 0) {
        /* 1번째 CQE: 전송 제출 완료 */
        io_req_set_res(req, ret, 0);
        /* 2번째 CQE (NOTIF)는 NIC DMA 완료 후 비동기 생성 */
    }
    return IOU_OK;
}

멀티샷 내부 루프

멀티샷 accept/recv의 핵심은 성공한 연산 후 즉시 재시도하는 내부 루프입니다. CQ에 공간이 있는 한 시스템 콜 없이 반복합니다.

/* 멀티샷 recv 내부 루프 의사 코드 */
while (1) {
    /* 1. 제공 버퍼 링에서 버퍼 할당 */
    buf = io_recv_buf_select(req);
    if (!buf)
        break;  /* -ENOBUFS → 멀티샷 종료 */

    /* 2. 소켓에서 데이터 수신 시도 */
    ret = sock_recvmsg(sock, &msg, MSG_DONTWAIT);
    if (ret == -EAGAIN)
        break;  /* 데이터 없음 → poll arm 후 대기 */

    /* 3. CQE 게시 (F_MORE 설정) */
    if (!io_req_post_cqe(req, ret, IORING_CQE_F_MORE | buf_flags))
        break;  /* CQ 가득 참 → 멀티샷 종료 */

    /* 4. 루프 제한 (nr_multishot_loops) 초과 시 중단
     *    → 다른 요청에 기아(starvation) 방지 */
    if (++loops > IO_MULTISHOT_LIMIT)
        break;
}
/* 중단 후: poll arm → 데이터 도착 시 재개 */
멀티샷 루프 제한: 커널은 멀티샷 루프를 무한히 돌리지 않습니다. IO_MULTISHOT_LIMIT(기본 32)에 도달하면 루프를 중단하고 다른 요청을 처리한 뒤, poll을 통해 이벤트가 다시 발생하면 재개합니다. 이는 하나의 소켓이 전체 io_uring을 독점하는 것을 방지합니다.

poll arm 메커니즘

네트워크 연산이 -EAGAIN을 반환하면, io_uring은 해당 소켓에 poll을 등록(arm)합니다. 이벤트(데이터 도착, 연결 가능 등)가 발생하면 poll 콜백(Callback)이 실행되어 연산을 재시도합니다.

/* io_uring poll arm 흐름 */
/* 1. io_recv() → sock_recvmsg() → -EAGAIN */
/* 2. io_arm_poll_handler() 호출 */
/*    → vfs_poll(sock->file, &pt) 로 EPOLLIN 등록 */
/*    → 소켓의 wait queue에 콜백 등록 */
/* 3. 데이터 도착 → TCP 스택 → sk_data_ready() */
/*    → wake_up() → io_uring poll 콜백 */
/*    → io_recv() 재실행 (task_work 또는 io-wq) */

/* DEFER_TASKRUN의 역할:
 * poll 콜백이 task_work로 큐잉됨
 * io_uring_enter() 호출 시 한꺼번에 처리
 * → 불필요한 인터럽트 기반 wake-up 방지 */

호출 체인(Call Chain) 요약

Opcodeio_uring 핸들러소켓 계층 함수비고
ACCEPTio_accept()__sys_accept4_file()멀티샷: retry 루프
CONNECTio_connect()__sys_connect_file()비동기 폴백 지원
SENDio_send()sock_sendmsg()MSG_DONTWAIT 자동 설정
RECVio_recv()sock_recvmsg()멀티샷 + buf_select
SENDMSGio_sendmsg()sock_sendmsg()scatter/gather
RECVMSGio_recvmsg()sock_recvmsg()멀티샷 + ancillary
SEND_ZCio_send_zc()sock_sendmsg() + MSG_ZEROCOPY2-CQE 패턴
SOCKETio_socket()__sys_socket_file()Direct FD 지원
SHUTDOWNio_shutdown()__sys_shutdown()-

성능 벤치마크

아래 벤치마크는 일반적인 서버 환경(AMD EPYC 7763, 100GbE Mellanox ConnectX-6, Linux 6.8)에서 측정한 대표적인 수치입니다. 실제 결과는 하드웨어, 커널 버전, 워크로드 특성에 따라 달라질 수 있습니다.

벤치마크 개요

io_uring 네트워크 성능을 epoll과 비교할 때 3가지 핵심 지표를 측정합니다.

벤치마크 환경: 아래 수치는 AMD EPYC 7763 64코어, 256GB DDR4, Mellanox ConnectX-6 Dx 100GbE, Linux 6.8.0에서 측정한 참고 데이터입니다. 클라이언트와 서버는 100GbE 직결(Direct Connect)로 연결되어 있습니다.

연결 처리 성능 (CPS)

모델CPS (단일 코어)상대 성능
epoll + accept()~120K1.0x (기준)
io_uring accept~150K1.25x
io_uring multishot accept~180K1.50x
io_uring multishot accept + direct FD~200K1.67x

처리량(Throughput)

모델1KB 메시지 (Gbps)64KB 메시지 (Gbps)
epoll + send/recv~35~55
io_uring send/recv~42~65
io_uring + provided buf ring~48~70
io_uring SEND_ZC~40~85
io_uring SEND_ZC + SQPOLL~42~90

지연 시간(Latency)

모델P50 (μs)P99 (μs)P99.9 (μs)
epoll1545120
io_uring DEFER_TASKRUN102560
io_uring SQPOLL51230
io_uring SQPOLL + NAPI busy-poll2510

각 최적화의 영향

최적화CPS 영향처리량 영향지연 시간 영향
Multishot accept+15~20%-미미
Multishot recv-+5~10%미미
Provided buffer ring-+10~15%-5~10%
Direct descriptors+5~10%+3~5%-3~5%
SEND_ZC (64KB)-+30~50%미미
SQPOLL+5~8%+5~10%-30~50%
NAPI busy-poll-미미-60~80%
DEFER_TASKRUN+3~5%+3~5%-20~30%

벤치마크 방법론

정확한 벤치마크를 위해 다음 조건을 통제해야 합니다.

# 1. CPU 주파수 고정 (터보부스트 비활성화)
echo performance > /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

# 2. IRQ affinity 설정 (벤치마크 코어와 분리)
echo 0f > /proc/irq//smp_affinity  # CPU 0-3에 IRQ
# 벤치마크 서버는 CPU 4-7에서 실행

# 3. NUMA 로컬 실행
numactl --cpunodebind=0 --membind=0 ./my_server

# 4. 네트워크 튜닝
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_max=16777216

# 5. 벤치마크 도구 (wrk, vegeta, iperf3)
# CPS 측정:
wrk -t8 -c10000 -d30s http://server:8080/

# 처리량 측정:
iperf3 -c server -t 30 -P 8

# 지연 시간 측정:
vegeta attack -rate 100000/s -duration 10s | vegeta report -type hist

실환경 벤치마크 시나리오

시나리오epoll 서버io_uring 서버향상률
단순 TCP echo (1KB, 10K 연결)820K msg/s1,050K msg/s+28%
HTTP GET (200B 응답, keep-alive)450K req/s620K req/s+38%
HTTP GET (64KB 응답, SEND_ZC)12 Gbps22 Gbps+83%
프록시 splice (1MB 전달)35 Gbps55 Gbps+57%
UDP DNS 응답 (512B)1.2M qps1.8M qps+50%
짧은 연결 CPS (connect → send → close)120K cps200K cps+67%
주의: 위 수치는 특정 하드웨어(AMD EPYC 7763, ConnectX-6 100GbE)에서 측정한 참고용 데이터입니다. 실제 환경에서는 CPU 아키텍처, NIC, 커널 버전, 워크로드 특성에 따라 크게 달라질 수 있습니다. 반드시 본인의 환경에서 벤치마크를 수행하세요.

성능 튜닝 가이드

SQ/CQ 링 크기 설정

DEFER_TASKRUN vs COOP_TASKRUN

/* 권장: 단일 스레드 네트워크 서버 */
struct io_uring_params params = {
    .flags = IORING_SETUP_SINGLE_ISSUER |   /* 단일 스레드 최적화 */
             IORING_SETUP_DEFER_TASKRUN |   /* task_work 지연 */
             IORING_SETUP_COOP_TASKRUN,     /* 협력적 실행 */
    .cq_entries = 16384,                    /* CQ 크기 명시 */
};
DEFER_TASKRUN은 커널이 완료 통지를 즉시 처리하지 않고, 다음 io_uring_enter() 호출 시점까지 지연합니다. 이를 통해 불필요한 컨텍스트(Context) 전환을 방지하고, 완료를 배치로 처리합니다.

버퍼 크기 전략

워크로드버퍼 크기버퍼 수이유
HTTP 요청 (짧은 메시지)4 KB4096~8192HTTP 헤더가 대부분 4KB 이내
파일 전송 (대용량)64 KB1024~2048큰 블록으로 처리량 최적화
게임 서버 (UDP)1~2 KB8192~16384작은 패킷, 높은 빈도
범용4~8 KB4096균형 잡힌 설정

CPU Affinity와 NUMA

/* Worker 스레드를 특정 CPU에 고정 */
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(core_id, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);

/* NUMA: NIC와 동일한 NUMA 노드에 스레드 배치
 * NIC NUMA 노드 확인: cat /sys/class/net/eth0/device/numa_node */
/* 버퍼 메모리도 동일 NUMA 노드에서 할당 */
void *bufs = numa_alloc_onnode(BUF_COUNT * BUF_SIZE, numa_node);

시스템 수준 튜닝

# TCP 연결 관련
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
sysctl -w net.ipv4.tcp_fin_timeout=15
sysctl -w net.ipv4.tcp_tw_reuse=1

# 버퍼 크기
sysctl -w net.core.rmem_default=262144
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_default=262144
sysctl -w net.core.wmem_max=16777216
sysctl -w net.ipv4.tcp_rmem="4096 262144 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 262144 16777216"

# 파일 디스크립터 제한 (io_uring + 대규모 연결)
ulimit -n 1048576
# 또는 /etc/security/limits.conf에 설정

# mmap 메모리 제한 (io_uring ring buffer용)
ulimit -l unlimited
# 또는 RLIMIT_MEMLOCK 증가

# io_uring 접근 권한
sysctl -w kernel.io_uring_disabled=0
# 0: 모든 사용자 허용 (기본값)
# 1: root만 허용
# 2: 완전 비활성

# NIC 관련
ethtool -G eth0 rx 8192 tx 8192    # RX/TX 링 버퍼 확대
ethtool -K eth0 tso on gro on       # 오프로드 활성화
ethtool -L eth0 combined 8          # 멀티큐 수

링 크기 산정 공식

/* SQ 크기 산정 */
/*
 * 요소:
 * - 동시 활성 연결 수 (N)
 * - 연결당 활성 SQE 수 (보통 1~2: recv + 가끔 send)
 * - 멀티샷 SQE 수 (accept 1개 + 기타)
 * - 여유 공간 (50~100%)
 *
 * 공식: SQ = next_power_of_2(N * 2 + 100)
 * 예: 1000 연결 → SQ = 4096
 *     5000 연결 → SQ = 16384
 */

/* CQ 크기 산정 */
/*
 * 멀티샷은 CQ를 빠르게 채움 (accept + recv 각각)
 * CQ = SQ * 4 ~ SQ * 8 권장
 *
 * CQ 오버플로우 시:
 * - 멀티샷 SQE가 종료됨 (F_MORE 제거)
 * - 재등록 필요 → 성능 저하
 *
 * 예: SQ 4096 → CQ 16384~32768
 */

struct io_uring_params params = {
    .flags = IORING_SETUP_CQSIZE,     /* CQ 크기 명시 */
    .cq_entries = SQ_SIZE * 8,        /* SQ의 8배 */
};

오류 처리 패턴

/* 견고한 CQE 오류 처리 패턴 */
static void handle_cqe_error(struct io_uring *ring,
                              struct io_uring_cqe *cqe,
                              int op, int fd)
{
    int err = -cqe->res;

    switch (err) {
    case EAGAIN:
        /* 비동기 연산이 아직 완료되지 않음
         * 멀티샷에서는 보통 발생하지 않음 */
        break;

    case ECONNRESET:
    case EPIPE:
    case ENOTCONN:
        /* 피어가 연결을 끊음 → 정상적 종료 */
        close_connection(ring, fd);
        break;

    case ENOBUFS:
        /* 제공 버퍼 링 고갈
         * → 버퍼 반환 후 멀티샷 recv 재등록 */
        fprintf(stderr, "Buffer ring exhausted, re-arming\n");
        arm_recv(ring, fd);
        break;

    case ECANCELED:
        /* ASYNC_CANCEL로 취소됨 — 정상 */
        break;

    case EMFILE:
    case ENFILE:
        /* fd 제한 도달 — accept 실패
         * → 기존 유휴 연결 정리 후 재시도 */
        fprintf(stderr, "fd limit reached\n");
        evict_idle_connections(ring);
        break;

    case ENOMEM:
        /* 메모리 부족 — 심각한 상황
         * → 로깅 후 부하 제한(throttle) */
        fprintf(stderr, "ENOMEM in io_uring op %d\n", op);
        break;

    default:
        fprintf(stderr, "Unexpected error %d in op %d fd %d\n",
                err, op, fd);
    }

    /* 멀티샷 재등록 확인 */
    if (!(cqe->flags & IORING_CQE_F_MORE)) {
        switch (op) {
        case OP_ACCEPT:
            arm_accept(ring, fd);
            break;
        case OP_RECV:
            if (err != ECONNRESET && err != EPIPE)
                arm_recv(ring, fd);
            break;
        }
    }
}

우아한 종료(Graceful Shutdown) 패턴

/* 서버 종료 절차 */
static volatile sig_atomic_t shutdown_requested = 0;

static void signal_handler(int sig)
{
    shutdown_requested = 1;
}

static void graceful_shutdown(struct io_uring *ring,
                              struct conn_pool *pool)
{
    /* 1. 멀티샷 accept 취소 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_cancel64(sqe,
        make_user_data(OP_ACCEPT, listen_fd), 0);

    /* 2. 리스닝 소켓 닫기 (새 연결 차단) */
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_close(sqe, listen_fd);

    /* 3. 모든 활성 연결에 shutdown 전송 */
    for (int i = 0; i < pool->max_conns; i++) {
        if (pool->conns[i].state != CONN_CLOSED) {
            sqe = io_uring_get_sqe(ring);
            io_uring_prep_shutdown(sqe, pool->conns[i].fd,
                                  SHUT_RDWR);
        }
    }

    /* 4. 대기: 진행 중인 I/O 완료 */
    io_uring_submit(ring);
    /* 잔여 CQE를 drain하면서 close 처리 */

    /* 5. io_uring 인스턴스 해제 */
    io_uring_queue_exit(ring);
}

튜닝 체크리스트

문제 해결

자주 발생하는 오류

증상원인해결 방법
멀티샷 accept가 즉시 종료CQ 오버플로우로 F_MORE 플래그 제거CQ 크기 증가, CQE 즉시 수확
멀티샷 recv에서 -ENOBUFS제공 버퍼 링 고갈버퍼 수 증가, 즉시 반환
SEND_ZC에서 -EOPNOTSUPP프로토콜 미지원 (UDP는 kernel 6.0+)커널 버전 확인
SQPOLL에서 -EPERM권한 부족 (CAP_SYS_NICE 필요)root 또는 capability 추가
NAPI 등록 실패커널 6.9 미만커널 업그레이드
Direct FD에서 -EBADFIOSQE_FIXED_FILE 누락SQE flags에 IOSQE_FIXED_FILE 추가
SEND_ZC 버퍼 use-after-freeNOTIF CQE 전에 버퍼 재사용NOTIF CQE 수신 후 버퍼 반환

디버깅(Debugging) 도구

# io_uring 트레이스포인트(Tracepoint) 활성화
echo 1 > /sys/kernel/debug/tracing/events/io_uring/enable

# 네트워크 관련 이벤트만 필터링
cat /sys/kernel/debug/tracing/trace_pipe | grep -E "accept|recv|send"

# bpftrace로 io_uring 네트워크 지연 측정
bpftrace -e '
tracepoint:io_uring:io_uring_submit_req /args->opcode >= 13 && args->opcode <= 20/ {
    @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]);
}'

# io_uring 인스턴스 상태 확인
cat /proc//fdinfo/
# SQ/CQ 크기, 등록된 파일 수, 활성 요청 수 확인 가능

# 네트워크 통계
ss -tnp | grep      # 연결 상태
nstat -az                 # TCP/IP 카운터

트레이스포인트 활용 예제

# io_uring 네트워크 opcode별 소요 시간 측정 (bpftrace)
bpftrace -e '
tracepoint:io_uring:io_uring_submit_req {
    @submit[args->opcode, args->req] = nsecs;
}
tracepoint:io_uring:io_uring_complete {
    $opcode = @submit_op[args->req];
    if (@submit[args->req]) {
        $latency = (nsecs - @submit[args->req]) / 1000;
        @latency[$opcode] = hist($latency);
        delete(@submit[args->req]);
    }
}'

# 멀티샷 accept 종료 이유 추적
bpftrace -e '
tracepoint:io_uring:io_uring_complete /args->res < 0/ {
    printf("CQE error: res=%d user_data=%lx\n", args->res, args->user_data);
}'

# CQ 오버플로우 감지
bpftrace -e '
kprobe:io_cqring_overflow_flush {
    printf("CQ overflow at %s\n", strftime("%H:%M:%S", nsecs));
    @overflow_count++;
}'

# 제공 버퍼 링 고갈 감지
bpftrace -e '
kretprobe:io_provided_buffers_select /retval == 0/ {
    printf("Buffer ring exhausted at %s\n", strftime("%H:%M:%S", nsecs));
    @buf_exhausted++;
}'

perf를 이용한 성능 분석

# io_uring 네트워크 서버 CPU 프로파일링
perf record -g -p $(pidof my_server) -- sleep 10
perf report --no-children

# io_uring 시스템 콜 횟수 측정 (SQPOLL 효과 확인)
perf stat -e 'syscalls:sys_enter_io_uring_enter' -p $(pidof my_server) -- sleep 10

# 캐시 미스 분석 (NUMA 문제 진단)
perf stat -e 'cache-misses,cache-references,LLC-load-misses' \
  -p $(pidof my_server) -- sleep 10

# flamegraph 생성
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

커널 설정 확인

# io_uring 관련 커널 설정 확인
zcat /proc/config.gz | grep -i io_uring
# CONFIG_IO_URING=y (필수)
# CONFIG_IO_URING_NET=y (네트워크 opcode)

# io_uring 보안 제한 확인
sysctl kernel.io_uring_disabled
# 0: 모든 사용자, 1: root만, 2: 완전 비활성

# io_uring 메모리 제한
ulimit -l
# mmap된 ring buffer가 이 제한에 포함됨
# 충분하지 않으면 io_uring_setup() 실패

# SQPOLL 권한 확인
capsh --print | grep sys_nice
# SQPOLL은 CAP_SYS_NICE 또는 root 필요

안티패턴(Anti-pattern)

피해야 할 패턴:
  • CQE 지연 수확 — CQ가 가득 차면 멀티샷이 종료됩니다. 매 루프에서 모든 CQE를 수확하세요.
  • 작은 메시지에 SEND_ZC — 1KB 미만 메시지에 제로카피를 사용하면 page pin 비용이 복사 비용보다 큽니다.
  • SQPOLL + 유휴 서버 — 트래픽(Traffic)이 간헐적인 서버에서 SQPOLL은 CPU만 낭비합니다.
  • 버퍼 반환 지연 — 제공 버퍼를 처리 후 즉시 반환하지 않으면 버퍼 고갈이 발생합니다.
  • 멀티 스레드에서 SINGLE_ISSUER — 여러 스레드가 하나의 ring을 공유하면 정의되지 않은 동작이 발생합니다.

메모리 관리(Memory Management) 최적화

네트워크 서버에서 io_uring의 메모리 관리를 최적화하는 기법을 정리합니다.

대형 페이지(Huge Page) 활용

/* 제공 버퍼 링에 대형 페이지 사용 */
#include <sys/mman.h>

/* 2MB 대형 페이지로 버퍼 할당 */
size_t total_size = BUF_COUNT * BUF_SIZE;
size_t aligned_size = (total_size + (2 * 1024 * 1024 - 1))
                     & ~(2 * 1024 * 1024 - 1);

void *bufs = mmap(NULL, aligned_size,
                  PROT_READ | PROT_WRITE,
                  MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                  -1, 0);
if (bufs == MAP_FAILED) {
    /* 대형 페이지 없으면 일반 페이지로 폴백 */
    bufs = mmap(NULL, total_size, PROT_READ | PROT_WRITE,
               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
}

/* 대형 페이지 장점:
 * - TLB 미스 감소 (특히 큰 버퍼 풀에서)
 * - SEND_ZC 페이지 핀닝 비용 감소
 * - get_user_pages()가 더 빠름 */

NUMA 인식 메모리 할당

#include <numa.h>

/* NIC의 NUMA 노드 확인 */
int get_nic_numa_node(const char *ifname)
{
    char path[256];
    snprintf(path, sizeof(path),
             "/sys/class/net/%s/device/numa_node", ifname);
    FILE *f = fopen(path, "r");
    int node = 0;
    if (f) {
        fscanf(f, "%d", &node);
        fclose(f);
    }
    return node;
}

/* NUMA 로컬 버퍼 할당 */
int nic_node = get_nic_numa_node("eth0");
void *bufs = numa_alloc_onnode(BUF_COUNT * BUF_SIZE, nic_node);

/* Worker 스레드도 같은 NUMA 노드에 고정 */
numa_run_on_node(nic_node);
/* 효과: 원격 NUMA 접근 감소 → 지연 시간 15~25% 개선 */

연결 객체 사전 할당

/* 동적 할당 대신 사전 할당된 풀 사용 */
#define MAX_CONNECTIONS  65536

struct connection conn_pool[MAX_CONNECTIONS];
int free_stack[MAX_CONNECTIONS];
int free_top = MAX_CONNECTIONS;

static struct connection *conn_acquire(void)
{
    if (free_top == 0) return NULL;
    return &conn_pool[free_stack[--free_top]];
}

static void conn_release(struct connection *c)
{
    int idx = c - conn_pool;
    free_stack[free_top++] = idx;
}
/* 장점: malloc/free 없음, 캐시 친화적, 메모리 단편화 없음 */

io_uring 네트워크 채택 현황

io_uring 네트워크를 활용하는 프레임워크

프레임워크/라이브러리언어io_uring 네트워크 활용
SeastarC++io_uring 전면 채택, 멀티샷 accept/recv 지원
GlommioRustio_uring 기반 비동기 런타임, per-thread ring
tokio-uringRusttokio 위에 io_uring 백엔드(Backend)
netty-io_uringJavaNetty의 io_uring 전송 계층
libuv (실험적)Cio_uring 백엔드 구현 진행 중
libev / libhvCio_uring 폴링 백엔드 지원
io_uring-goGoGo 런타임 io_uring 통합 실험

프로덕션(Production) 배포 사례

언어별 바인딩(Binding) 현황

언어라이브러리네트워크 지원 수준주요 기능
Cliburing완전 지원공식 라이브러리, 모든 네트워크 opcode
C++Seastar reactor완전 지원per-core ring, zero-copy
Rustio-uring crate완전 지원안전한 API, multishot, buffer ring
Rusttokio-uring높음tokio 비동기 런타임 통합
Goiouring-go기본 지원accept, recv, send
Javanetty-io_uring높음Netty Channel 추상화
Pythonpython-liburing기본 지원ctypes 바인딩
Zigstd.os.linux.io_uring완전 지원표준 라이브러리 내장

epoll에서 io_uring으로 마이그레이션(Migration) 전략

기존 epoll 기반 서버를 io_uring으로 전환할 때 단계적으로 접근하는 것이 안전합니다.

/* 단계 1: epoll 이벤트 루프를 io_uring CQE 루프로 교체
 *         accept/recv/send를 SQE로 전환
 *         동작 확인 후 다음 단계 */

/* 단계 2: 멀티샷 accept 적용
 *         SQE 재제출 코드 제거, F_MORE 처리 추가 */

/* 단계 3: 제공 버퍼 링 + 멀티샷 recv 적용
 *         사용자 버퍼 관리 코드를 buf_ring으로 교체 */

/* 단계 4: DEFER_TASKRUN + SINGLE_ISSUER 플래그 추가
 *         io_uring_submit_and_wait() 사용 확인 */

/* 단계 5: (선택) Direct Descriptor, SEND_ZC 등 고급 최적화
 *         벤치마크로 효과 검증 후 적용 */

/* 단계 6: (선택) SQPOLL, NAPI busy-poll
 *         초저지연 필요 시에만 적용, CPU 비용 확인 */
마이그레이션 주의 사항:
  • io_uring은 Linux 전용입니다. 크로스 플랫폼(Cross-platform) 지원이 필요하면 추상화 계층을 유지하세요.
  • 커널 버전별 기능 가용성이 다릅니다. 최소 지원 커널을 정하고 런타임(Runtime)에 기능을 탐지(Probe)하세요.
  • io_uring_probe를 사용하면 현재 커널이 지원하는 opcode를 런타임에 확인할 수 있습니다.
/* 런타임 opcode 지원 확인 */
struct io_uring_probe *probe = io_uring_get_probe();
if (!probe) {
    fprintf(stderr, "io_uring probe failed\n");
    return 1;
}

/* 멀티샷 accept 지원 확인 */
if (io_uring_opcode_supported(probe, IORING_OP_ACCEPT)) {
    printf("ACCEPT supported\n");
    /* 멀티샷은 런타임에 시도하여 확인 */
}

/* SEND_ZC 지원 확인 */
if (io_uring_opcode_supported(probe, IORING_OP_SEND_ZC)) {
    printf("SEND_ZC supported\n");
} else {
    printf("Falling back to normal SEND\n");
}

io_uring_free_probe(probe);

보안 고려 사항

io_uring은 강력한 기능만큼 보안 공격 면(Attack Surface)도 넓습니다. 네트워크 서버에서 io_uring을 사용할 때 고려해야 할 보안 사항을 정리합니다.

io_uring 접근 제한

# io_uring 비활성화 (보안이 최우선인 환경)
sysctl -w kernel.io_uring_disabled=2

# root만 io_uring 허용
sysctl -w kernel.io_uring_disabled=1

# Seccomp으로 io_uring 시스템 콜 필터링
# io_uring_setup(425), io_uring_enter(426), io_uring_register(427)
# BPF seccomp 필터에서 이 3개 syscall을 차단

SQPOLL 보안

SQPOLL 보안 위험: SQPOLL은 커널 스레드가 사용자 제출 작업을 수행합니다. 악의적인 사용자가 SQPOLL을 남용하면 CPU를 독점할 수 있습니다. SQPOLL에는 CAP_SYS_NICE 또는 root 권한이 필요하며, sq_thread_idle을 적절히 설정하여 유휴 시 CPU를 반환하도록 해야 합니다.

고정 파일 테이블 보안

/* 고정 파일 테이블 크기 제한
 * 과도한 크기 할당 방지 */
#define MAX_FIXED_FILES  16384  /* 적절한 제한 */

/* RLIMIT_NOFILE 확인 */
struct rlimit rl;
getrlimit(RLIMIT_NOFILE, &rl);
int max = (int)rl.rlim_cur;
if (MAX_FIXED_FILES > max) {
    fprintf(stderr, "RLIMIT_NOFILE too low: %d\n", max);
}

io_uring 제한(Restrictions)

/* io_uring 제한: 허용 opcode를 명시적으로 지정 */
struct io_uring_restriction restrictions[] = {
    /* 네트워크 opcode만 허용 */
    { .opcode = IORING_RESTRICTION_REGISTER_OP,
      .register_op = IORING_REGISTER_FILES },
    { .opcode = IORING_RESTRICTION_SQE_OP,
      .sqe_op = IORING_OP_ACCEPT },
    { .opcode = IORING_RESTRICTION_SQE_OP,
      .sqe_op = IORING_OP_RECV },
    { .opcode = IORING_RESTRICTION_SQE_OP,
      .sqe_op = IORING_OP_SEND },
    { .opcode = IORING_RESTRICTION_SQE_OP,
      .sqe_op = IORING_OP_CLOSE },
    /* 파일 I/O opcode 차단: READ, WRITE 등 사용 불가 */
};

io_uring_register_restrictions(&ring, restrictions,
    sizeof(restrictions) / sizeof(restrictions[0]));
io_uring_enable_rings(&ring);
/* 이후 허용되지 않은 opcode 제출 시 -EACCES */

LSM(Linux Security Module) 통합

Linux 6.15에서 io_uring 전용 LSM 훅(Hook)이 추가되었습니다.

/* 커널 6.15+ LSM 훅 */
/* security_uring_sqe(): 각 SQE 제출 시 호출
 * - SELinux, AppArmor 등이 opcode별 접근 제어 가능
 * - 네트워크 연산에 대해 소켓 접근 권한 검사 */

/* security_uring_cmd(): io_uring_cmd passthrough 시 호출
 * - 디바이스별 접근 제어 */

/* SELinux 정책 예제: io_uring 네트워크 접근 */
/* allow my_server_t self:io_uring { sqpoll override_creds }; */
/* allow my_server_t port_type:tcp_socket { name_connect }; */

메모리 보안

/* 제공 버퍼 링의 보안 고려 사항 */
/*
 * 1. 버퍼가 mmap으로 공유되므로 민감 데이터가 남을 수 있음
 *    → 사용 후 버퍼를 0으로 초기화 (보안이 중요한 경우)
 *
 * 2. 고정 버퍼(registered buffers)는 페이지가 pin됨
 *    → 스왑 불가, OOM killer 회피 → 메모리 사용량 주의
 *
 * 3. SEND_ZC의 페이지 핀닝
 *    → 사용자 페이지가 NIC DMA 완료까지 pin
 *    → 악의적으로 많은 SEND_ZC를 제출하면 메모리 고갈 가능
 *    → ulimit -l 로 제한
 */

/* 보안 민감 버퍼 초기화 */
static void secure_return_buffer(int buf_id)
{
    explicit_bzero(buffers + buf_id * BUF_SIZE, BUF_SIZE);
    io_uring_buf_ring_add(br, buffers + buf_id * BUF_SIZE,
                          BUF_SIZE, buf_id,
                          io_uring_buf_ring_mask(BUF_RING_SIZE), 0);
    io_uring_buf_ring_advance(br, 1);
}

커널 버전별 호환성 가이드

io_uring 네트워크 기능은 커널 버전별로 가용성이 다릅니다. 배포 대상 커널에 따라 사용 가능한 기능을 정리합니다.

기능 가용성 매트릭스

기능최소 커널안정 권장비고
기본 accept/connect5.45.6+초기 버전은 버그 다수
기본 send/recv5.55.6+-
sendmsg/recvmsg5.65.10+scatter/gather, UDP
SHUTDOWN5.115.11+-
SOCKET5.185.19+소켓 생성
Multishot accept5.196.0+고CPS 서버 핵심
SEND_ZC5.196.0+제로카피 전송
Provided buffer ring5.196.0+효율적 버퍼 관리
Multishot recv6.06.1+멀티샷 수신
SINGLE_ISSUER6.16.1+단일 스레드 최적화
DEFER_TASKRUN6.16.1+지연 시간 최적화
RECV_ZC6.26.4+제로카피 수신
IORING_REGISTER_NAPI6.96.9+초저지연
BIND / LISTEN6.116.11+서버 설정 통합
FIXED_FD_INSTALL6.126.12+Direct FD → 일반 fd
Bundle send/recv6.146.14+배치 전송

런타임(Runtime) 기능 탐지

/* 안전한 기능 탐지 패턴 */
struct server_caps {
    bool has_multishot_accept;
    bool has_multishot_recv;
    bool has_send_zc;
    bool has_provided_buf_ring;
    bool has_socket_op;
    bool has_defer_taskrun;
    bool has_napi;
};

static void detect_capabilities(struct server_caps *caps)
{
    struct io_uring_probe *probe = io_uring_get_probe();
    if (!probe) {
        memset(caps, 0, sizeof(*caps));
        return;
    }

    caps->has_multishot_accept =
        io_uring_opcode_supported(probe, IORING_OP_ACCEPT);
    caps->has_send_zc =
        io_uring_opcode_supported(probe, IORING_OP_SEND_ZC);
    caps->has_socket_op =
        io_uring_opcode_supported(probe, IORING_OP_SOCKET);

    /* DEFER_TASKRUN은 probe로 확인 불가
     * → 시도하여 EINVAL 확인 */
    struct io_uring test_ring;
    struct io_uring_params p = {
        .flags = IORING_SETUP_SINGLE_ISSUER |
                 IORING_SETUP_DEFER_TASKRUN,
    };
    if (io_uring_queue_init_params(1, &test_ring, &p) == 0) {
        caps->has_defer_taskrun = true;
        io_uring_queue_exit(&test_ring);
    }

    /* NAPI: IORING_REGISTER_NAPI 시도 */
    struct io_uring_napi napi = { .busy_poll_to = 1 };
    struct io_uring napi_ring;
    io_uring_queue_init(1, &napi_ring, 0);
    if (io_uring_register_napi(&napi_ring, &napi) == 0) {
        caps->has_napi = true;
        io_uring_unregister_napi(&napi_ring, &napi);
    }
    io_uring_queue_exit(&napi_ring);

    io_uring_free_probe(probe);
}

/* 감지된 기능에 따라 서버 전략 결정 */
static void configure_server(struct server_caps *caps)
{
    if (caps->has_multishot_accept) {
        printf("Using multishot accept\n");
    } else {
        printf("Falling back to single accept\n");
    }

    if (caps->has_send_zc && large_responses) {
        printf("Using SEND_ZC for large responses\n");
    } else {
        printf("Using normal SEND\n");
    }

    if (caps->has_defer_taskrun) {
        printf("DEFER_TASKRUN enabled\n");
    }
}

주요 배포판(Distribution) 커널 버전

배포판커널 버전주요 io_uring 네트워크 기능
Ubuntu 22.04 LTS5.15기본 accept/recv/send, MSG_RING
Ubuntu 24.04 LTS6.8멀티샷, SEND_ZC, DEFER_TASKRUN, 제공 버퍼 링
RHEL 95.14기본 네트워크 opcode만
Fedora 416.11멀티샷, SEND_ZC, DEFER_TASKRUN, io_uring_cmd
Debian 126.1멀티샷, DEFER_TASKRUN, SINGLE_ISSUER
Debian 136.12멀티샷, SEND_ZC, DEFER_TASKRUN, 제공 버퍼 링
Arch Linux최신모든 기능
권장: io_uring 네트워크의 핵심 기능(멀티샷 accept/recv, 제공 버퍼 링, DEFER_TASKRUN)을 모두 사용하려면 최소 커널 6.1이 필요합니다. 프로덕션 환경에서는 6.6 LTS 이상을 권장합니다.

io_uring 네트워킹 최신 변화 (v6.8~v6.15)

io_uring 네트워킹 경로는 v6.8 이후 DEFER_TASKRUN/SQPOLL 안정화, 멀티샷 send의 개선, devmem 인프라 정합, 그리고 v6.15의 zero-copy receive 병합으로 정점을 찍었습니다.

Zero-copy receive (v6.15)

v6.15에서 io_uring zerocopy receive가 병합되었습니다. 드라이버가 사용자 공간 페이지를 직접 페이지 풀로 HW RX 큐에 공급하여, 들어오는 데이터가 DMA로 사용자 메모리에 바로 도달합니다. 전통적인 커널→사용자 복사 단계가 완전히 제거됩니다.

devmem TCP(v6.12) vs io_uring zcrx(v6.15): 전자는 socket API + MSG_ZEROCOPY + DMABUF 조합으로 GPU 메모리 직접 수신이 주 타깃이고, 후자는 io_uring 네이티브 경로에서 호스트 사용자 메모리로 직접 받는 방식입니다. 두 기능은 상호 보완.

MSG_ZEROCOPY + SEND_ZC (v6.1+~)

기존 MSG_ZEROCOPY는 TCP·UDP·VSOCK 소켓에서 send 경로의 복사를 줄이는 방식입니다. 일반적으로 10 KB 이상 전송에서 효과가 나타납니다. io_uring의 IORING_OP_SEND_ZC/IORING_OP_SENDMSG_ZC가 이 경로를 SQE로 노출합니다.

per-ring NAPI busy poll 제어 (v6.9+)

v6.9에서 epoll에 per-instance busy poll이 추가된 흐름과 맞물려, io_uring도 IORING_REGISTER_NAPI로 ring-별 busy poll 및 prefer_busy_poll 설정이 가능해졌습니다. NAPI suspension(v6.13)과 결합하면 저지연·저CPU를 자연스럽게 오갈 수 있습니다.

struct io_uring_napi napi = {
    .busy_poll_to     = 50,    /* 50us */
    .prefer_busy_poll = 1,
};
io_uring_register(ring.ring_fd, IORING_REGISTER_NAPI, &napi, 1);

멀티샷과 제공 버퍼 링

SCM / io_uring_cmd 소켓 확장

IORING_OP_URING_CMD가 v6.7 이후 안정화되어 소켓 관련 특수 명령(예: setsockopt/getsockopt)을 SQE로 실행할 수 있게 되었습니다. 기존 blocking syscall을 완전히 ring으로 이관 가능.

참고 링크

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

외부 참고 자료: