Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Clang 21.0.0 and GCC 16.2.0
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: Readiness or completion: two ways to wait for many connections · Why poll gets slower as connections grow, measured · Practise: readiness, completion and the cost of a wait. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

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:

Interactive · message by message One message on one of many sockets, by readiness and by completion Step through one message arriving on socket 57, first with a readiness interface, then with a completion one. Count the calls the program makes.
One message on one of many sockets, by readiness and by completion, step 8 of 8. Messages so far: Program to Kernel: watch 10,000 sockets; Program to Kernel: wait for any; Kernel to Program: 57 is ready; Program to Kernel: read(57, buf); Program to Kernel: read 57 into buf; Kernel to Program: read 57: 512 bytes.Programone threadKernelsockets and bufferswatch 10,000 socketswait for any57 is readyread(57, buf)read 57 into bufread 57: 512 bytes

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Data arrives. A message reaches socket 57. The kernel puts the bytes in that socket's buffer and marks the socket ready.
  4. Ready. The wait returns with socket 57 alone, not the 9,999 sockets that have nothing to say.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 Which interface tells a program that an operation has finished, rather than that it could start one without waiting?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: io_uring

    Readiness answers "which descriptors can I use now?"; completion answers "which of my operations are done?". Windows' I/O completion ports are the other completion interface the lesson names.

  2. Question 2 of 2 A server watches 10,000 sockets. With which interfaces is the whole list handed to the kernel on every wait?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: select and poll

    epoll_ctl and kevent register interest once, so a wait costs work for the ready descriptors only.

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:

One wait over 10 to 10,000 connections, with poll and with kqueue Python · poll_vs_kqueue.py
# 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

  1. Question 1 of 2 In the measurement, one poll over 10,000 connections took thousands of times as long as one kqueue wait, though both found the same single ready connection. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: poll was handed all 10,000 descriptors and the kernel checked each one; kqueue already held the set and returned the one ready socket

    The poll column grew about tenfold or more for every tenfold step in connections; the kqueue column stayed under a microsecond.

  2. Question 2 of 2 select can watch only descriptor numbers below FD_SETSIZE. What is FD_SETSIZE, according to select(2)?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1024 descriptor numbers

    The manual page warns that select can monitor only descriptor numbers below FD_SETSIZE (1024), and that modern applications should use poll or epoll instead.

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.

  1. Question 1 of 9 What does a readiness interface report?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: Which descriptors can be read or written now without blocking

    select, poll, epoll and kqueue all report readiness; they differ in how the watched set is handed over.

  2. Question 2 of 9 Which model do Windows I/O completion ports follow?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: Completion

    Windows and io_uring are the two completion interfaces in the lesson's table; epoll and kqueue are readiness.

  3. Question 3 of 9 A Node.js server runs on Linux, macOS and Windows. Which mechanism does libuv use on each?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: epoll on Linux, kqueue on macOS, I/O completion ports on Windows

    The program sees one event loop; the library maps it onto each system's interface.

  4. Question 4 of 9 Why does a server with 10,000 mostly idle connections usually avoid a thread per connection?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: 10,000 threads that mostly sleep still cost memory and scheduling, while one thread with a readiness interface can serve them all

    Readiness interfaces let one thread wait on every connection at once, and serve each only when it has data.

  5. Question 5 of 9 As the lesson's first section defines it, what does edge-triggered notification do?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: It reports a descriptor once each time new data arrives

    The two styles report the same readiness at different moments: on every wait while it lasts, or once per change.

  6. Question 6 of 9 A server calls poll 2,000 times a second over 5,000 watched sockets. How many descriptor checks does the kernel make each second?

    Type a number.

    A hint

    Every call checks every watched descriptor.

    Show the answer: Question 6 of 9

    Answer: 10000000 checks

    2,000 calls × 5,000 descriptors = 10,000,000 checks a second, nearly all of them finding nothing on mostly idle connections. With epoll or kqueue the work per wait follows the ready descriptors instead.

  7. Question 7 of 9 The same server moves to epoll, and on average 3 of its 5,000 sockets are ready per wait. What does each wait now cost, roughly?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: Work for the 3 ready sockets, not for all 5,000

    This is the property the kqueue column showed: under a microsecond per wait from 10 to 10,000 connections.

  8. Question 8 of 9 When is poll a perfectly good choice?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: When a program watches a handful of descriptors, such as a pipe and a socket

    The choice follows the numbers: a fixed cost per watched descriptor is nothing for ten of them and a lot for ten thousand.

  9. Question 9 of 9 In the measurement, what happened to the cost of one kqueue wait as the connections grew from 10 to 10,000?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: It stayed under a microsecond

    A wait that costs the same at any size is what lets one thread serve tens of thousands of connections.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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.
  • select and poll are 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.