The flip: from 'it's ready' to 'it's done'
Guides 2 and 3 built the whole machine around one promise the kernel makes: a descriptor is ready. epoll wakes you and says "a read() on this fd will not block right now" — and then you, in user space, call read() yourself to actually move the bytes. That is the readiness model, and it is the only thing select(), poll(), epoll, and kqueue ever offer. Every one of them tells you when to act; none of them acts for you. This guide is about throwing that contract away.
The completion model asks a different question. Instead of "tell me when I can read," you say "here is a buffer — go read 4 KiB into it, and tell me when you are done." The kernel takes ownership of the whole operation: it waits for data, it copies the bytes into your buffer, and only then does it hand you back a small completion record saying "that read you asked for finished; it moved 1280 bytes; here is the result." You never call read() at all. You submit a wish and later collect a result. Readiness is a doorbell; completion is a delivery.
Reactor becomes proactor
Guide 3 gave a name to the readiness-based event loop: the reactor. A reactor waits for events, and when a descriptor becomes ready it reacts by dispatching to a handler that performs the I/O. Completion-based I/O has its own name and its own shape: the proactor. A proactor does not wait for readiness and then act; it pro-actively launches the operation up front, hands it to the kernel, and waits only for the finished result to come back. Same event loop skeleton, opposite division of labor.
Walk one connection through each, and the difference becomes physical. In a reactor: epoll_wait returns "fd 7 readable" → you call read(7, buf, 4096) → the kernel copies bytes up to you → you process them. The read happens in your thread, after the wake-up. In a proactor: you submit "read 4096 from fd 7 into buf" → your thread sleeps → the kernel does the read whenever data arrives → you are woken with "read on fd 7 done, 1280 bytes" → you process them. The read happened in the kernel, before the wake-up. The handler in a reactor starts the work; the handler in a proactor finishes it.
io_uring: two shared rings, almost no syscalls
Linux's completion engine is io_uring, added in kernel 5.1 in 2019 — young, and still evolving. Its central trick is mechanical and beautiful: instead of one syscall per operation, the kernel and your program share two ring buffers in memory that both sides can see. One is the submission queue (SQ): you write your wishes into it. The other is the completion queue (CQ): the kernel writes finished results into it. Both are mapped into your address space, so reading and writing entries is just ordinary memory access — no kernel crossing at all to post a request or to see a result.
your program (user space) kernel +---------------------+ | submission queue SQ | ---you write---> reads SQEs, | [read fd7 ->bufA] | does the I/O, | [write fd9 bufB ] | writes results +---------------------+ +---------------------+ | completion queue CQ | <---kernel writes--- [fd7 done: 1280] | [fd7 done: 1280 ] | ---you read---> [fd9 done: 512] +---------------------+ fill SQ entries in memory (no syscall), then optionally io_uring_enter() ONCE to submit a whole batch + reap completions. with SQPOLL, a kernel thread polls the SQ and you skip even that.
Follow one request through the rings. You take a free slot in the SQ and fill in a submission queue entry (an SQE): the opcode IORING_OP_READ, the fd, the buffer pointer, the length, and a user_data tag you choose so you can recognize the answer later. That is pure memory writing — no syscall yet. When you have queued as many as you like, you make one call, io_uring_enter(), telling the kernel "I added N entries; go." The kernel drains the SQ, performs every operation, and posts a completion queue entry (a CQE) for each — carrying your user_data tag and the result (bytes moved, or a negative errno). You read the CQEs straight from shared memory. One syscall just moved a whole batch of reads and writes.
Now the punchline that makes io_uring special. Because the rings live in shared memory, batching means many operations cost one io_uring_enter() instead of one syscall each. And you can go further: in SQPOLL mode a dedicated kernel thread spins watching the submission queue, so when you add SQEs it notices and starts them with no syscall from you at all. At that point a busy server can submit thousands of reads and writes and reap their completions while crossing into the kernel essentially zero times — the readiness model's per-event syscall tax, which guide 2 measured as the C10k bottleneck, collapses toward nothing.
IOCP: Windows had the proactor first
If io_uring feels like a fresh idea, here is an honest piece of history: Windows shipped a completion-based design over twenty years earlier. The I/O completion port (IOCP) arrived with Windows NT in the mid-1990s, and it is the proactor model, full-grown, long before Linux had anything like it. The shape will now feel familiar. You start an operation up front with an overlapped call — ReadFile() or WSARecv() handed an OVERLAPPED structure — which returns immediately without doing the read; the kernel takes the job and the buffer.
The clever organizing piece is the completion port itself: a kernel queue you bind many sockets and files to. Every overlapped operation on any of them, when it finishes, posts its result onto that one port. Your worker threads sit in GetQueuedCompletionStatus(), each blocking until a finished operation pops out, then handling it and looping back for the next. One port, a small pool of threads, and the kernel hands each finished I/O to whichever worker is free — it even limits how many run at once to match your CPU count, so the threads do not stampede. It is a clean, batched, completion-delivery loop, just spelled in Win32.
So the two giants meet from opposite directions. Windows was born a proactor: its scalable path has always been completion-based, and there is no Windows epoll because there was never a need for one. Linux was born a reactor: epoll was its scalable path for two decades, and io_uring is the recent arrival that finally gives it a true proactor. This is also why cross-platform runtimes do real work here. A library like libuv presents one async interface and quietly drives epoll/kqueue underneath on Unix and IOCP on Windows — and increasingly io_uring on modern Linux — so your application code never has to know which model the OS speaks.
The price of the flip: ownership, lifetime, and honest limits
Completion-based I/O is not free elegance; it moves real responsibility onto you, and the move is subtle enough to deserve naming plainly. The deepest one is buffer ownership. When you submit a read, you lend the kernel your buffer for the entire duration of the operation, and you may not touch it, free it, or let it go out of scope until the matching completion arrives. In C this is a live trap: a buffer on the stack of a function that returns before the completion lands becomes a dangling region the kernel is still writing into — silent memory corruption of exactly the kind the debugging and UB rungs warned about. The completion's user_data tag is how you reunite a result with the right still-alive buffer; get that bookkeeping wrong and you corrupt memory or process a stale read.
There is a second, happier consequence of the kernel owning the operation: it opens the door to zero-copy. Because the kernel controls the whole transfer end to end, it can sometimes move data from the network card or page cache straight to its destination without the extra copy that read() into a user buffer normally forces — guide 5 takes this up directly. And there is an honest caveat to balance the praise: io_uring is genuinely new and has had a rough security history, with a real run of kernel vulnerabilities that led some hardened environments (parts of Google, Android, ChromeOS) to disable it by default. It is powerful and it is the future of Linux I/O, but it is not yet the boring, battle-worn default that epoll is.
Holding the shape
Assemble it. The readiness model of guides 2 and 3 makes the kernel a doorbell — "this fd is ready, now you read" — which costs at least two kernel crossings per event and organizes into a reactor. The completion model makes the kernel a delivery service — "the read you asked for is done, the bytes are already in your buffer" — which folds discovery and execution into one submitted operation and organizes into a proactor. Linux reaches it with io_uring's two shared rings and near-zero syscalls; Windows has had it for decades as IOCP's completion port. Same goal, opposite native histories.
Carry three things into guide 5. First, the price of the flip is yours to pay: while the kernel owns the operation, the buffer is the kernel's, and lifetime mistakes corrupt memory silently. Second, that same kernel ownership is what unlocks zero-copy and vectored I/O, the very subjects guide 5 opens with. Third, none of this replaces the accept-and-scale problem — you still have to get connections in the door and spread them across cores, herd and all, which is where the last guide of this rung lands. You now hold both halves of the high-performance I/O picture: how the kernel tells you (readiness vs completion), and next, how to move the bytes and scale the accept once it does.