Processes vs threads vs async
Published
Three ways a server can juggle many pieces of work, and they differ in what they share and what they cost.
- A process is a running program with its own private memory. Think of separate flats in a building: each has its own kitchen, and a fire in one does not spread to the others.
- A thread runs inside a process and shares its memory with the other threads. Flatmates sharing one kitchen: cheap to coordinate, easy to get in each other's way, and one person leaving the gas on affects everyone.
- An async task (a coroutine, a promise chain, a goroutine) is a unit of work the runtime schedules itself, many per thread. One very organised flatmate keeping twenty recipes going on one stove, switching only when a dish needs to simmer.
Your Node.js app runs on 50 pods, each with a pool of 20 database connections. Postgres starts refusing connections and its memory climbs, while Redis, serving the same app, has 5,000 connections and is fine. Why do the two databases react so differently to many connections?
What each costs#
| Process | OS thread | Goroutine / virtual thread | Async task (event loop) | |
|---|---|---|---|---|
| Memory each | Megabytes | Stack reserved per thread (1 MB in Java, 8 MB virtual on Linux) | Starts around 2 KB (Go) and grows | Hundreds of bytes to a few KB |
| Created by | OS, fork | OS | Language runtime | Language runtime |
| Switching | OS context switch, microseconds | OS context switch, microseconds | Runtime switch, much cheaper | A function returning to the loop |
| Shares memory | No (needs IPC) | Yes | Yes | Yes, but one runs at a time |
| A crash takes down | Itself | The whole process | The whole process | The whole process |
| Runs on many cores | Yes | Yes | Yes (runtime spreads them) | No, one core per loop |
Rough figures, and real usage depends on what each connection holds, but the orders of magnitude are right. This gap is the C10K problem from 1999, and the reason event loops and lightweight runtimes took over network servers.
How real systems combine them#
Almost nothing uses only one model. Nginx runs one worker process per core, each with its own event loop: processes for parallelism and isolation, event loops for concurrency. Node.js in production is the same shape: one process per core or per container, each an event loop. Go runs goroutines over a thread per core, so you write blocking style code and get both. Chrome uses a process per site so a malicious page cannot read another tab's memory, and threads inside each. Postgres forks a process per connection for isolation, which is why a pooler like PgBouncer sits in front: thousands of app connections share a few dozen real backend processes.
An event loop only works if every callback returns quickly. A synchronous file read, a CPU
heavy loop, bcrypt at a high cost factor, or a big JSON.parse in a Node handler blocks every
other connection on that process. In Python, calling the blocking requests library inside an
async def does the same. Use async drivers, and push CPU work to worker threads or another
service.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Processes | Isolation of memory and crashes, true parallelism, simple mental model. | Heavy per unit, sharing data needs IPC or an external store. | Isolation matters (browsers, databases, sandboxes), or one worker per core. |
| Threads | Parallel on many cores, shared memory for caches and pools. | Locks and races, a crash kills the process, heavier than async tasks. | CPU parallel work in one process, thread pools for blocking libraries. |
| Async / event loop | Huge numbers of I/O bound tasks for very little memory, no locks in a single thread. | One core per loop, any blocking call stalls everyone, coloured functions (async spreads). | Network servers, proxies, gateways, chat and streaming. |
| Goroutines / virtual threads | Blocking style code with async level cheapness and multi core parallelism. | Still shared memory and races, runtime specific behaviour. | Go services, Java 21+ servers handling many concurrent requests. |
- Node.jsEvent loop (libuv), worker threads for CPU work
- GoGoroutines on a thread per core
- NGINXProcess per core, event loop in each
- PostgreSQLProcess per connection, pool with PgBouncer
- RedisSingle threaded event loop for commands
- Pythonasyncio, threads with the GIL, multiprocessing
In the interview#
Checkpoint#
1. Why does Postgres struggle with thousands of connections when Redis does not?
2. What happens if one thread in a multi threaded server crashes with a segfault?
3. Why does a long CPU computation hurt a Node.js server so badly?
- Process: own memory, isolated, heavy. Thread: shared memory, shared fate.
- Async task: KBs, cooperative, one core per loop. Never block it.
- Goroutines and virtual threads: cheap tasks over a thread per core.
- Real servers combine: process per core, event loop inside (Nginx, Node).
- Postgres is process per connection, so pool with PgBouncer.
A process is a running program with its own memory: strong isolation of crashes and memory, true parallelism, but megabytes per process and IPC to share data. A thread lives inside a process and shares its memory: cheaper and easy to share caches, but needing locks, and one crash kills the whole process. An async task is a lightweight unit the runtime schedules cooperatively on an event loop: a few kilobytes each, no locks in one thread, ideal for thousands of I/O bound connections, but limited to one core per loop and stalled by any blocking call. Goroutines and Java virtual threads give blocking style code with async level cheapness spread across cores. Real systems combine them: Nginx and Node run a process per core with an event loop inside, Chrome isolates sites in processes, and Postgres forks a process per connection, which is why it needs a pooler like PgBouncer while single threaded Redis handles thousands of connections.