1P by GN⁺ | ★ favorite | 댓글 1개
  • TinyGate 리버스 프록시는 워커 기반 구조에서 epoll로 바꾸며 성능을 끌어올렸지만, 이후 한계를 만나 io_uring으로 다시 작성됨
  • epoll은 I/O가 가능한 시점을 알려주는 준비 상태 모델이라 epoll_wait 뒤에 read()/write()를 별도로 호출해야 함
  • io_uring은 I/O 완료를 기준으로 움직이는 완료 모델이며, 애플리케이션과 커널이 공유 링 버퍼로 제출 큐와 완료 큐를 주고받음
  • io_uring_enter()는 기본적으로 필요하지만 여러 작업을 한 번에 제출·회수할 수 있고, IORING_SETUP_SQPOLL은 syscall을 줄이는 대신 CPU 사용량을 비용으로 가짐
  • kernel v5.1+를 쓰는 최신 Linux 서버에서 새 프로젝트를 시작한다면, epoll보다 io_uring이 더 적합한 선택지로 평가됨

TinyGate가 드러낸 epoll의 한계

  • TinyGate는 학생들과 만든 리버스 프록시 서버였고, 첫 버전은 단순한 워커 기반 구조였음
  • 교육용 프로젝트로는 동작했지만 nginx나 haproxy 같은 도구와 비교하면 아키텍처 한계가 컸음
  • 두 번째 버전은 epoll 기반으로 바뀌며 첫 버전보다 성능이 크게 좋아짐
    • 다만 벤치마크에서는 여전히 nginx/haproxy를 넘지 못함
  • 이후 epoll의 한계 때문에 io_uring으로 전환했고, 프로젝트를 처음부터 다시 작성하게 됨

epoll: 준비 상태 통지와 반복 syscall

  • epoll은 Linux에서 오래 쓰인 비동기 I/O 관리 방식이며, 2002년에 Linux kernel에 들어감
  • 핵심은 I/O가 가능한 시점을 알려주는 준비 상태 통지
    • epoll은 “읽거나 쓸 수 있음”을 알려줌
    • 실제 데이터 읽기와 쓰기는 이후 read() 또는 write() syscall로 애플리케이션이 수행함
  • 일반적인 흐름에서는 이벤트마다 syscall 비용이 반복됨
    • epoll_ctl은 파일 디스크립터를 등록하는 1회성 syscall임
    • 실제 I/O 이벤트마다 epoll_waitread()/write()가 필요함
    • 결과적으로 이벤트 처리에 추가 syscall이 계속 붙음
  • syscall은 사용자 모드와 커널 모드 사이 컨텍스트 전환을 만들며, 연결 수가 많아질수록 오버헤드가 커짐

io_uring: 완료 모델과 공유 링 버퍼

  • io_uring은 epoll이 Linux kernel에 들어간 지 약 17년 뒤인 2019년에 등장했고, kernel v5.1+에서 지원됨
  • epoll과 달리 I/O가 가능한지가 아니라 I/O가 완료됐는지를 기준으로 동작함
  • 애플리케이션과 커널은 공유 메모리의 링 버퍼를 함께 사용함
    • 제출 큐에는 애플리케이션이 커널에 요청할 작업을 넣음
    • 완료 큐에는 커널이 완료 결과를 다시 올림
  • 기본 설정에서는 커널이 제출 큐를 확인하도록 io_uring_enter()를 호출해야 함
    • 한 번의 호출로 여러 작업을 제출하고 여러 완료를 회수할 수 있음
    • epoll과 read() 조합처럼 작업마다 syscall 쌍을 반복하는 구조가 아님
  • IORING_SETUP_SQPOLL을 쓰면 커널 스레드가 제출 큐를 폴링함
    • 정상 운용 상태에서는 syscall을 거의 없앨 수 있음
    • 큐가 비어 있어도 커널 스레드가 돌기 때문에 CPU를 사용함
    • sq_thread_idle 이후에는 sleep으로 물러나지만 비용이 사라지는 것은 아님

코드 예제로 보는 차이

  • epoll 예제

    • stdin 파일 디스크립터를 등록하고, 이벤트가 오면 별도 read()를 호출함
    • epoll_create1로 epoll 인스턴스를 만듦
    • epoll_ctlSTDIN_FILENO를 등록함
    • epoll_wait로 읽을 수 있을 때까지 블록함
    • 이벤트가 오면 read() syscall로 데이터를 읽음
    • 이 흐름에서는 실제 I/O 이벤트마다 epoll_waitread가 필요해짐
  • io_uring 예제

    • liburing을 사용함
    • io_uring_queue_init으로 링을 초기화함
    • io_uring_get_sqe로 제출 큐 엔트리를 얻음
    • io_uring_prep_readstdin 읽기 작업을 준비함
    • io_uring_submit으로 제출하고 io_uring_wait_cqe로 완료를 기다림
    • io_uring 예제에는 별도 준비 상태 확인이 없고, 완료 시점에 따로 read()를 호출하지 않음
    • 단순화를 위해 두 예제에는 중요한 예외 처리가 빠져 있음
    • stdin에 데이터가 없으면 영원히 블록될 수 있음
    • io_uring 예제는 제출 큐가 가득 찼을 때 io_uring_get_sqe()NULL을 반환하는 경우를 검사하지 않음

io_uring을 쓸 때의 추가 조건

  • zero-copy I/O를 쓰려면 io_uring_register_buffers()로 버퍼를 미리 등록해야 함
    • 작업마다 커널이 메모리를 다시 매핑하는 일을 피할 수 있음
    • 네트워크 전송에서는 kernel 6.0+의 IORING_OP_SEND_ZC가 버퍼를 커널로 복사하지 않는 전송을 제공함
  • IORING_SETUP_SQPOLL은 syscall을 줄일 수 있지만 CPU 사용량이 비용임
    • 큐가 비어 있어도 커널 스레드가 계속 폴링함
    • idle timeout 이후 sleep으로 전환될 수 있지만 비용이 없어지는 것은 아님
  • io_uring의 오류는 동기 syscall의 직접 반환값이 아니라 완료 큐 엔트리의 res 필드로 비동기적으로 돌아옴
    • 오류 처리는 cqe->res를 통해 해야 함

최신 Linux 서버에서의 선택

  • epoll은 I/O 가능 시점 통지와 별도 syscall 호출에 기반한 오래된 Linux 비동기 I/O 방식임
  • io_uring은 최신 Linux에서 완료 기반 모델과 배치 제출·완료 처리를 제공함
  • 현대 Linux 서버에서 처음부터 새 프로젝트를 만든다면 io_uring을 선택하는 쪽이 자연스러움
  • 오래된 시스템 지원을 합리적인 시점에 중단할 수 있다면, kernel v5.1+ 환경에서는 epoll을 고를 이유가 많지 않음

댓글과 토론

Hacker News 의견들
  • GitHub 저장소 https://github.com/sibexico/TinyGate를 아주 잠깐 봤는데, CPU 고정은 아직 안 쓰는 것 같음
    스레드와 리슨 소켓을 CPU에 고정하고, sockopt SO_INCOMING_CPU를 쓰면 성능을 조금 더 끌어낼 수 있음
    나가는 소켓까지 CPU 정렬하면 꽤 큰 향상이 있을 텐데, 알기로는 이를 위한 좋은 API가 없음. Linux에는 호환 NIC용 트래픽 조향/흐름 조향 API가 있고, NIC가 쓰는 해시가 무엇인지 알면—아마 Toeplitz일 가능성이 큼—백엔드로 가는 소스 포트를 잘 골라 해시가 맞게 만들 수 있음
    목표는 프록시가 CPU 간 통신 없이 패킷을 처리하게 만드는 것임

    • 저장소의 v0와 v1은 거의 처음부터 다시 쓴 완전히 다른 구현이고, 지금은 세 번째 구현을 작업 중이며 아마 마지막일 것 같음. 아키텍처 선택도 완전히 달라졌음
    • 그 패치의 벤치마크를 보고 싶음
  • https://github.com/concurrencykit/ckhttps://github.com/microsoft/mimalloc를 보면 좋겠음. 무복사와 메모리 정렬된 리버스 프록시에 잘 맞을 것임
    DDoS 방어와 더 고급 L4 기능을 넣고 싶다면 https://docs.ebpf.io/ebpf-library/libxdp/libxdp/도 확인할 만함

    • 계획은 다른 계층에서 최적화를 적용한 뒤 할당자로 넘어가는 것이었음. 지금 학생들과 할당자를 공부 중이고, 블로그의 이전 글은 Zig 언어로 만든 커스텀 할당자에 관한 것이었음
  • 정말 좋은 글임
    이 글 때문에 uring, 커널 개발, C를 파고드는 토끼굴에 빠졌음. Rust와 C++ 개발을 꽤 오래 해왔지만, 작고 적당한 규모의 C 프로그램에는 단순함과 예술적인 느낌까지 있음

  • io_uring 기반 웹 서버에서 아직 공유 버퍼는 테스트하지 않았음. 파일에서 읽어서 쓰는 대신, mmap된 영역에서 직접 보내기 때문임
    사실은 io_uring으로 sendfile을 쓰고 싶은데 아직 지원되지 않음
    Rust와 kTLS 같은 유행어를 곁들인 글: https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-ze...
    HN에도 올라왔음: https://news.ycombinator.com/item?id=44980865

    • 참고로 splice(2)가 구현되어 있어서 uring으로 sendfile 비슷한 방식을 쓸 수 있음. sendfile만큼 쓰기 편하진 않지만 거의 비슷하게 동작할 것임
  • DPDK로 만들면 훨씬 복잡해지겠지만, 성능에서는 nginx를 압도할 기회가 생김
    FPGA에서 돌리게 만들면 더 복잡해짐
    교훈은 성능을 위해서는 추상화를 뜨거운 칼로 버터 자르듯 뚫고 지나가는 태도가 필요하지만, 그만큼 모든 것이 더 어려워진다는 것임. 소켓과 연결당 스레드 방식은 네트워크가 CPU에 비해 매우 느리던 시절에는 좋은 접근이었고, 오늘날에도 여전히 가장 단순한 접근인 경우가 많음

  • 나도 늘 이게 궁금해서, 핵심 차이를 익히려고 최근 HTTP 파일 서버 구현을 몇 가지 작성해 봤음
    https://theconsensus.dev/p/2026/05/18/serving-files-three-wa...

  • 프록시 맥락에서는 epoll_wait 바쁜 폴링도 언급해야 함. 최근 저지연 옵션을 검토하다가 살펴봤는데, DPDK/VMA/io_uring 없이도 단순 소켓만으로 사용자 공간 바쁜 폴링에 가까운 것이 가능해 보였고, Fastly가 여기에 기여해서 사용 중임
    너무 저수준이라 전체를 이해했다고는 못 하겠고 개념만 이해한 정도라 링크를 남김. NAPI epoll 컨텍스트별로만 동작하고 NAPI ID를 쉽게 제어할 수는 없지만, 머신 전체를 프록시 전용으로 쓴다면 NAPI ID별로 소켓을 전용 폴러에 배정하는 간단한 꼼수가 가능함
    내 용도는 프록시가 아니라, 한 머신에서 N개 소켓을 폴링한 뒤 받은 데이터를 처리하는 것이었음. 그런 경우에는 실현 가능해 보이지 않았고, 단일 스레드에서 NAPI 컨텍스트를 라운드로빈으로 폴링하면 가능할지도 모르겠음. 언젠가 커널에 “믿어라, 이 단일 소켓은 내가 결국 폴링할 테니 절대 IRQ 경로를 쓰지 말라”고 쉽게 알려줄 수 있으면 좋겠음
    이 커널 기능에 대한 이전 HN 토론: https://news.ycombinator.com/item?id=43749271
    Fastly 기여자의 좋은 발표 자료로, 큰 그림을 이해하기 쉽게 해주는 다이어그램이 있음: https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...
    LWN 글: https://lwn.net/Articles/1008399/, https://lwn.net/Articles/997491/, https://lwn.net/Articles/959462/
    커널 문서: https://docs.kernel.org/networking/napi.html#irq-mitigation

  • C++와 비동기 네트워킹을 좋아한다면 Boost.Asio가 있음

    • 최근 Asio를 직접 만든 epoll 이벤트 루프로 바꿨더니 RPS가 약 16% 좋아졌음. 적당한 규모의 SQL 서버에서 나온 결과라, 잘 포장된 라이브러리를 쓸 때는 조심해야 함
    • 데이터베이스 서버에서 Asio의 epoll 백엔드를 io_uring으로 바꿨더니 CPU 사용률이 확 올라갔음. 사용 방식과 이벤트 코드에 어떻게 통합했는지에 따라 달라질 가능성이 큼
    • Boost는 너무 불편함. 빌드하고 쓰기 까다로운 거대한 동적 라이브러리들임. 이미 CMake를 쓰고 있었는데도, Boost를 설치해서 발견 가능하게 만드는 과정이 매우 성가셨음. 다만 Mac에서 겪은 일임
  • 2050년쯤 되면 Linux에서 소켓을 폴링하는 방법이 20가지쯤 있을 것 같음

    • 맞음, io_uring 안에서도 그렇다. 더 빠르게 가려고 io_uring 단발 방식이 나오고, 그다음엔 다중 발사 방식까지 생김
  • 맞음, io_uringepoll보다 확실히 빠름. 내 경우에는 io_uring이 초당 요청 수 기준으로 약 20% 빨랐던 것 같음
    문제는 커널에서 명시적으로 켜야 하고, 보안 이유로 거의 모든 곳에서 비활성화되어 있다는 점임. 커널과 사용자 공간 사이에 직접 메모리 공유가 있는 것 같은데, 꽤 꺼림칙함. 최근 io_uring을 노린 익스플로잇도 여러 번 있었음
    그래서 Go처럼 가능한 한 최고 성능을 노리는 엔지니어링 프로젝트도 io_uring을 합리적인 기본값으로 깊게 넣지는 않음. 위험을 감수하고 싶다면 좋아하는 언어에서 직접 돌릴 수는 있음. 더 빠르지만 대가는 잠재적인 익스플로잇 가능성임

    • 비활성화되는 주된 이유는 이제 해결됨. 최신 RC에 cBPF 지원이 들어가서, 전부 끄는 대신 실행 가능한 작업을 제한할 수 있게 됨
    • 경우에 따라 다름. epoll이 아니라 poll로 만든 내 POSIX 방식 io_uring 에뮬레이션이 io_uring보다 빨랐던 적도 있음. 다만 큰 무복사 버퍼에서는 io_uring이 최고임
      io_uring은 비동기 I/O가 아니어도 유용함. 예를 들어 mkdir 후 그 디렉터리를 여는 식의 연산 체인을 단일 원자적 작업처럼 구현할 수 있음
      네트워킹에서 초당 패킷 수를 최대화하려 하면 커널 한계[1]에 매우 빨리 부딪히고, 결국 GSO/GRO 같은 기능을 활용하거나 네트워크 스택을 완전히 우회해야 함
      1: https://github.com/axboe/liburing/discussions/1346
    • RHEL 9와 10은 이제 기본적으로 io_uring을 완전히 지원함. 아주 최근 일이지만, 이로써 많은 기업 Linux 설치 환경이 포함됨. Gemini는 Ubuntu와 SuSE도 지원한다고 “말했지만”, 이를 증명할 링크는 주지 않았음
      https://access.redhat.com/solutions/4723221
      Go도 지원을 재검토해야 함. 한번 해볼 만함
    • Go 같은 프로젝트라면 런타임 시작 시 한 번만 io_uring 기능 감지를 하는 선택지도 있지 않을까? 익스플로잇은 io_uring을 쓰기로 한 프로그램만의 문제가 아니라 전체 OS의 문제 아닌가?
    • 모든 종류의 폴링 모드 네트워킹은—RDMA, DPDK, io_uring—결국 메모리 격리를 사용자가 책임져야 하는 성격이 강함
      다만 io_uring의 경우 링이 커널 안에 있어서 사용자가 할 수 있는 게 많지 않음
      LLM 덕분에 앞으로 나아질 거라 기대하지만, 해결하기 어려운 문제임. 커널 자체에서 처리하기도 매우 어렵고, 사람들도 이를 튜닝하는 법을 제대로 이해하지 못하는 경우가 많음