Event-Driven vs Threaded Concurrency
It is 1999 and Dan Kegel poses the C10K problem: can a single server handle
ten thousand simultaneous clients? The obvious design —
one thread per
connection — hits a wall long before that. Twenty-five years later the bar is
C10M (ten million), and the architecture that gets there looks nothing like
thread-per-connection. This lesson is the great fork in the road of server design: a thread for
every client versus a single event loop juggling them all, and the coroutine
middle path that tries to give you both.
The whole debate turns on one number: what does an idle, waiting connection cost? In the threaded
model, a blocked connection costs a whole thread — a megabyte or two of stack sitting idle, plus a slot in
the scheduler. In the event-driven model, a waiting connection costs a few hundred bytes of state in a hash
table. Multiply by ten million and the difference decides whether your server runs or falls over.
Two ways to wait
A server spends almost all its life waiting — for a client to send a request, for a disk to
return data, for a socket buffer to drain. The two architectures are two answers to "what does a thread do
while it waits?"
- Thread-per-connection (blocking): each connection gets its own thread that calls
\texttt{read()} and blocks — the kernel parks it until data arrives.
The code is beautifully simple and linear (read, process, write, repeat), and the kernel scheduler does
all the multiplexing. But every blocked thread holds a full stack, and 10,000 threads means gigabytes of
stacks and a scheduler thrashing through context switches.
- Event-driven (the reactor): a single thread runs an event loop.
It asks the kernel "which of my thousands of sockets are ready right now?", then runs a short, non-blocking
handler for each ready one, and loops. One thread, one stack, no per-connection thread cost — but the
code inverts into callbacks, and one handler that blocks or runs long stalls everyone.
The pivot that makes the event loop possible is non-blocking I/O: a socket set to
non-blocking mode, when read with no data available, returns immediately with
\texttt{EAGAIN} instead of parking the thread. The event loop never blocks on any
single socket; it blocks only in one place — the readiness call that waits for any socket to
become ready.
select/poll to epoll: the O(1) readiness revolution
The event loop needs to ask the kernel "which sockets are ready?" The old syscalls,
\texttt{select()} and \texttt{poll()}, answer this
but with a fatal flaw: you pass in the entire set of file descriptors every call, and the
kernel scans all of them to find the ready few. That is O(n) per loop
iteration in the number of connections — so with 10,000 mostly-idle connections you scan 10,000 fds to find
the 5 that are ready, every single time around the loop. The cost grows with connections even though the
work does not.
Linux's \texttt{epoll} (and BSD's \texttt{kqueue},
Windows' IOCP) fix this. You register interest in each fd once, and the kernel maintains a
ready list internally, updated by interrupts as data arrives. Each
\texttt{epoll\_wait()} returns only the fds that are actually ready — so
the cost is O(\text{ready}), independent of the total connection count. That
single change from O(n) to O(1) per event is what
made C10K, and then C10M, achievable.
| API | Cost per loop | Set passed each call? | Scales to millions? |
select/poll | O(n) (n = all fds) | yes — rescanned every call | no |
epoll / kqueue | O(\text{ready}) | no — registered once | yes |
The reactor loop, drawn
The event-driven server is called the reactor pattern: it reacts to readiness
events. Follow the cycle. The single thread parks in \texttt{epoll\_wait()}; the
kernel wakes it with a list of exactly the sockets that now have data; the loop dispatches a short,
non-blocking handler for each; the handlers do a little work and return immediately; and the loop
goes back to wait. No handler ever blocks — if one needs the disk, it kicks off async I/O and returns,
arranging to be called again when that completes.
The elegance is that ten thousand connections flow through this one loop with one stack and no
per-connection thread. The catch is written on the handler box: must not block. A single
handler that does a synchronous disk read, a slow computation, or (the classic sin) a blocking DNS lookup
freezes the entire server — every other connection waits behind it. Event-driven code trades the kernel's
pre-emptive fairness for the programmer's discipline.
The stack tax, and why threads run out first
Why can't you just spawn a million threads and be done? The dominant cost is stack memory.
Each thread needs its own stack, and the default on Linux is 8 MB of virtual address space
(though only touched pages are physically backed). Even at a frugal 64 KB of resident stack per
thread, a million threads is 64 GB of RAM doing nothing but holding stacks for connections that are
99% idle. Add scheduler overhead — a million runnable threads is a scheduler's nightmare — and the model
collapses. Let's compare the two models' memory footprints directly.
// Compare memory footprint: thread-per-connection vs a single event loop, as connections scale.
const THREAD_STACK_KB = 64; // resident stack per blocked thread (frugal estimate)
const EVENT_STATE_B = 512; // bytes of heap state per connection in an event loop
function threadedMemMB(conns: number): number {
return (conns * THREAD_STACK_KB) / 1024; // MB
}
function eventMemMB(conns: number): number {
return (conns * EVENT_STATE_B) / (1024 * 1024); // MB (one stack, tiny per-conn state)
}
console.log("conns threaded event-loop ratio");
for (const c of [1_000, 10_000, 100_000, 1_000_000]) {
const t = threadedMemMB(c), e = eventMemMB(c);
console.log(
`${c.toLocaleString().padStart(9)} ${(t.toFixed(0) + " MB").padStart(9)} ` +
`${(e.toFixed(1) + " MB").padStart(9)} ${(t / e).toFixed(0)}x`,
);
}
// Readiness cost per loop: select/poll is O(n); epoll is O(ready).
function scanCost(api: "poll" | "epoll", totalConns: number, ready: number): number {
return api === "poll" ? totalConns : ready; // fds examined per loop iteration
}
const conns = 100_000, ready = 20;
console.log(`\npoll examines ${scanCost("poll", conns, ready).toLocaleString()} fds/loop`);
console.log(`epoll examines ${scanCost("epoll", conns, ready).toLocaleString()} fds/loop (only the ready ones)`);
Coroutines: have your cake and eat it
The event loop is efficient but its callback-inverted code is painful (the infamous "callback hell").
Coroutines — green threads, fibers, \texttt{async}/\texttt{await}
— are the middle path that keeps the efficiency while restoring straight-line code. The idea: write code
that looks blocking (\texttt{let data = await read(sock)}), but at each
\texttt{await} the coroutine suspends and yields control back to an
event loop underneath, which runs other coroutines while this one waits. When the I/O completes, the
coroutine resumes exactly where it left off.
The trick is that a suspended coroutine saves only its small live state (a few hundred bytes), not
a full OS thread stack — so you can have millions of them cheaply, multiplexed onto a handful of OS threads
by a user-space scheduler. This is exactly how Go goroutines, Rust async,
Kotlin coroutines, and Node.js (single-threaded async under the hood)
reach C10M with readable code. You write it like threads; it runs like an event loop.
- Thread-per-connection — simple linear code; kernel does the multiplexing; cost is
one full stack + scheduler slot per connection. Fine to thousands, not millions;
- Event loop (reactor) — one thread, non-blocking I/O, O(1)
readiness via epoll; scales to millions but code inverts to callbacks and no handler may block;
- Coroutines / async-await — straight-line code that suspends at
\texttt{await}; a user-space scheduler multiplexes millions of tiny stacks
onto few OS threads. The efficiency of the event loop with the readability of threads.
The line between "thread" and "coroutine" is blurrier than it looks, and the difference is mostly
stack size and who schedules. Go's goroutines began life with tiny segmented stacks
(a few KB that grow on demand), scheduled by Go's own runtime rather than the kernel — which is precisely
why you can spawn a million of them. Old-school green threads (early Java, Erlang processes) did
the same trick in user space. The reason the OS can't just shrink its threads to match is that a kernel
thread must handle arbitrary C code that might recurse deeply or call blocking syscalls, so it
conservatively reserves a big stack and schedules pre-emptively. A language runtime knows more about its
own coroutines — where they suspend, how deep they go — so it can be far more frugal. "Cheap threads" and
"coroutines" are two names for the same escape from the kernel-thread tax.
The deadliest bug in event-driven code: a handler that blocks. Because the whole server is one
thread, a single \texttt{read()} on a blocking fd, a synchronous file read (many
OSes can't do truly async disk I/O easily — a real historical headache), a slow regex, or a
\texttt{getaddrinfo()} DNS lookup freezes every connection until it
returns. There is no scheduler to pre-empt it, because you gave that up for efficiency. Symptoms are
maddening: the server handles 100k connections fine, then one slow database query and all latencies
spike together. The disciplines: never call a blocking API from a handler; offload genuinely blocking work
(CPU-heavy or unavoidably-sync I/O) to a separate thread pool; and keep every handler short. The event loop
gives you scale in exchange for a vow of non-blocking — break the vow and it all falls down.