Lesson 12 · Python Concurrency / Runtime

How one thread handles 10,000 connections

Tied to Mission: Backend Depth — resolves the epoll pointer Lesson 5 dangled, and deepens Lesson 6's asyncio row from "cooperative multitasking" into the actual mechanism.

Lesson 5 ended on a hook: a single thread can wait on thousands of sockets at once, no thread per connection needed. This is that mechanism, and it's the entire secret of asyncio. But it comes with a trap so sharp that it has its own famous production incident shape: the service runs thousands of connections happily, one handler calls a blocking function, and every connection freezes at once. Understanding the loop is understanding both the trick and the trap.

The loop: wait for "ready," then run the ready things

The event loop is one thread running one simple cycle, over and over:

  1. Ask the OS: which of the sockets I'm watching are ready to be read/written, and are any timers due? (epoll_wait with a timeout — one syscall that blocks until something is ready.)
  2. Run the code that was waiting for each of those ready things.
  3. Repeat.

That's it. All the "10,000 connections" magic is in step 1: the OS does the waiting on your behalf. The kernel watches thousands of file descriptors and reports only the handful that are ready. No thread is sitting on each socket; one thread sleeps in epoll_wait and wakes only when there's actual work. Contrast the thread-per-connection design: each thread costs a kernel stack (~8 MB of virtual address space), scheduling time, and cache pressure — thousands of them make the OS the bottleneck even before your code runs. A task in asyncio is a small heap object; ten thousand of them are nothing. This is the same "waiting doesn't need a core" insight from Lesson 5, made into a programming model.

What await actually does

An async def function isn't a function that runs — it's a coroutine: a function that can pause itself. Calling it returns a coroutine object; nothing runs yet. The loop wraps it in a Task and drives it. When the coroutine hits await, it suspends: its local variables and position are saved in the task object, and control returns to the loop. The loop registers the awaited thing with epoll (or starts the timer) and goes back to step 1. When the socket is readable or the timer fires, the loop schedules the task to be resumed — the coroutine continues right where it left off.

So the concurrency in asyncio is interleaving at await points, not parallelism: one thread, running tasks in short slices, switching only when a task chooses to yield. The GIL from Lesson 6 isn't even the constraint here — there's a single thread by design. The tradeoff is that a task that never awaits (a tight CPU loop) runs forever and starves everything else.

The trap: blocking the loop blocks everything

Here is the failure mode that gives asyncio its reputation. The loop's single thread can only be in one place. If any handler calls something that blockstime.sleep(), requests.get(), a synchronous file read, subprocess.run() — that thread parks itself in the kernel until the call returns. Step 1 never runs. No other task gets to run, on any connection, until the blocking call finishes.

Notice what this isn't: it's not "that one handler is slow." It's "the entire process just stopped servicing everyone." The incident shape is unmistakable — a service that serves thousands of connections, then one code path starts calling a blocking HTTP client, and the whole service freezes in chunks that look like latency spikes, with a restart as the "fix." The rule that prevents it is one sentence: in asyncio, every I/O call on the hot path must be async-nativeasyncio.sleep, httpx / aiohttp, asyncpg, aiofiles — or explicitly offloaded to a thread via run_in_executor. CPU-bound work belongs in an executor or a separate process too: it doesn't block on I/O, but it occupies the single thread just the same.

Choosing between the Lesson 6 tools, sharpened

Lesson 6's decision rule said "I/O-bound with many connections → asyncio." The refinement this lesson adds: that's only true if your I/O stack is actually async. If you're stuck with a blocking SDK you can't replace (a database driver with no async port, a proprietary client), threads are the honest choice — blocking calls inside a thread pool stall that thread, not the world. asyncio's scaling is real, but it's rented from a contract you must keep: nothing on the loop blocks.

Check yourself

An asyncio service serves 5,000 WebSocket connections. One handler calls requests.get() — a blocking HTTP call — to fetch something. What happens to the other 4,999 connections?
Right — there is exactly one loop thread and one epoll_wait per iteration. While a blocking call occupies that thread, step 1 (and therefore every other task) waits. The whole service freezes, which is the signature of a blocking call in async code. Not quite — requests.get() is synchronous: it parks the calling thread in the kernel until the response arrives. In asyncio that thread is the loop itself. Re-read "The trap" section and trace where step 1 of the loop goes during the call.

Try it for real

Hands-on

Write two tiny scripts. First: async def w(n): await asyncio.sleep(n), run two of them concurrently, and time it — two 2-second sleeps complete in ~2s. Then swap one asyncio.sleep for time.sleep(2) inside the same async function and time again: total time jumps to ~4s, and if you add a third task that should complete immediately, it doesn't — the loop is blocked. That's the whole trap, visible in five lines. For the scaling half, run Lesson 5's socket demo again but with asyncio (asyncio.start_server) and watch thousands of connections cost one thread.

Primary sources

The official asyncio docs are the primary source for the API and the event loop policy. David Beazley's talk "Python Concurrency from the Ground Up: LIVE!" builds an event loop from first principles in front of you — the single best way to see the mechanism this lesson describes, and it also covers the select/epoll side Lesson 5 pointed at. Lesson 6's concurrency cheat sheet has the tool-comparison table this lesson refines.

Concurrency, memory, and networks all have their own failure shapes. The next three lessons move to the system level: what a service should do when it simply cannot keep up — Lesson 13.