Ch. 21 · Operating Systems

Processes Versus Threads and Shared State

What a process owns, what its threads share, and what that costs: macOS runs of thread vs fork vs spawn, and a segfault that kills a process.

~7 min readbeginnerupdated Oct 12, 2026

“What’s the difference between a process and a thread?” is often the first operating-systems question in an interview, and the textbook line (“a thread is a lightweight process”) is where weak answers stop. The interviewer wants three things: what each one owns, what happens when something goes wrong, and how you’d choose. The experiments below ran on an M1 MacBook Pro with macOS and Python 3.14, so you can reproduce them.

What a process owns and what its threads share

A process is the kernel’s unit of resource ownership. It holds:

  • a virtual address space: code, global variables, the heap, memory-mapped files, and a stack for each thread
  • a table of open file descriptors (files, sockets, pipes)
  • credentials such as user and group IDs, plus a working directory, signal handlers and resource limits

A thread is the unit the scheduler runs. Each thread gets its own program counter, registers, stack and thread-local storage. That’s the whole list. Everything else is shared by every thread in the process. If one thread opens a socket, another can write to it using the same descriptor number. If one thread calls chdir, the working directory changes for all of them. If one thread writes past the end of an array, whichever thread owns the neighbouring data sees the damage.

On Linux this boundary is visible in the API. clone() creates a new task, and flags like CLONE_VM, CLONE_FILES and CLONE_SIGHAND decide which of those resources the new task shares with its creator. A thread is a task that shares nearly everything. A process created by fork() shares nearly nothing, though its memory starts as copy-on-write pages rather than a physical copy. macOS builds this on Mach tasks and Mach threads, but the behaviour you can observe from a program is the same.

Watching the boundary in one program

Python makes the comparison easy because threading.Thread and multiprocessing.Process take the same arguments. The same function runs once in a thread and once in a child process, and appends to a module-level list:

import os, threading, multiprocessing as mp

shared = []

def work(tag):
    shared.append(tag)
    print(f"{tag:8} pid={os.getpid()} native_tid={threading.get_native_id()} sees {len(shared)} item(s)")

if __name__ == "__main__":
    print(f"main     pid={os.getpid()} native_tid={threading.get_native_id()}")
    t = threading.Thread(target=work, args=("thread",)); t.start(); t.join()
    p = mp.Process(target=work, args=("process",)); p.start(); p.join()
    print("after both, main sees:", shared)
python
main     pid=8587 native_tid=163272
thread   pid=8587 native_tid=163275 sees 1 item(s)
process  pid=8589 native_tid=163281 sees 1 item(s)
after both, main sees: ['thread']
Text

The thread reports the same PID as the main thread but a different kernel thread ID. The kernel schedules it separately, yet it lives inside the same process.

The child process has a new PID, and its list started empty. On macOS, multiprocessing uses the spawn start method, so the child is a fresh interpreter that re-imported the module. On Linux, Python 3.14 changed the default from fork to forkserver; with any of the three methods the child works on its own copy, never the parent’s list.

The last line is the point. The parent’s list contains only the thread’s append. The child’s write happened in an address space the parent can’t see. To get data back you need an explicit channel: a pipe, a socket, a multiprocessing.Queue (which is a pipe plus pickling), or a shared memory segment that both sides map on purpose.

You can see a process’s threads from the shell too. With three extra threads parked on an Event, ps -M -p <pid> printed four rows under one PID. On Linux the equivalent is ps -T -p <pid> or listing /proc/<pid>/task/, which I didn’t run here.

Crash isolation is where the difference bites

Shared memory is convenient right up until something corrupts it. This experiment makes a worker dereference address 0, first from a thread and then from a child process. Each case runs in a throwaway interpreter so nothing important dies:

import subprocess, sys

thread_crash = ("import threading, ctypes\n"
                "t = threading.Thread(target=lambda: ctypes.string_at(0)); t.start(); t.join()\n"
                "print('main thread still alive')\n")
r = subprocess.run([sys.executable, "-c", thread_crash], capture_output=True)
print("thread segfault -> child exit code", r.returncode, "stdout:", r.stdout)

process_crash = ("import multiprocessing as mp, ctypes\n"
                 "if __name__ == '__main__':\n"
                 "    p = mp.Process(target=ctypes.string_at, args=(0,)); p.start(); p.join()\n"
                 "    print('parent alive, worker exitcode', p.exitcode)\n")
r = subprocess.run([sys.executable, "-c", process_crash], capture_output=True)
print("process segfault -> parent exit code", r.returncode, "stdout:", r.stdout)
python
thread segfault -> child exit code -11 stdout: b''
process segfault -> parent exit code 0 stdout: b'parent alive, worker exitcode -11'
Text

An exit code of -11 means the process was killed by signal 11, SIGSEGV. When the faulting code ran in a thread, main thread still alive never printed: the default action for SIGSEGV terminates the entire process, every thread included. When the same fault happened in a child process, only the child died, and the parent read its exit code and carried on.

This is why browsers put sites in separate renderer processes, and why pre-fork servers such as Gunicorn run a master that restarts dead worker processes. A common misconception runs the other way: people see a Python exception in a thread print a traceback while the program continues, and conclude threads are isolated. That’s the interpreter catching an exception inside one thread. A real memory fault, from a C extension or a native library, takes everything down.

What creation actually costs

Here is each option created and torn down in a loop, averaged:

thread start+join :       33 us each
fork + exit + wait:      668 us each
spawn interpreter :    42757 us each
Text

A second run gave 36 µs, 660 µs and 41 ms, so treat these as orders of magnitude. A thread needs a stack and a kernel thread object. fork() has to duplicate the page tables for the parent’s address space (copy-on-write means it shares pages instead of copying them, but the mappings still have to be set up) and copy the descriptor table. spawn costs the most because it starts a whole new Python interpreter and re-imports your code; almost all of those 42 ms are interpreter start-up, not kernel work.

So “processes are heavy” is mostly about start-up and memory per worker. Create them once in a pool and the per-task cost drops to the price of passing a message. The other recurring cost is the switch itself: moving between two threads of one process keeps the same address space, while moving between processes changes it, which can mean colder caches and TLB entries.

The GIL changes the usual advice in Python

The classic rule is “threads for I/O-bound work, processes for CPU-bound work”. In CPython with the global interpreter lock, only one thread executes Python bytecode at a time, so CPU-heavy Python threads don’t use more cores, and multiprocessing does. The interpreter used here reports sys._is_gil_enabled() as True; Python 3.13 and later also ship an optional free-threaded build without the GIL.

Don’t carry that constraint into other languages. Java, Go, C++ and Rust threads run in parallel on every core. Saying “threads can’t use multiple CPUs” in a Java interview is a red flag.

Two more mistakes come up often. One is saying threads have separate heaps; they have separate stacks, and the heap is shared. The other is calling fork() in a program that already has threads. The child gets a copy of memory but only the thread that called fork(). If another thread held a lock at that moment, the lock is copied in its locked state and nobody in the child will ever release it. Python 3.12 and later warn about exactly this: DeprecationWarning: This process (pid=9255) is multi-threaded, use of fork() may lead to deadlocks in the child.

Interview practice: isolating a crash-prone library

Your API server decodes user-uploaded images using a native C library on a thread pool. A few times a week the library segfaults on a malformed file, and every in-flight request on that server fails. What would you change, and what does the change cost?

Answer

Move decoding into a pool of worker processes. The segfault happens because the library touched memory it shouldn’t. In a thread, that signal kills the whole server, so unrelated requests die with it. In a separate process, only the worker dies; the supervisor sees the exit status (-11 in the experiment above), returns an error for that one upload, and starts a replacement worker.

The costs are worth naming. Data has to cross a process boundary, so you’ll copy the upload bytes through a pipe or socket, or place large files in shared memory and send only the offset. Workers should be started ahead of time, since spawning per request would add tens of milliseconds. Each worker has its own copy of the library and its caches, so memory use rises with the pool size.

You also gain things threads can’t give you. Each worker can run with fewer privileges, under its own RLIMIT_AS and CPU-time limits, and the supervisor can kill one that hangs. A strong answer mentions a per-job timeout and a restart limit, so a file that crashes every worker doesn’t keep the pool restarting forever.

More in Operating Systems

read ✓Operating Systems · mid

Condition Variables and Predicate-Based Waiting

Why a condition variable wait sits in a while loop under the lock, with Python runs of a stolen wakeup, a lost notify and a notify() deadlock.

~6 min readread →
read ✓Operating Systems · easy

Virtual Memory and Address-Space Isolation

Why two processes can hold different values at the same address, how page tables translate it, and why a 32 GiB mapping can cost almost nothing.

~6 min readread →
read ✓Operating Systems · hard

Operating Systems: I/O Models

Blocking, non-blocking, multiplexed and completion I/O compared: a 1-second spin burned a full second of CPU, and 2,000 threads cost 70 MiB.

~6 min readread →
esc