“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)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']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)thread segfault -> child exit code -11 stdout: b''
process segfault -> parent exit code 0 stdout: b'parent alive, worker exitcode -11'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 eachA 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.
Related reading on processes and memory
- Operating Systems interview questions
- Operating Systems MCQs
- Copy-on-Write Memory and fork
- Operating Systems: Thread Pools
- OSTEP, Concurrency: An Introduction, the chapter on threads and their shared address space