Threads: Several Instruction Streams in One Process
A thread is an independently scheduled instruction stream inside a process: it has its own stack, registers and instruction pointer, and shares everything else — heap, globals, descriptors — with its siblings, which makes threads cheap to create and communicate through, and easy to corrupt.
The problem
One process, three threads
Split what a process owns into two piles. The execution pile: an instruction pointer, a stack pointer, the other registers, a stack, a scheduling state, a signal mask, a small block of thread-local storage. The environment pile: the address space, the heap, the globals, the code, the descriptor table, the working directory, the uid, the signal handlers. A thread is one execution pile. A process is one environment pile plus at least one thread. Creating a second thread means creating a second execution pile in the same environment.
The scheduler schedules threads, not processes. On Linux this is literal: the kernel’s unit is the task (task_struct), a "process" is a group of tasks sharing an mm_struct and a thread group id, and ps -T or /proc/<pid>/task/ shows each one with its own TID. The main thread’s TID equals the PID. On Windows, a process is a container object and threads are the schedulable objects inside it — the same split under different names.
Shared versus private
Sharing the address space is the feature and the hazard. A pointer created by thread A is valid in thread B with no translation, so passing a 100 MB structure between threads costs one pointer copy. But the same pointer means B can write the structure while A reads it, and nothing in the hardware prevents that: the two threads are simply two instruction streams touching the same bytes. Every rule about mutexes, atomics and memory ordering exists because of this one design decision — see Race Conditions and Mutexes.
Each thread’s stack is private by allocation, not by protection. It is an mmap’d region in the shared address space (Linux glibc reserves 8 MB of virtual space per thread by default, Windows 1 MB; the pages are committed on touch), so thread B *can* read or corrupt thread A’s locals if it has a pointer to them. Returning a pointer to a stack variable and handing it to another thread is undefined behaviour precisely because that stack frame is gone the moment the function returns. Thread-local storage (thread_local in C++, threading.local in Python, AsyncLocalStorage is the async analogue in Node) gives each thread its own copy of a named global, implemented as an offset from a per-thread base register.
| Item | Per thread | Shared by all threads |
|---|---|---|
| Instruction pointer, registers, flags | yes | |
| Stack (locals, return addresses) | yes (private by convention, not by protection) | |
| Scheduling state, priority, CPU affinity | yes | |
| Signal mask, errno, thread-local storage | yes | |
| Heap, globals, static data, code | yes | |
| Descriptor table, working directory, uid/gid | yes | |
| Signal handlers, address space limits | yes |
Kernel threads and user threads
A kernel thread (1:1 model) is one the kernel knows about and schedules directly: pthread_create on Linux calls clone() with CLONE_VM | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, and the result is a task the scheduler can put on any core. Every mainstream runtime — C++ std::thread, Java platform threads, Python threading, Rust std::thread, Node worker threads — uses this model. The costs are the kernel’s: creation ~10–50 µs, a context switch ~1–5 µs, and a kilobyte-scale kernel stack plus the user stack per thread.
User threads (M:N or green threads) are scheduled by the runtime on top of a few kernel threads: Go goroutines, Erlang processes, Java virtual threads, and in a degenerate form every async task in an event loop. They start with a tiny stack (Go: a few kB, grown on demand), switch in tens of nanoseconds, and can number in the millions — but the kernel cannot see them, so a blocking syscall in one would block the kernel thread carrying it and every other user thread on it. Runtimes solve that by intercepting blocking calls and parking the user thread instead (Go’s netpoller, Java’s virtual-thread I/O integration). The distinction matters when you count: "10,000 threads" is a big number for kernel threads and a small one for goroutines.
Creating and joining
The API is the same shape everywhere: start a function on a new thread, later join it (block until it finishes) or detach it (let it run and clean itself up). A joinable thread that is never joined leaks its stack and record; C++ terminates the program if a joinable std::thread is destroyed. A thread that throws or segfaults takes the whole process down — a signal is delivered to the process, and an uncaught exception in a std::thread calls std::terminate. There is no "restart this thread" the way a supervisor restarts a process, which is one of the arguments for process isolation covered in Process versus Thread.
1#include <thread>2#include <iostream>3 4int counter = 0; // shared: lives in .bss, one copy per process5 6void work() {7 for (int i = 0; i < 1'000'000; ++i) ++counter; // read-modify-write, not atomic8}9 10int main() {11 std::thread a(work), b(work); // two kernel threads via clone()12 a.join(); b.join();13 std::cout << counter << '\n'; // almost never 200000014}Key points
- A thread is an execution context (IP, registers, stack, scheduling state, TLS); a process is an environment (address space, heap, descriptors, credentials) plus one or more threads.
- Threads share the heap, globals, code and descriptor table; they have private stacks and registers — private by convention, not by hardware protection.
- The scheduler schedules threads; on Linux each thread is a task with its own TID and the main thread’s TID is the PID.
- Kernel threads (1:1) cost ~10–50 µs to create and ~MB of virtual stack; user threads (M:N, goroutines, virtual threads) cost kilobytes and cannot block the kernel thread carrying them.
- A crash in any thread kills the process; a joinable thread must be joined or detached.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why have threads when processes already exist?
Because sharing an address space makes communication a pointer copy and creation ten times cheaper than fork — the price is that nothing protects one thread’s data from another.
▸Why does each thread need its own stack?
A stack records the chain of calls in progress; two instruction streams have two different chains, and interleaving them on one stack would corrupt both.
▸Why do user-level threads exist on top of kernel threads?
To make the unit of concurrency cheaper than the kernel can: kilobyte stacks and nanosecond switches, at the cost of the runtime having to manage blocking itself.
Threads inside a process
How it fails
What the failure looks like from inside real software.
- A counter incremented by two threads ends up short: the read-modify-write interleaves — Race Conditions.
- A thread returns a pointer to a local and another thread reads garbage or crashes: the frame was popped from the private stack.
- Creating 5,000 threads on a 32-bit build, or in a container with a low
pidscgroup limit, fails withEAGAINfrompthread_createlong before RAM runs out. - An exception escapes a worker thread and the whole server dies with
terminate called after throwing …, taking every other request with it. - A thread-local cache is filled per thread, so a 64-thread pool holds 64 copies and "the cache" uses 64× the expected memory.