Operating Systems Module 9 – I/O systems, disks and disk scheduling
Many connections: epoll, kqueue, io_uring
How one thread serves thousands of connections: readiness with poll, epoll and kqueue, completion with io_uring, and the cost of poll measured.
What you will learn
- Explain the readiness and completion models of I/O and name the interfaces of each
- Trace one event loop on poll and on kqueue, and map each of its calls to epoll's
- Explain level- and edge-triggered notification, and fix a loop that stalls under edge triggering
- Describe io_uring's submission and completion rings and count the system calls a batch needs
Before you start
What you’ll learn · Before you start
What you will learn
- Explain the readiness and completion models of I/O and name the interfaces of each
- Trace one event loop on poll and on kqueue, and map each of its calls to epoll's
- Explain level- and edge-triggered notification, and fix a loop that stalls under edge triggering
- Describe io_uring's submission and completion rings and count the system calls a batch needs
Before you start
On this page
A chat server with 10,000 open connections, most of them idle, has to notice the few that have something to say. On
the recorded Mac one call to poll over 10,000 sockets, one of them with data, took several milliseconds, while the
same wait with kqueue took under a microsecond, because kqueue already knew which sockets to watch.
This lesson explains the two ways a single thread can wait for many connections, readiness and completion, measures
why poll slows down as connections grow, and shows what epoll: Linux's readiness interface for many descriptors: epoll_ctl adds a descriptor to an interest list kept in the kernel, and epoll_wait returns only the descriptors that are ready.,
kqueue: The readiness interface of macOS and the BSDs: one call, kevent, registers changes to the watched set in the kernel and returns the events that are pending. and io_uring: Linux's completion interface: the program and the kernel share a submission ring and a completion ring in memory, so a batch of operations needs one system call or none. each change.
Readiness or completion: two ways to wait for many connections
A thread per connection is the simplest server, and 10,000 connections then need 10,000 threads that mostly sleep. A single thread can serve them all instead, if the kernel can tell it which connections need attention. There are two ways to ask. A readiness interface: An I/O interface that tells a program which descriptors it can read or write now without blocking, as select, poll, epoll and kqueue do; the program then makes the call itself. interface answers “which sockets can I read or write without waiting?”, and the program then makes the read or write itself, with non-blocking calls. A completion interface: An I/O interface that takes a whole operation, buffer included, and later reports that it has finished, as io_uring and Windows I/O completion ports do. interface takes the whole operation, buffer included, and answers “which of my operations have finished?”. Step through one message both ways:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Register once. With a readiness interface the program tells the kernel once which sockets to watch, with epoll_ctl on Linux or kevent on macOS. The kernel keeps that interest set from then on.
- Wait. The program calls epoll_wait, or kevent, and sleeps. It hands the kernel no list of sockets this time, and nothing runs while every connection is idle.
- Data arrives. A message reaches socket 57. The kernel puts the bytes in that socket's buffer and marks the socket ready.
- Ready. The wait returns with socket 57 alone, not the 9,999 sockets that have nothing to say.
- Read. Readiness only says that a read would not block now. The program still calls read on socket 57, non-blocking, and the kernel copies the 512 bytes into its buffer. Two calls for one message, the wait and the read.
- Completion instead. With io_uring the program queues the read itself, with its buffer, in the submission ring before any data exists, and tells the kernel with one call, or none when the kernel polls the ring.
- Done. When the message arrives, the kernel performs the read into the buffer and posts a completion in the completion ring. The program finds the bytes already in its buffer.
- The difference. Readiness says a read can be made now; completion says the read has been made. The first needs a call after every wait, the second hands the kernel the buffer in advance.
The readiness interfaces differ in where the list of watched sockets lives. select and poll take the whole list
on every call. epoll, on Linux, keeps it in the kernel: epoll_ctl adds a descriptor to the
interest list once, and epoll_wait returns only those on the ready list. kqueue, on macOS and the BSDs, does the
same through one call, kevent, which registers changes and returns pending events together. io_uring, on Linux,
is a completion interface: the program and the kernel share a submission ring and a completion ring, so a whole batch
of operations needs one system call, or none. Windows has completion too, I/O completion ports.
| Interface | Model | Systems | The watched set | Each wait costs |
|---|---|---|---|---|
select, poll |
Readiness | POSIX | Passed in on every call | Grows with every watched descriptor |
| epoll | Readiness | Linux | Kept in the kernel | Grows with the ready ones only |
| kqueue | Readiness | macOS, BSDs | Kept in the kernel | Grows with the ready ones only |
| io_uring | Completion | Linux 5.1 and later | Operations in a shared ring | One call per batch, or none |
| I/O completion ports | Completion | Windows | Handles tied to a port | One wait per completion |
Libraries hide the differences: libuv, which Node.js runs on, uses “epoll on Linux, kqueue on OSX and other BSDs” and IOCP on Windows. Three more names belong to readiness. level-triggered: Readiness notification that reports a descriptor on every wait for as long as it is ready, for example while unread data is waiting. notification reports a descriptor for as long as it is ready, and edge-triggered: Readiness notification that reports a descriptor once each time new data arrives, not for as long as data waits. notification reports it once each time new data arrives. One-shot notification reports a descriptor once and then ignores it until the program asks for it again.
Checkpoint
Check: Readiness and completion
Why poll gets slower as connections grow, measured
poll receives an array of every descriptor to watch, and the kernel has to look at each one on every call, ready
or not; select has the same cost and a hard limit, descriptors below FD_SETSIZE, 1024. A server that calls
poll thousands of times a second over 10,000 sockets pays for 10,000 checks each time. The program below measures
one wait with poll and one with kqueue as the number of watched connections grows, with a single connection
holding data. It uses Python’s select module, whose poll and kqueue objects are thin wrappers around the system calls:
# The cost of one wait for events with poll and with kqueue, as the number of
# watched connections grows. One connection has data waiting; the rest are idle.
import resource
import select
import socket
import time
WAITS = 200
SIZES = [10, 100, 1000, 10000]
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (min(hard, 2 * max(SIZES) + 64), hard))
def microseconds_per_wait(wait) -> float:
start = time.perf_counter()
for _ in range(WAITS):
ready = wait()
assert len(ready) == 1
return (time.perf_counter() - start) * 1e6 / WAITS
print("connections poll (us) kqueue (us)")
for n in SIZES:
pairs = [socket.socketpair() for _ in range(n)]
pairs[n // 2][1].send(b"x") # one connection in the middle has data
poller = select.poll()
for ours, _ in pairs:
poller.register(ours.fileno(), select.POLLIN)
poll_us = microseconds_per_wait(lambda: poller.poll(0))
kq = select.kqueue()
changes = [select.kevent(ours.fileno(), select.KQ_FILTER_READ) for ours, _ in pairs]
kq.control(changes, 0, 0) # register every connection once
kqueue_us = microseconds_per_wait(lambda: kq.control(None, 16, 0))
print(f"{n:11} {poll_us:11.1f} {kqueue_us:13.1f}")
kq.close()
for ours, theirs in pairs:
ours.close()
theirs.close() Output
connections poll (us) kqueue (us)
10 2.9 0.6
100 31.4 0.6
1000 305.1 0.6
10000 9306.7 0.6
This output changes from run to run: the times depend on the machine and on what else it is running; the connection counts do not
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 poll_vs_kqueue.py
The poll column grows with the connections, about tenfold or more for every tenfold step, to several
milliseconds at 10,000. The kqueue column hardly moves and stays under a microsecond, because the kernel already holds the watched
set and returns only the one ready socket. At 10,000 connections the difference is thousands of times, for exactly the
same answer. epoll gives Linux the same property: its manual page says it “scales well to large numbers of watched
file descriptors”.
Key idea
A readiness interface that keeps the watched set in the kernel, as epoll and kqueue do, pays for the descriptors that are ready; one that is handed the whole set on every call, as poll and select are, pays for all of them.
Checkpoint
Check: Why poll's cost grows with the connections
Practise: readiness, completion and the cost of a wait
The questions below cover both ways of waiting and what a wait costs. A question you miss is asked again before the session ends.
Practice that adapts to you
Practise this lesson
9 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
One event loop, from poll to kqueue and epoll Pro: opens with a Pro, Premium or Ultimate pass
An event loop waits for readiness, serves every ready connection without blocking, and waits again. This part runs the same echo loop on 1,000 connections with poll and with kqueue on the recorded Mac, counts what each hands the kernel, and maps every call to epoll’s.
In this part:
- The same echo loop with poll and with kqueue, on 1,000 connections
- What the loop does between two waits
1 runnable example (C) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Level- and edge-triggered: the hang, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass
Edge-triggered notification reports a change once, which saves the kernel work and stalls any reader that stops before the data is gone. This part reproduces the stall on a real kqueue, models epoll’s two modes in 14 languages, and fixes the loop.
In this part:
- The stall on a real kqueue
- epoll’s two modes, in a model
- The rule: non-blocking descriptors, drained until EAGAIN
- Drain a non-blocking connection until EAGAIN
- Results of the sample tests
2 runnable examples (C) · 1 exercise with sample tests · about 10 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
io_uring's rings, step by step Pro: opens with a Pro, Premium or Ultimate pass
io_uring replaces a system call per operation with two rings in memory shared by the program and the kernel. This part runs both rings in a model, counts the system calls a batch needs, and follows how the interface grew over three Linux releases.
In this part:
- Submission and completion queues in a model
- Batching, polling and three releases
1 diagram · 1 runnable example · about 10 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
Why do servers with thousands of connections use epoll or kqueue rather than select or poll?
Because select and poll are handed the whole list of watched descriptors on every call, and the kernel checks each one each time, so a wait costs work for every connection, ready or not. select also has a hard limit: it can watch only descriptor numbers below FD_SETSIZE, 1024. epoll on Linux and kqueue on macOS and the BSDs keep the watched set in the kernel: a program registers each descriptor once, and a wait returns only the ready ones. On the recorded Mac one poll over 10,000 connections, all but one of them idle, took several milliseconds, while a kqueue wait stayed under a microsecond at every size. For a handful of descriptors poll is perfectly fine; the difference matters as the set grows into the thousands.
-
Mid to senior
After a server's event loop is switched from level-triggered to edge-triggered epoll, some connections stop being served under load: clients send requests that never get an answer, although the server is idle. What is the bug, and how do you fix it without giving up edge triggering?
-
Senior to staff
Design the I/O core of a service that must hold 100,000 mostly idle connections on one Linux machine. Which interface do you use, how many threads, and what does each thread do?
-
Senior to staff
A storage service keeps many disk reads and network writes in flight at once. When would you choose io_uring over epoll for it, and what does that change in the way the program is written?
Key takeaways
- A readiness interface tells a program which connections it can read or write without waiting; a completion interface takes the whole operation and reports when it is done.
selectandpollare handed every watched descriptor on every call, so their cost grows with the connections: several milliseconds at 10,000 on the recorded Mac.- epoll and kqueue keep the watched set in the kernel and return only the ready descriptors, so a wait stayed under a microsecond at any size.
- io_uring shares a submission ring and a completion ring with the kernel, so a batch of operations needs one system call or none.
Terms in this lesson
- completion interface
- An I/O interface that takes a whole operation, buffer included, and later reports that it has finished, as io_uring and Windows I/O completion ports do.
- edge-triggered
- Readiness notification that reports a descriptor once each time new data arrives, not for as long as data waits.
- epoll
- Linux's readiness interface for many descriptors: epoll_ctl adds a descriptor to an interest list kept in the kernel, and epoll_wait returns only the descriptors that are ready.
- io_uring
- Linux's completion interface: the program and the kernel share a submission ring and a completion ring in memory, so a batch of operations needs one system call or none.
- kqueue
- The readiness interface of macOS and the BSDs: one call, kevent, registers changes to the watched set in the kernel and returns the events that are pending.
- level-triggered
- Readiness notification that reports a descriptor on every wait for as long as it is ready, for example while unread data is waiting.
- readiness interface
- An I/O interface that tells a program which descriptors it can read or write now without blocking, as select, poll, epoll and kqueue do; the program then makes the call itself.
References
- epoll(7): I/O event notification facility (Linux man-pages project (man7.org))
- epoll_ctl(2): control interface for an epoll file descriptor (Linux man-pages project (man7.org))
- select(2): synchronous I/O multiplexing (Linux man-pages project (man7.org))
- poll(2): wait for some event on a file descriptor (Linux man-pages project (man7.org))
- poll: input/output multiplexing (POSIX.1-2024) (The Open Group and IEEE)
- kqueue(2), kevent: kernel event notification mechanism (The FreeBSD Project)
- io_uring(7): asynchronous I/O facility (Linux man-pages project (man7.org))
- io_uring_enter(2): initiate and/or complete asynchronous I/O (Linux man-pages project (man7.org))
- io_uring_setup(2): set up a context for performing asynchronous I/O (Linux man-pages project (man7.org))
- I/O completion ports (Microsoft)
- libuv design overview (libuv project)
- select: synchronous I/O multiplexing (Python 3.14) (Python Software Foundation)
- Linux 5.1 (io_uring) (KernelNewbies)
- Linux 6.15 (io_uring zero-copy receive) (KernelNewbies)
- Linux 7.1 (BPF-controlled io_uring) (KernelNewbies)
Report a problem with this lesson
Kept only in this browser. Your Learn progress