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?"

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.

APICost per loopSet passed each call?Scales to millions?
select/pollO(n) (n = all fds)yes — rescanned every callno
epoll / kqueueO(\text{ready})no — registered onceyes

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.

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.