Epoll와 io_uring, 리눅스

발행: (2026년 6월 21일 AM 08:07 GMT+9)
8 분 소요

출처: Hacker News

먼저, 여기서 어떻게 도착했는지 정확히 설명하고 리눅스에서 비동기 I/O를 처리하는 다양한 옵션을 연구하게 된 이유를 이야기하고 싶어요… 지난해 저는 학생들과 함께 간단한 워커 기반 역 프록시 서버인 TinyGate를 만들었습니다. 그것은 매우 단순했으며 잘 동작했습니다.

물론 예상보다 빠르지 않았지만 교육용 프로젝트였으며 실제 수준의 도구로 발전했다고 생각하니 진심으로 뿌듯했습니다. 하지만 제 학생들은 저와同じ level of satisfaction을 느끼지 못했고, 진정한 유용성을 가진 무언가를 만들고 싶었고, 우리 “제품”이 강력한 아키텍처 한계를 가지고 있어 nginx나 haproxy 같은 거대 툴들을 능가하지 못했다는 사실에 크게 실망했습니다. 그래서 그들은 바로 제가 직접 그 도구들이 어떻게 동작하는지, 그리고 비동기 I/O를 어떻게 처리해야 효율적인지를 함께 조사하라고 강제로 요구했습니다. 이는 무거운 오버헤드를 줄이기 위함이었습니다.

간단히 말해, 우리는 TinyGate의 두 번째 버전을 epoll 기반으로 만들었습니다. 이 버전은 여전히 벤치마크에서 nginx/haproxy에 밀렸지만 첫 번째 버전에 비해 대폭적인 성능 향상이 있었습니다. 하지만 epoll도 완벽하지 않으며(아래에서 설명하겠지만) 결국 io_uring로 전환하게 되었고, 이는 전체 프로젝트를 다시 쓰게 만들었습니다. 따라서 이 주제는 매우 흥미롭고, 오늘은 리눅스가 비동기 I/O를 위한 두 가지 대기열 시스템을 소개해 드리겠습니다.

epoll heritage

When I just started developing for Linux, epoll was a new feature, and basically it had no alternatives. Everyone used it to manage asynchronous execution - there was no other choice. The problem is, epoll relies heavily on syscalls: it tells you when I/O is possible, but you still have to call read()/write() yourself afterward - that’s two syscalls per I/O event, on top of the one-time epoll_ctl registration.

각 시스템 호출은 사용자 모드와 커널 모드 간 컨텍스트 스위치를 유발하며, 연결이 많이 처리되는 경우 enorme(거대한) 오버헤드를 초래합니다. 하지만 해결책이 있습니다! epoll이 리눅스 커널에 landed(도입된) 지 17년 후인 2002년부터 약 17년이 지난 2019년에 io_uring이 등장했습니다! io_uring은 I/O가 가능해졌음을 알려주는 것이 아니라 I/O가 끝났음을 알리며, 폴링 루프 없이 훨씬 적은 시스템 호출을 동반합니다.

The kernel consumes submissions from memory shared between your app and the kernel, and posts completions back into that same shared memory - both live in ring buffers, hence the name. The catch: by default you still have to call io_uring_enter() to tell the kernel “go check the submission queue” - but one call can submit a whole batch of operations and reap a whole batch of completions, instead of one syscall pair per operation like with epoll + read.

하지만 기본적으로는 여전히 io_uring_enter()를 호출해야 합니다. 이는 커널에 “제출 큐를 확인해라”고 알리는 것이며, 한 번의 호출로 여러 작업을 제출하고 여러 완료를 수집할 수 있어 epoll + read와 같이 각 작업당 하나의 시스템 호출 쌍이 필요하지 않습니다.

If you want close to zero syscalls during steady state, there’s IORING_SETUP_SQPOLL, which spins up a dedicated kernel thread that polls the submission queue for you - at the cost of that thread burning CPU (more on this below).

A little comparison

Basic architecture: as I said before, epoll notifies you when I/O is possible, io_uring notifies you when I/O is done.

epoll은 모든 I/O 작업을 커널 경계를 넘게 하지만, io_uring은 한 번의 “설정 비용”(링 생성)과 배치당 비용(io_uring_enter() 호출)만 부과합니다. 즉, I/O 작업당 시스템 호출 쌍이 아니라 I/O 배치당 하나의 시스템 호출(또는 SQPOLL에서는 거의 none)만을 얻게 됩니다.

As you can see, with a ton of I/O happening, this saves a lot of syscalls.

[Let’ s code!](#let- s-code)

Of course, I won’t leave you without some code showing how both systems work. We’ll use C. (The io_uring example uses liburing, the userspace helper library - install it via liburing-dev/liburing-devel, or drop down to the raw io_uring_setup/io_uring_enter syscalls if you want zero dependencies.)

epoll

Let’s make a simple example of how epoll works. We’ll create the instance, register a file descriptor (stdin, in our case), and process the incoming event.

As you can see, this example uses three syscalls in total: epoll_ctl (a one-time registration), then epoll_wait and read for the event - so two syscalls per actual I/O event, like I mentioned above. The code itself is pretty easy to follow.

io_uring

Now let’s do the same thing with io_uring instead of epoll.

What can we see here?

  • Similar instance creation step.
  • No epoll_ctl registration step needed.
  • No readiness check needed before submission.
  • No separate read() call at completion.

Yeah, io_uring takes way fewer resources for this - though, as noted above, there’s still one io_uring_enter() call hiding inside io_uring_submit() and io_uring_wait_cqe() unless you’re running with SQPOLL.

When you test these examples, keep in mind that for the sake of simplicity, some important parts are missing. For example, it will block forever if stdin never produces any data, and the io_uring example skips checking for a NULL sqe (which io_uring_get_sqe() can return if the submission queue is full).

Something additional about io_uring

  • Zero-copy. For real zero- copy I/O, register your buffers ahead of time with io_uring_register_buffers() - this avoids the kernel re-mapping memory on every single operation. For network sends specifically, look at IORING_OP_SEND_ZC (kernel 6.0+ needed), which skips copying the buffer into the kernel entirely.

  • SQPOLL uses CPU. Even when your queue is empty, IORING_SETUP_SQPOLL keeps a kernel thread spinning and polling, which burns CPU. There’s an idle timeout (sq_thread_idle) after which it backs off to sleeping, but it’s not free.

  • Asynchronous error handling. Errors come back (and must be handled) asynchronously, as part of the cqe’s res field - not as a direct return value like a normal synchronous syscall.

Summary

io_uring is the new standard for async I/O in the modern Linux world, and honestly, I don’t see much reason to still reach for epoll on a system that has it. For a from- scratch project on a modern Linux server, like our TinyGate rewrite, io_uring is absolutely the way to go. I’m a die-hard supporter of dropping support for old systems as soon as it’s reasonable - if you’re still running a kernel released more than 7 years ago, in my opinion, that’s not a great idea…

0 조회
Back to Blog

관련 글

더 보기 »

프로젝트 페치 2단계

Michael Ilie, C. Daniel Freeman, and Kevin K. Troy In August 2025, we ran an experimenthttps://www.anthropic.com/research/project-fetch-robot-dog to see how muc...

2022년 이전 책

I noticed that I seem to, subconsciously, gravitate towards books published on or before 2022, and somewhat discount books published after, especially from auth...