# Linux의 epoll과 io_uring 비교

> Clean Markdown view of GeekNews topic #30698. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=30698](https://news.hada.io/topic?id=30698)
- GeekNews Markdown: [https://news.hada.io/topic/30698.md](https://news.hada.io/topic/30698.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-06-22T06:35:52+09:00
- Updated: 2026-06-22T06:35:52+09:00
- Original source: [sibexi.co](https://sibexi.co/posts/epoll-vs-io_uring/)
- Points: 1
- Comments: 1

## Topic Body

- 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_wait`와 `read()`/`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_ctl`로 `STDIN_FILENO`를 등록함
  - `epoll_wait`로 읽을 수 있을 때까지 블록함
  - 이벤트가 오면 `read()` syscall로 데이터를 읽음
  - 이 흐름에서는 실제 I/O 이벤트마다 `epoll_wait`와 `read`가 필요해짐
- ## io_uring 예제
  - `liburing`을 사용함
  - `io_uring_queue_init`으로 링을 초기화함
  - `io_uring_get_sqe`로 제출 큐 엔트리를 얻음
  - `io_uring_prep_read`로 `stdin` 읽기 작업을 준비함
  - `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을 고를 이유가 많지 않음

## Comments



### Comment 60086

- Author: neo
- Created: 2026-06-22T06:35:53+09:00
- Points: 1

###### [Hacker News 의견들](https://news.ycombinator.com/item?id=48613872) 
- GitHub 저장소 [https://github.com/sibexico/TinyGate](<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/ck](<https://github.com/concurrencykit/ck>)와 [https://github.com/microsoft/mimalloc](<https://github.com/microsoft/mimalloc>)를 보면 좋겠음. **무복사**와 메모리 정렬된 리버스 프록시에 잘 맞을 것임  
  DDoS 방어와 더 고급 L4 기능을 넣고 싶다면 [https://docs.ebpf.io/ebpf-library/libxdp/libxdp/](<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...](<https://blog.habets.se/2025/04/io-uring-ktls-and-rust-for-zero-syscall-https-server.html>)  
  HN에도 올라왔음: [https://news.ycombinator.com/item?id=44980865](<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...](<https://theconsensus.dev/p/2026/05/18/serving-files-three-ways.html>)

- 프록시 맥락에서는 **`epoll_wait` 바쁜 폴링**도 언급해야 함. 최근 저지연 옵션을 검토하다가 살펴봤는데, DPDK/VMA/io_uring 없이도 단순 소켓만으로 사용자 공간 바쁜 폴링에 가까운 것이 가능해 보였고, Fastly가 여기에 기여해서 사용 중임  
  너무 저수준이라 전체를 이해했다고는 못 하겠고 개념만 이해한 정도라 링크를 남김. NAPI `epoll` 컨텍스트별로만 동작하고 NAPI ID를 쉽게 제어할 수는 없지만, 머신 전체를 프록시 전용으로 쓴다면 NAPI ID별로 소켓을 전용 폴러에 배정하는 간단한 꼼수가 가능함  
  내 용도는 프록시가 아니라, 한 머신에서 N개 소켓을 폴링한 뒤 받은 데이터를 처리하는 것이었음. 그런 경우에는 실현 가능해 보이지 않았고, 단일 스레드에서 NAPI 컨텍스트를 라운드로빈으로 폴링하면 가능할지도 모르겠음. 언젠가 커널에 “믿어라, 이 단일 소켓은 내가 결국 폴링할 테니 절대 IRQ 경로를 쓰지 말라”고 쉽게 알려줄 수 있으면 좋겠음  
  이 커널 기능에 대한 이전 HN 토론: [https://news.ycombinator.com/item?id=43749271](<https://news.ycombinator.com/item?id=43749271>)  
  Fastly 기여자의 좋은 발표 자료로, 큰 그림을 이해하기 쉽게 해주는 다이어그램이 있음: [https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-s...](<https://netdevconf.info/0x18/docs/netdev-0x18-paper10-talk-slides/Real%20world%20tips,%20tricks,%20and%20notes%20of%20using%20epoll-based%20busy%20polling%20v2.pdf>)  
  LWN 글: [https://lwn.net/Articles/1008399/](<https://lwn.net/Articles/1008399/>), [https://lwn.net/Articles/997491/](<https://lwn.net/Articles/997491/>), [https://lwn.net/Articles/959462/](<https://lwn.net/Articles/959462/>)  
  커널 문서: [https://docs.kernel.org/networking/napi.html#irq-mitigation](<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_uring`은 `epoll`보다 확실히 빠름. 내 경우에는 `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](<https://github.com/axboe/liburing/discussions/1346>)
  - RHEL 9와 10은 이제 기본적으로 `io_uring`을 완전히 지원함. 아주 최근 일이지만, 이로써 많은 기업 Linux 설치 환경이 포함됨. Gemini는 Ubuntu와 SuSE도 지원한다고 “말했지만”, 이를 증명할 링크는 주지 않았음  
    [https://access.redhat.com/solutions/4723221](<https://access.redhat.com/solutions/4723221>)  
    Go도 지원을 재검토해야 함. 한번 해볼 만함
  - Go 같은 프로젝트라면 런타임 시작 시 한 번만 **`io_uring` 기능 감지**를 하는 선택지도 있지 않을까? 익스플로잇은 `io_uring`을 쓰기로 한 프로그램만의 문제가 아니라 전체 OS의 문제 아닌가?
  - 모든 종류의 폴링 모드 네트워킹은—RDMA, DPDK, `io_uring`—결국 메모리 격리를 사용자가 책임져야 하는 성격이 강함  
    다만 `io_uring`의 경우 링이 커널 안에 있어서 사용자가 할 수 있는 게 많지 않음  
    LLM 덕분에 앞으로 나아질 거라 기대하지만, 해결하기 어려운 문제임. 커널 자체에서 처리하기도 매우 어렵고, 사람들도 이를 튜닝하는 법을 제대로 이해하지 못하는 경우가 많음
