Threads versus Async versus Processes
There are three ways to have many things in flight — a thread per task, an event loop that parks tasks while they wait, or a process per task — and the choice is decided by whether the work is CPU-bound or I/O-bound, how many tasks you need, and what it costs to have one of them fail.
The problem
Three strategies for one problem
The problem is always the same: you have more logical tasks than cores, and most tasks spend most of their time waiting. Thread per task hands each task a kernel thread; when it blocks, the kernel parks it and runs another. Async (an event loop) keeps one thread per core, never blocks it, and expresses each task as a chain of callbacks or an async function that the runtime resumes when its I/O completes. Process per task is thread-per-task with isolation: each task gets an address space, so it can crash, leak or be killed alone.
They are not exclusive. A modern server typically runs *processes* for isolation (one per core, or per tenant), each running an *event loop* for the I/O-bound majority, offloading the CPU-bound minority to a *thread pool*. Knowing which layer does what is the point of this lesson; The Event Loop and Async I/O: What `await readFile()` Actually Does cover the async mechanism in detail, and The Thread Pool Server and The Event-Driven Server build the server versions.
Concurrency is not parallelism
Concurrency is structure: many tasks in progress, interleaved. Parallelism is execution: many tasks running at the same instant on different cores. An event loop on one thread is fully concurrent (10,000 open connections, each mid-request) and has zero parallelism. Eight threads on eight cores are parallel. Threads on a single core are concurrent but not parallel — Concurrency versus Parallelism spells it out.
The distinction decides the strategy. I/O-bound work (waiting on sockets, disks, databases, other services) needs concurrency, not parallelism: the CPU is idle anyway, and what matters is how cheaply you can keep a task suspended. CPU-bound work (compression, image transforms, JSON parsing at scale, cryptography) needs parallelism: a task that never waits gains nothing from an event loop and everything from another core. An async runtime that receives a CPU-bound task stalls every other task on the loop for its duration — the single most common async performance bug.
- I/O-bound: async or many threads; throughput is bounded by the remote system and by how many waits you can hold cheaply.
- CPU-bound: threads or processes up to the core count; more than that is context-switch overhead.
- Mixed: event loop for I/O, thread pool (or process pool) for CPU, with a queue between them.
Cost per unit
The table gives typical figures. Per-thread memory is dominated by the stack reservation — 8 MB virtual on glibc Linux, 1 MB on Windows, 512 kB for Rust’s default, with only the touched pages committed — plus the kernel’s task state. An async task is a heap object: a Promise and its closure in JavaScript (hundreds of bytes to a few kB), a coroutine frame in Python (~1–2 kB), a Future state machine in Rust (as small as tens of bytes). The 100× to 1,000× gap in memory and switching cost is why 10,000 kernel threads is a serious number and 1,000,000 async tasks is routine.
| Thread per task | Async task | Process per task | |
|---|---|---|---|
| Memory per unit | ~MB virtual stack, ~10s of kB committed | ~100 B – few kB heap | ~MBs private data + page tables |
| Creation | ~10–50 µs | ~100 ns – 1 µs (allocate an object) | ~50 µs – 1 ms |
| Switch between units | ~1–5 µs (kernel) | ~10–100 ns (a function return / resume) | ~2–5 µs + TLB/cache cold |
| Practical ceiling | ~thousands to low tens of thousands | ~millions | ~hundreds to low thousands |
| Uses multiple cores | yes | no, per loop; run one loop per core | yes |
| A task blocks (sync call) | only that thread | the whole loop | only that process |
| A task crashes | whole process | whole process (loop) | that process only |
| Code shape | sequential, blocking calls | async/await or callbacks; colouring | sequential; IPC to share |
Where each wins
Threads win when tasks are CPU-heavy or call blocking libraries you do not control (a legacy C client, a driver with no async API), when the count is modest (a few hundred), and when sequential code is worth more than peak connection count. Thread pools bound the count and reuse the expensive creation. Async wins when the work is dominated by waiting and the count is large — proxies, chat servers, API gateways, anything holding thousands of idle sockets — and when a runtime already does it well (Node, asyncio, Tokio, Go’s scheduler, which is async underneath sequential-looking code). Processes win when isolation matters more than sharing: untrusted code, crash containment, per-tenant limits, or a runtime that cannot use cores otherwise (CPython in the default build).
The failure mode of picking wrong is asymmetric. Threads where async was needed gives a server that runs out of memory or scheduler capacity at a few thousand connections — the C10K problem, see C10K: Ten Thousand Connections, Then a Million. Async where threads were needed gives a server whose p99 latency is dominated by the longest CPU-bound task, because everything else waits behind it. Processes where threads were needed gives a system that spends its time copying data through pipes.
Mixing them correctly
The production shape is layered. Node runs its event loop on one thread and delegates file I/O, DNS and crypto to a libuv thread pool (4 threads by default); heavy CPU work goes to worker_threads or a separate process. Python’s asyncio offers loop.run_in_executor to push a blocking call to a thread pool or a ProcessPoolExecutor for CPU work. C++ servers pair an io_uring/epoll reactor per core with a work-stealing pool. Go hides the whole arrangement: goroutines are async tasks, the runtime multiplexes them over GOMAXPROCS kernel threads, and blocking syscalls are detected and moved off the loop.
Two rules keep the mix honest. Never call a blocking function on a loop thread — a synchronous file read, a sleep, a lock held across a slow section — because it stalls every task on that loop, and the symptom is latency with idle CPU (The Event Loop shows how to see it). And size the pools from the resource they consume: CPU pools at the core count, I/O pools at whatever the downstream can absorb, not "as many as possible".
Key points
- Thread per task, async task on an event loop, and process per task are three answers to "more tasks than cores"; production systems layer all three.
- I/O-bound work needs concurrency (cheap suspended tasks); CPU-bound work needs parallelism (more cores); an async loop given CPU-bound work stalls every other task.
- A kernel thread costs a MB-scale stack reservation and microsecond switches; an async task costs kilobytes and nanosecond resumes — a 100–1,000× gap that sets the practical ceilings.
- Threads and async share the process’s fate on a crash; only processes contain it.
- Never block a loop thread; size CPU pools to cores and I/O pools to what the downstream can take.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why does async exist if the OS already provides threads?
Because a kernel thread is too expensive to hold per idle connection: at 10,000 connections the stacks, the kernel state and the scheduler’s work dominate. Async makes the suspended task a small heap object the kernel never sees.
▸Why not make everything async, then?
Async gives no parallelism by itself and cannot suspend a CPU-bound computation or a blocking library call; those still need threads or processes.
▸Why keep processes around at all?
They are the only unit the kernel can isolate, limit and kill independently; every strategy that needs crash containment or untrusted code ends at a process boundary.
How it fails
What the failure looks like from inside real software.
- A Node or asyncio service shows p99 latency of seconds with CPU at 30%: a CPU-bound task (JSON parse of a 50 MB body, sync compression) is blocking the loop.
- A thread-per-connection Java service hits
OutOfMemoryError: unable to create native threadat ~8,000 connections: stack reservations and thepidslimit, not heap. - A CPython service uses threads for a CPU-bound batch job and gets no speed-up on 16 cores in the default build; the work needed a process pool.
- A Go service stalls under load because a cgo call blocks a kernel thread without yielding; the runtime has to spawn threads to compensate and thread count balloons.
- A thread pool sized at 500 for "throughput" on 8 cores makes latency worse: the CPU is oversubscribed and every task waits behind 60 others.