bigonotes
CS ConceptsOS Fundamentals

Processes vs Threads

How a process lays out memory, how it moves through its lifecycle, and what threads share versus keep private.

11 min readLast updated OS FundamentalsListen · Watch on NotebookLM

A process is a program in execution: the OS has loaded it into memory, given it an ID, and is running it. A thread is a single line of execution inside a process. Every concurrency topic that follows (locks, deadlocks, thread pools, async) builds on knowing exactly what a process owns and what its threads share.

Memory layout of a process#

Each process gets four main regions in its virtual address space.

Region What lives there Shared between threads?
Stack One frame per function call: locals, parameters, return address. Pushed on call, popped on return. Fast, automatic, limited in size. No, each thread has its own
Heap Dynamically allocated memory (every object you create in Python, malloc() in C). Larger but slower to allocate; leaks if not freed or garbage collected. Yes
Data Globals and statics. Initialized data holds ones with a value (static int count = 5;); BSS holds uninitialized ones (static int count;), zeroed at start. Yes
Code (text) Compiled machine instructions. Read-only so a bug can't overwrite them. Yes

Infinite recursion keeps pushing stack frames until the stack runs out, which is why you get a StackOverflowError.

For example, if main() calls foo() and foo() calls bar(), the stack holds three frames:

Stack position Frame
Top (current) bar() and its local vars
Middle foo() and its local vars
Bottom main() and its local vars

In the address space, the regions sit in this order, from high address to low:

  1. Stack, which grows downward as functions are called.
  2. Free space between the two growing regions.
  3. Heap, which grows upward with new / malloc.
  4. Data (BSS + initialized): global and static variables.
  5. Code (text): compiled instructions, at the low end.

Because the heap and data segment are shared, two threads can read and write the same object. That is useful, and it is also where race conditions come from.

What a process gets#

A script on disk is just a file. Once the OS loads it and starts running it, it becomes a process with its own isolated world:

  • a PID (process ID)
  • its own virtual address space (stack, heap, data, code)
  • a file descriptor table
  • a security context

Two processes each get a full copy of this world, and neither can touch the other's memory:

Process A Process B
PID 1001 1002
Stack, heap, data, code Its own Its own
File handles Its own Its own

Process A cannot read Process B's memory; the OS enforces this with virtual memory and page tables. This is why Chrome runs tabs in separate processes: one tab crashing doesn't take down the others.

The process lifecycle#

A process moves through five states. Most of its life is spent cycling between Ready, Running and Waiting.

State diagram: Created goes to Ready (admit), Ready to Running (dispatch), Running back to Ready (preempted), Running to Waiting (blocks), Waiting back to Ready (event done), and Running to Terminated (exit)

Created (new)#

You run python app.py or call fork(). The OS:

  • allocates a PCB (Process Control Block), its bookkeeping record for the process
  • assigns a PID and allocates a virtual address space
  • loads the program code, sets up the stack, initializes registers
  • points the program counter (PC) at the entry point (e.g. main())

The process exists but isn't running yet. The OS may delay admitting it to the ready queue if the system is overloaded.

Ready#

The process is in memory and able to run; it is only waiting for a free core. It sits in the ready queue (for example P3, then P7, then P1, ...), and the CPU scheduler decides who goes next.

Running#

The dispatcher hands over the CPU: it loads the saved registers, PC and stack pointer, switches to user mode, and the CPU starts executing. A single-core machine has at most one running process; a quad-core has up to four.

There are two ways out of Running besides finishing.

Running → Ready (preemption). The OS takes the CPU away involuntarily:

  • Time slice expired. In round robin, a timer interrupt fires after, say, 10 ms.
  • Higher-priority task arrived. With preemptive priority scheduling, it runs immediately.

The OS saves the full CPU state into the PCB so the process can resume exactly where it stopped. Say P3 is running, executing line 47:

  1. A timer interrupt fires.
  2. The OS saves P3's state to its PCB.
  3. The OS picks P7 from the ready queue.
  4. The OS loads P7's state from its PCB.
  5. P7 resumes, and P3 goes back to the ready queue.

Running → Waiting (blocked). The process stops itself because it needs something that isn't ready:

  • Disk I/O: read() on a file
  • Network: waiting for an HTTP response
  • Lock: another thread or process holds it
  • Sleep: time.sleep(5)
  • Page fault: the page was swapped to disk and must be loaded back

The OS parks it on a wait queue (one per device, lock, and so on).

Wait queue Processes waiting
Disk I/O P3, P8
Network P2
Mutex lock P5, P1

This transition is why concurrency pays off. While one task blocks on a database call, the CPU is free to run another. That is the whole idea behind thread pools.

Waiting → Ready#

When the event completes (disk interrupt, response arrives, lock released), the OS moves the process back to Ready, not Running. Finishing I/O doesn't give you the CPU; it puts you back in line.

Terminated#

The process exits normally (main() returns, System.exit(0)), crashes (uncaught exception, segfault), or is killed (kill -9 PID). The OS frees memory, closes file handles, releases locks and notifies the parent.

Zombie processes. After a child exits, the OS keeps a stub of its PCB (exit code, PID, some stats) so the parent can call wait(). Until the parent does, the dead child lingers as a zombie; wait() cleans it up.

The PCB ties it together#

Every state transition reads or writes the PCB. Here is what the PCB for a process P3 might hold:

Field Example value
PID 1001
State Ready / Running / ...
Program counter 0x004A
Registers Saved values
Memory mappings Page table
Open files fd0, fd1, fd3
Priority 5
CPU time used 342 ms
Parent PID 1000

On a context switch away from P3, the OS saves CPU state into P3's PCB; when P3 runs again, it loads that state back.

Why threads exist#

Picture a service that handles one HTTP request at a time. Request 1 makes a 200 ms database call, and for those 200 ms the CPU does nothing while 99 other requests queue up.

  • One process per request fixes the waiting but is expensive: fork() sets up a whole new address space, and sharing data (like a cache) needs slow, complex IPC.
  • Threads are cheap to create and share memory naturally.
One process with shared code, data, heap and open files, and three threads that each have their own stack and registers

Each thread has its own lifecycle with the same states. Thread 1 can be Running while Thread 2 waits on I/O and Thread 3 sits in the ready queue.

Kernel, user and hybrid threads#

Threads differ in who schedules them: the OS kernel, a library in your program, or both.

Kernel-level threads#

The OS knows about every thread and schedules each one independently onto cores.

import threading

# Each of these is backed by a kernel thread
t1 = threading.Thread(target=process_request, args=(req1,))
t2 = threading.Thread(target=process_request, args=(req2,))
t1.start()  # OS can now schedule t1
t2.start()  # OS can now schedule t2

If t1 blocks on a database call, the OS schedules t2 on that core. Python threading.Thread, C++ std::thread and C pthread_create all create kernel threads.

  • Advantage: one thread blocking on I/O doesn't stop the others.
  • Cost: creation is a system call (user mode → kernel → back), roughly 1–10 µs, and switches go through the kernel.

User-level threads#

A library inside your program manages them; the OS sees one thread. Switching is just swapping stack pointers in user space, with no system calls, so it's very fast.

The fatal flaw: if one user thread makes a blocking call like read(), the OS blocks its only thread and the whole process freezes. The user-space scheduler never gets a chance to run another thread. Pure user-level threads are rare today for this reason.

Hybrid (M:N) model#

Many lightweight user threads are multiplexed onto a few kernel threads, usually about one per core. There are two layers of scheduling:

  • User space: a runtime scheduler maps lightweight threads G1, G2, ... G1000 onto kernel threads KT1 to KT4.
  • Kernel: the OS sees and schedules only KT1 to KT4.

Go goroutines are the classic example. Each one starts with a small (~2 KB) growable stack, so you can run millions of them.

When a goroutine blocks on I/O, the Go runtime parks it and runs another goroutine on the same kernel thread, which removes the fatal flaw of pure user threads. A million kernel threads would exhaust memory (each reserves a stack, commonly around 1 MB).

Python's closest tool is asyncio. It isn't M:N: all tasks share one kernel thread, but each task is a cheap object, and awaiting I/O parks only that task:

import asyncio

async def handle_request(i: int) -> int:
    await asyncio.sleep(0.1)  # stands in for network I/O
    return i

async def main() -> None:
    # 100,000 concurrent tasks on one thread: about a second, not hours
    tasks = (handle_request(i) for i in range(100_000))
    results = await asyncio.gather(*tasks)
    print(len(results))

asyncio.run(main())

See Async vs Multithreading for when to pick this over threads.

Kernel threads User threads Hybrid (e.g. Go)
OS sees them? Yes No Only the carrier kernel threads
Creation cost ~1–10 µs ~0.1 µs ~0.1 µs
Stack size ~1 MB, fixed ~2–8 KB, growable ~2–8 KB, growable
Practical max ~10,000s Millions Millions
Blocking I/O stops That thread The whole process Just that goroutine
Context switch Through the kernel User space only Mostly user space

Thread lifecycle in Python#

A threading.Thread goes through the same kind of cycle as a process. Each step is triggered by something you or the scheduler does:

Trigger New state
threading.Thread(...) New: the object exists, nothing runs yet
.start() Runnable: eligible to run
Scheduler picks it Running
time.sleep(), Condition.wait() or blocking I/O Waiting/Blocked (see sleep vs wait)
Timer ends, notify(), I/O completes Runnable again
run() returns Terminated: is_alive() is now False

Switching between threads of the same process only swaps the TCB (Thread Control Block: stack pointer, PC, registers, state, thread ID). Shared things like memory maps and file descriptors stay in the process's PCB, so no page-table change is needed.

What goes wrong with threads#

Sharing memory brings three classic bugs.

  • Race condition: two threads read-modify-write shared data at the same time. With counter = 0, both threads read 0, both write 1, and the result is 1 instead of 2.
  • Deadlock: Thread 1 holds lock A and wants B; Thread 2 holds B and wants A. See Deadlocks.
  • Starvation: a runnable thread never gets CPU time because others keep cutting in. See Starvation vs Deadlock vs Livelock.

Thread-safety tools: mutexes (one thread at a time), semaphores (N at a time), atomic operations (single hardware-guaranteed steps), and immutable objects (no writes, no race).

Memory cost: processes vs threads#

The figures below are rough and typical; they vary by OS and settings.

Process Thread
Stack ~8 MB (main thread) ~1 MB per thread
Heap Own, grows as needed Shared with the process
Code segment Own mapping, ~1–50 MB Shared
Data segment Own, ~KBs Shared
Page tables Own, a few MB Shared
Control block PCB, a few KB TCB, a few KB
Rough total Tens of MB ~1 MB

Thread stacks alone (~1 MB each) add up fast:

Threads Stack memory
10 10 MB
1,000 1 GB
10,000 10 GB

Threads are cheap one at a time but expensive in bulk, which is why servers reuse a bounded set of them through a thread pool. When RAM fills, both processes and threads spill to disk through virtual memory, which is slow.

Key takeaways#

  • A process owns an isolated address space (stack, heap, data, code), a PID and file descriptors; isolation is enforced by virtual memory.
  • Threads in one process share heap, data, code and files, but each has its own stack, PC and registers.
  • Lifecycle: a process is Created, cycles through Ready, Running and Waiting, then is Terminated; finished I/O returns you to Ready, not Running.
  • The PCB (per process) and TCB (per thread) store the state that makes pause-and-resume possible.
  • Kernel threads are robust but heavy; hybrid models (such as Go goroutines) give millions of cheap threads on a few kernel threads.
  • Shared memory is what makes threads fast to cooperate and what makes race conditions possible.

Found a mistake? Tell me on LinkedIn ↗

Further reading#