“It’s slow” is the least useful bug report in software. “It’s slow when 400 users hit the checkout endpoint simultaneously, but fine with 40” is a diagnosis waiting to happen — and the difference between those two sentences is concurrency. Not the kind measured in benchmarks, but the kind measured in how many things can be in flight at once before your program stops making progress on any of them.
This post is about the unit of “a thing in flight.” Processes, threads, and coroutines are all answers to the same question — how do we get more than one thing done at a time — and they differ in how much they share, how much they protect you, and what they cost. Understanding the trade-offs is the difference between a system that scales and a system that merely runs.
Processes: isolation first
A process is an operating system’s idea of a program: private virtual memory, its own file descriptor table, its own address space, scheduled by the kernel. The isolation is the point — one process crashing does not corrupt another, and the boundary between them is enforced by hardware. The cost is that nothing is shared: creating a process means duplicating the environment, and communicating between them requires the kernel to broker (pipes, sockets, shared memory segments), which means context switches and copies.
The payoff is robustness. Browsers put every tab in its own process so a runaway page dies alone. Postgres and Redis are typically single-threaded per connection or per command loop but multi-process where it counts. NGINX and Apache both use multiple processes for connection handling. When the failure domain matters more than the sharing, processes win.
Threads: shared memory, shared danger
A thread is a sequential flow of execution that shares the process’s memory: heap, code, open files. The kernel schedules threads independently, each gets its own stack and registers, and the switch between them is cheaper than a process switch because the address space stays mapped. Threads were the default answer to concurrency for decades, and they still are for CPU-bound parallelism.
The catch is the shared heap. Any thread can write any memory, which means any two threads touching the same data need synchronization — locks, atomics, careful ordering — and getting it wrong produces data races, undefined behavior in most languages, and heisenbugs that appear once a week in production and never under the debugger. Entire subfields of computer science exist because of the shared heap. The scheduler can also pause a thread at any instruction, which is what makes preemption both powerful and terrifying.
// The classic race: two threads, one counter.
int counter = 0;
void increment() {
counter++; // read, add, write — three steps, not one
}
That counter++ is three machine instructions — read, increment, write — and the scheduler can interleave them between two threads freely. The result is lost updates: run it a million times on two threads and the counter lands short of two million. The fixes (mutexes, atomic instructions) all boil down to making those steps indivisible, at the cost of coordination overhead and the eternal risk of deadlock.
Coroutines: cheap concurrency by giving up preemption
Coroutines attack the cost side. A coroutine is a function that can suspend itself and be resumed later — the runtime, not the kernel, manages the switching, and switches happen only at points the code explicitly marks (a keyword like await in Python, suspend in Kotlin, or the yield points implicit in Go’s runtime scheduling). Because a coroutine owns its suspension points, it never gets preempted mid-instruction, and because it is just a small stack-plus-state object, you can have millions of them in the memory a single OS thread uses.
The trade is responsibility. Nothing forces a coroutine to yield; one busy CPU loop hogs its carrier thread and every coroutine riding it stalls. That is why async runtimes are so opinionated about never blocking the loop — a single synchronous file read or DNS lookup parked on the kernel can freeze thousands of coroutines — the reason the asyncio docs devote a page to it waiting behind it. Coroutines give you massive numbers of in-flight operations, on the condition that every one of them is either fast or async all the way down.
Go: goroutines as the hybrid answer
Go deserves its own paragraph because it refuses the traditional trade. A goroutine starts as a tiny stack (a few KB) that grows on demand, is scheduled by the Go runtime onto OS threads via an M:N scheduler, and — crucially — integrates with every blocking syscall in the standard library: when a goroutine blocks on I/O, the runtime parks it and lets another run on the freed thread. You get coroutine-level cost with thread-like semantics: blocking calls are transparently async under the hood.
// Go: blocking code, non-blocking reality
resp, err := http.Get(url) // parks goroutine, thread serves others
body, err := io.ReadAll(resp.Body)
process(body) // resumes when data arrives
This is why Go servers routinely hold hundreds of thousands of idle connections cheaply, and why the language never needed an async/await color problem: every function is potentially suspending, and no function is colored. The cost is runtime complexity and a GC, and the benefit is concurrency as an infrastructure detail rather than a programming model.
The decision, compressed
| Need | Reach for | Because |
|---|---|---|
| Crash isolation, untrusted code, hard boundary | Processes | Private memory; failure does not propagate |
| CPU-bound parallelism on multicore | Threads (or process pools) | Kernel schedules across cores; real parallelism |
| Massive I/O-bound concurrency (10k+ in flight) | Coroutines / async runtimes | Cheapest unit; suspension at known points |
| Both, without async coloring | Go-style goroutines | M:N scheduling with integrated blocking syscalls |
One more axis worth internalizing: preemption. Threads are preemptive — the kernel can stop any thread at any time, which is safe only with synchronization and is what makes fair sharing possible. Coroutines are cooperative — switching happens only at suspension points, which is why they are cheap and why a single tight loop can starve everything. Preemption buys fairness and costs synchronization; cooperation buys cheapness and costs discipline.
Wrapping up
The vocabulary is small, but it is load-bearing. Processes isolate. Threads share dangerously. Coroutines suspend cheaply. Everything else — thread pools sized to cores, event loops guarded like glass, process supervisors restarting crashed children — follows from those three properties. When the next “it’s slow under load” ticket arrives, the first question is not which tool to add but which unit of concurrency the workload actually needs. Get that right and the architecture mostly draws itself.