"Handle 10,000 connections" has exactly two viable answers, and they are not philosophical preferences — they are two different contracts with the kernel. The thread-per-connection model gives each connection a thread that blocks on I/O. The event loop model gives all connections one thread that never blocks. Both exist because the alternative — thousands of blocked threads — is physically wasteful. Understanding the mechanics of each tells you which one your workload needs, and why Go ended up building a third model on top of both.
What a thread actually costs
A thread is not free state. On Linux, every thread gets its own kernel stack (typically
2 MB of virtual address space, 16 KB committed) plus a task_struct, and the scheduler
must scan it on every tick. Context switches take ~1–3 microseconds of pure overhead — not
counting the cache and TLB pollution that follows. Ten thousand threads means tens of
gigabytes of virtual memory and a scheduler that spends most of its time switching between
threads that are all, individually, waiting on one socket each. That is the C10K wall: not a
hardware limit, a scheduling tax.
Blocking vs non-blocking I/O
The distinction is the entire subject. A blocking read() on a socket parks the thread
in the kernel's wait queue and only returns when data arrives — which is exactly what makes
thread-per-connection simple: your code reads like synchronous code, and the kernel does the
waiting. A non-blocking socket returns immediately with EAGAIN when no data is ready.
The thread never waits — which means someone else has to know when the socket becomes
readable. That someone else is a readiness notification mechanism: select/poll
(O(n) scan of every fd on every call), or on modern systems epoll (Linux) and
kqueue (BSD/macOS), which maintain interest sets in kernel space and return only the
ready events — O(ready), not O(total).
The event loop: one thread, no waiting
// The heart of every event loop: wait for readiness, dispatch, repeat
for (;;) {
int n = epoll_wait(epfd, events, 1024, -1); // sleep until something is ready
for (int i = 0; i < n; i++) {
handle(events[i].data.fd); // non-blocking work, no I/O waits
}
}Every event-driven server you've used — nginx, Node.js, Redis — is this loop with different
flavors of handle. The single thread registers interest in thousands of sockets, parks in
epoll_wait (which is a blocking syscall, but blocks for all connections at once), and
does work only when work exists. Blocking work still blocks it — which is why event loops
route CPU-heavy jobs to worker threads and why they're terrible for embarrassingly
computational loads.
Why 10,000 connections forces the choice
Ten thousand connections, thread-per-connection: ten thousand threads, ~20 GB of virtual
address space, and a scheduler thrashing among them. Ten thousand connections on an event
loop: one thread, one epoll interest set, and memory proportional to open sockets, not
threads. The kernel never even notices the second design. Threads are the right tool when
concurrency is low and each unit of work is blocking (I/O-heavy, latency-sensitive, simple
math); event loops are the right tool when concurrency is high and work is mostly waiting.
That's not a style choice; it's an accounting statement about stack memory and scheduling.
The fairness gap
Threads are preemptively fair: the kernel interrupts them every scheduling tick, so a runaway thread is eventually yanked. An event loop is cooperatively fair: one callback that computes for 500 ms freezes every other connection for 500 ms. This is the loop's structural weakness — there is no preemptor. Every serious event-loop design compensates with explicit yielding (splitting work into chunks), worker threads for CPU-bound paths, and careful instrumentation.
Go's M:N scheduler: both models at once
Go sidesteps the choice with goroutines: cheap logical threads multiplexed onto a pool of
OS threads (the M:N model). When a goroutine does blocking I/O, the Go runtime rewrites
that I/O through its netpoller — an internal event loop over epoll/kqueue — so the
goroutine parks while the underlying thread is freed to run another goroutine. And when a
goroutine makes a syscall that truly blocks, the runtime hands the thread to another
goroutine and fetches a fresh one. You get thread-style blocking code with event-loop-style
resource usage, because the scheduler sits between the two models:
for conn := range listener { // thousands of these, one goroutine each
go handle(conn) // runtime does the multiplexing
}