Processes, Threads and Async I/O
Open in the interactive app: practice, quiz, mock interview →In one minute
- A process is a running program with its own private memory, open files and at least one thread. The operating system keeps processes apart, so one crashing process does not corrupt another.
- A thread is one line of execution inside a process. Threads in the same process share memory, which makes them cheap to coordinate and easy to get wrong: two threads updating the same value at once is a race.
- Most server code spends its time waiting on the network, a database or a disk. Concurrency is about doing useful work during that waiting; parallelism is about using several CPU cores at the same moment. They are different tools for different bottlenecks.
- Async I/O lets one thread juggle thousands of waiting connections: it asks the kernel "tell me which of these are ready" and only runs code when there is something to do. The price is that one slow, blocking call stalls everything on that thread.
- The underlying problem shape is contention: many workers, one shared thing. It shows up as a data race between threads, two riders matched to the same driver, or two agents editing the same file, and the fixes (locks, single writers, message passing) are the same.
How it works
Processes: a program with walls around it
When you start a program, the operating system creates a process. It gets a process ID, its own virtual address space (address 0x1000 in one process means nothing in another), a table of open file descriptors (files, sockets, pipes), and one thread to run main. The walls are the point: a bug that scribbles over memory in one process cannot touch another, and the OS can kill a process and reclaim everything it held.
Processes talk only through channels the OS provides: pipes, sockets, files, or explicitly shared memory. A web server that runs 8 worker processes behind one listening socket, as Nginx and Gunicorn do, gets crash isolation and uses 8 cores, but the workers cannot share an in-memory cache without extra machinery.
Threads: several workers in one house
A thread is a program counter, a set of registers and a stack. Every thread in a process sees the same heap, globals and open files. Creating one is cheaper than creating a process, and passing data between threads is just passing a pointer.
The danger is that sharing is the default. Consider count = count + 1 run by two threads. Each reads count (say 41), adds one, and writes back 42. One increment is lost. This is a race condition: the result depends on the timing of instructions you do not control. The fix is to make the read-modify-write happen as one step, with a mutex (only one thread inside the critical section at a time), an atomic instruction, or by giving the data a single owner that other threads send messages to.
Concurrency versus parallelism
Concurrency means several tasks are in progress at once, possibly taking turns on one core. Parallelism means several tasks are executing at the same instant on different cores. A chef with three pans on the stove is concurrent; three chefs are parallel. If your work is CPU-bound (image resizing, compression, model inference on CPU), you need parallelism, which means more cores and more threads or processes. If your work is I/O-bound (most web backends), you need concurrency: plenty of requests in flight while each one waits.
Blocking I/O and the thread-per-request model
The simplest server gives each request its own thread. The thread calls read on a socket, and if no data has arrived, the OS puts the thread to sleep until it does. This is blocking I/O, and the code reads top to bottom like a script. It works well up to a few hundred or a few thousand concurrent connections. Beyond that, each thread costs memory for its stack and the scheduler spends more time switching between threads than running them.
Async I/O and the event loop
The alternative is to mark sockets non-blocking and use one thread to watch many of them. The thread asks the kernel, "of these 10,000 sockets, which have data now?", handles the ready ones, and asks again. This is an event loop. Node.js, Python's asyncio, Nginx and Redis are built this way. In code it appears as callbacks, promises, or async/await: each await is a point where your function says "I am waiting; run something else and come back to me".
The event loop has one hard rule: never block it. A 200 ms synchronous call (a CPU-heavy hash, a blocking file read, a synchronous HTTP client) freezes every other connection on that loop for 200 ms. Runtimes push such work to a separate thread pool, and you should too.
Green threads: the best of both
Go takes a third path. Goroutines look like ordinary blocking threads in code, but they are managed by the Go runtime, not the OS. They start with a stack of a few KB that grows as needed (2 KB in early versions; since Go 1.19 the starting size follows the average stack use seen so far) (Go FAQ, Go 1.19 notes), and when one blocks on a socket, the runtime parks it and registers the socket with the same kernel readiness mechanism an event loop uses. You write simple sequential code; the runtime does the event loop for you. Java's virtual threads, final in Java 21, follow the same idea: the JDK schedules them onto a small set of carrier threads and unmounts them when they block on most I/O, and the JEP says never to pool them and to use a semaphore to limit concurrency instead (JEP 444). In Java 21 a virtual thread blocking inside synchronized stayed pinned to its carrier; Java 24 removed that limitation (JEP 491).
Down the stack
Layer 1The contract
The OS promises each process an isolated address space and its own file descriptors, and promises that runnable threads will each get CPU time eventually, in no particular order. It does not promise when a thread runs or for how long, and without synchronisation other threads may see shared memory updates late or reordered. A non-blocking read promises either some bytes or an immediate "would block" error (EAGAIN); it never sleeps.
Layer 2The mechanism
On Linux, a process and a thread are both a task_struct in the kernel. fork creates a task with a copy-on-write copy of the parent's memory; pthread_create calls clone with flags such as CLONE_VM and CLONE_FILES, so the new task shares memory and open files; CLONE_THREAD puts it in the same thread group, so all threads share one process ID (clone(2)). The scheduler keeps runnable tasks per core and gives each a slice of CPU time. Linux used CFS for years and began moving to EEVDF in 6.6, which also lets a task request a specific slice through sched_setattr() (kernel EEVDF docs).
The event loop is built on epoll: epoll_create1 makes an interest list, epoll_ctl adds sockets to it, and epoll_wait sleeps until at least one is ready, then returns only the ready ones (epoll(7)). Its cost tracks ready sockets, not watched ones. By default epoll is level-triggered, like poll but faster; edge-triggered mode (EPOLLET) reports only changes and requires non-blocking sockets read until EAGAIN. Node's libuv wraps epoll (kqueue on macOS) plus a default pool of 4 threads (UV_THREADPOOL_SIZE, maximum 1024) for work with no non-blocking form, such as file system calls and DNS lookups through getaddrinfo (libuv docs, Node.js guide). That small pool is itself a trap: four slow fs or dns.lookup calls leave everything else queued behind them. Go's runtime scheduler multiplexes goroutines (G) onto OS threads (M) through logical processors (P, one per GOMAXPROCS), and its netpoller uses epoll underneath.
Layer 3The machine
A context switch between threads saves and restores registers and switches stacks. One careful measurement on Linux found about 1.2 to 1.5 µs per switch when pinned to a core and about 2.2 µs unpinned (Bendersky). The hidden cost is larger: the new thread's data is not in the L1 and L2 caches, so it runs slowly for a while. Each OS thread reserves virtual memory for its stack: the default comes from the RLIMIT_STACK soft limit, typically 8 MB, and only touched pages use RAM (pthread_create(3)). In that same measurement, 10,000 threads showed about 80 GB virtual but only about 80 MB resident. So 10,000 threads is feasible but heavy, while 100,000 goroutines at a few KB each is routine.
When a packet arrives, the kernel appends its bytes to the socket's receive buffer and marks the socket ready, waking whichever thread sleeps in epoll_wait or read. An uncontended mutex is one atomic compare-and-swap, tens of nanoseconds. Under contention, the losing thread sleeps in the kernel via futex, which is entered only when blocking is likely, and the lock's cache line bounces between cores (futex(2)).
Why it's built this way
- Programs crash and misbehave, so the OS isolates them in processes with separate address spaces.
- Isolation makes sharing expensive, so threads exist to share memory cheaply inside one process.
- Servers spend most of their time waiting, and a sleeping OS thread per waiting connection wastes memory and scheduler time.
- So kernels added readiness APIs (
select, thenpoll, thenepollandkqueue) that let one thread wait on many sockets at a cost tied to activity, not connection count. - Callback-style code is hard to write, so languages added
async/awaitand runtimes such as Go's hide the event loop behind ordinary-looking blocking code.
Diagram
sequenceDiagram participant L as Event loop thread participant K as Kernel (epoll) participant D as Database L->>K: epoll_ctl add sockets A, B, C L->>D: send query for A (non-blocking) L->>D: send query for B (non-blocking) L->>K: epoll_wait D-->>K: reply for B arrives K-->>L: B is readable L->>L: run handler for B, send response L->>K: epoll_wait D-->>K: reply for A arrives K-->>L: A is readable L->>L: run handler for A, send response
Worked example
An API endpoint spends 2 ms of CPU per request and 98 ms waiting on a database. Peak traffic is 2,000 requests per second.
Thread-per-request with a fixed pool: by Little's law (the JEP 444 text uses the same law to explain why thread-per-request caps throughput), requests in flight equal arrival rate times time in system, 2,000 × 0.1 s = 200, so the service needs at least 200 threads plus headroom. CPU needed is 2,000 × 2 ms = 4 core-seconds per second, so 4 cores busy. If the pool were capped at 100 threads, the service could only finish 100 / 0.1 s = 1,000 requests per second, and the rest would queue, even though the CPUs sit half idle. That is the classic sign of thread pool exhaustion: latency climbs while CPU stays low.
Event loop: one thread can hold all 200 in-flight requests because waiting costs only a registered socket. Its limit is CPU: 1 s / 2 ms = 500 requests per second per loop, so run 4 to 5 processes (one loop per core) behind a load balancer.
Now someone adds a synchronous password hash that takes 50 ms to one endpoint, called on the event loop. Each loop can now finish at most 20 of those per second, and every other request on that loop waits behind it. p99 latency jumps from about 100 ms to several seconds. The fix is to move the hash to a worker thread pool (Node's worker_threads, Python's run_in_executor) so the loop keeps serving.
In the wild
- AWS Kinesis, 2020: capacity add hits the OS thread limit. Each Kinesis front-end server created OS threads for every other server in the fleet, so a small capacity addition on 25 November 2020 pushed every server over the OS thread limit. Shard-map caches could not be built, and Kinesis was impaired until late that evening Pacific time, with CloudWatch, Cognito and others affected. AWS moved to fewer, larger servers, added alarms on thread use and began moving the cache to a dedicated fleet (AWS post-event summary).
- Roblox, 2021: 73-hour outage from contention on one Go channel. After enabling Consul's streaming feature, heavy read and write load caused contention on a single Go channel that blocked writes. A separate BoltDB freelist problem made each log append rewrite a 7.8 MB freelist. Disabling streaming cut median KV write latency from about 2 s to 300 ms (Roblox post-mortem).
- Node.js, ongoing: one regex or sync call stalls every client. The official guide shows a nested-quantifier regex that can block the event loop effectively forever on a short crafted input, and a 50 MB
JSON.parsethat takes about 1.3 s (Node.js guide).
Trade-offs
| Model | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Multiple processes | Crash isolation, uses all cores, no shared-memory races | Higher memory, coordination via IPC | Isolation matters; runtimes with a global lock |
| OS threads, blocking I/O | Simple sequential code, true parallelism | Memory per thread, switch cost, data races | Hundreds to low thousands of connections, CPU work |
| Event loop, async I/O | Tens of thousands of connections per thread | One blocking call stalls all, harder code paths | I/O-bound services, proxies, chat, gateways |
| Green threads (goroutines, virtual threads) | Sequential code with event-loop efficiency | Runtime-specific, still need locks for shared data | High-concurrency services in Go or modern Java |
Pitfalls in production
- Blocking the event loop with CPU work or a synchronous library call. Fix: measure event loop lag, and move blocking or CPU-heavy work to a worker pool. Beware catastrophic regex backtracking and synchronous
*SyncAPIs too: both run on the loop (Node.js guide). - Unsynchronised shared state, such as a counter or a map updated from several threads. Fix: protect it with a mutex or atomic, or give it one owner; run the race detector (
go test -race) in CI. - Thread pool exhaustion when a downstream slows down: every thread waits, new requests queue, latency explodes while CPU looks idle. Fix: timeouts on every outbound call, bounded queues, and rejecting work early (backpressure).
- Deadlock from two locks taken in opposite orders by two threads. Fix: always acquire locks in one global order and keep critical sections short.
- Expecting threads to speed up CPU-bound Python. CPython's global interpreter lock lets only one thread run Python bytecode at a time (an optional free-threaded build arrived experimentally in 3.13 and became officially supported, but still optional, in 3.14; the GIL build remains the default) (PEP 779). Fix: use processes for CPU-bound Python.
- Unlimited concurrency: spawning a goroutine or promise per item for a million items can overwhelm a database. Fix: cap concurrency with a semaphore or worker pool.
Leadership lens
- In design review, ask "is this workload CPU-bound or I/O-bound?" first. The answer picks the concurrency model, the instance type and the scaling metric.
- Standardise one concurrency model per service and per team. Mixing blocking and async libraries in one codebase is a steady source of outages.
- Require timeouts and concurrency limits on every outbound call; they turn a slow dependency into a handled error rather than a full outage.
- Watch event loop lag, thread pool queue depth and saturation alongside CPU. Low CPU with high latency is a concurrency problem, not a capacity problem.
Check yourself
A service has low CPU usage but rising latency and a growing request queue. What is a likely cause?
Show answer
All worker threads are blocked waiting on a slow dependency (thread pool exhaustion), so new requests queue even though the CPUs are idle.
Why can one event loop thread handle 10,000 connections while 10,000 OS threads struggle?
Show answer
A waiting connection on an event loop costs only a registered socket, and
epoll_waitreturns just the ready ones; each OS thread costs stack memory, kernel structures and context switches.Two threads each run
x = x + 1a million times on a sharedxwithout a lock. Why can the result be less than two million?Show answer
The increment is a separate read, add and write; if both threads read the same value before either writes, one update overwrites the other and is lost.
When would you choose processes over threads for parallel work?
Show answer
When you need crash isolation, or the runtime cannot run threads in parallel (such as CPython with its global interpreter lock) for CPU-bound work.
Sources and further study
Primary
- JEP 444: Virtual Threads and JEP 491: thread-per-request, Little's law and the pinning limitation and its removal.
- PEP 779: free-threaded Python: the phases from experimental to supported.
- Go 1.19 release notes: adaptive initial goroutine stack size.
Systems docs
- epoll(7), clone(2), futex(2) and pthread_create(3): the Linux mechanisms this lesson names.
- libuv thread pool docs and Node.js: Don't Block the Event Loop: what runs on the loop and what on the pool.
- Go FAQ: goroutines: how goroutines multiplex onto threads and what
GOMAXPROCScontrols. - Linux EEVDF scheduler docs: the move away from CFS.
Books
- Operating Systems: Three Easy Pieces, Ch 4 Processes, Ch 5 Process API, Ch 6 Direct Execution, Ch 7 CPU Scheduling, Ch 27 Thread API, Ch 28 Locks, Ch 33 Event-based Concurrency: free at the OSTEP site.
- Systems Performance, 2nd ed. (Brendan Gregg), Ch 3 Operating Systems and Ch 6 CPUs: scheduling, context switches and CPU saturation.
Other curricula
- System Design Primer: treats asynchronism (message queues, task queues, back pressure) as a way to move slow work off the request path, the system-level cousin of not blocking the event loop.
Watch
- What the heck is the event loop anyway? (Philip Roberts, JSConf EU): the call stack, callback queue and event loop shown visually.
- Concurrency is not Parallelism (Rob Pike): why structuring work concurrently is different from running it in parallel.
Free resources
- Operating Systems: Three Easy Pieces: free chapters on processes, scheduling, threads, locks and event-based concurrency.
- The Node.js event loop, timers and process.nextTick: the phases of a real event loop, from the official docs.
- Python asyncio documentation: the standard library's event loop, tasks and executors.
- epoll(7) man page: the Linux readiness API behind most event loops.
- Concurrency is not parallelism (Go blog): slides and notes for Rob Pike's talk.
- Thread (computing) on Wikipedia: background on threads, processes and scheduling models.