Threads vs Processes: Shared Memory, Isolation and the GIL
7 min readBytePatterns
Threads vs processes explained: shared memory against isolation, what a crash takes down, why data must be copied between processes, and when the GIL decides.
"What is the difference between a thread and a process?" is one of the most common opening questions in a concurrency or backend interview. The textbook answer, that a process is a program in execution and a thread is a unit of execution inside it, is true and earns little. The answer that lands is about memory: who can see what, what that makes cheap, what it makes dangerous, and how that decides which one you reach for.
The problem it solves
A program that must do several things at once, such as serve many requests, crunch a large batch or keep a UI responsive while downloading, needs more than one flow of execution. The operating system offers two units for that:
- A process is a running program with its own address space: its own heap, globals and open files, walled off from every other process by the OS.
- A thread is a flow of execution inside a process. Each thread has its own stack and registers, but all threads of a process share its heap, globals and file handles.
Choosing between them is choosing between sharing and isolation.
The intuition
Threads are housemates sharing a kitchen. Passing the salt takes a second, because it is already on the counter. But one housemate leaving the gas on ruins everyone's evening, and two reaching for the same pan at the same moment is a fight. In code: data written by one thread is visible to the others immediately, with no copying. Starting a thread is cheap. And every shared variable can be corrupted by an unlucky interleaving, which is why threads need locks, and why race conditions and deadlocks are thread problems first.
Processes are neighbours with their own kitchens. Borrowing sugar means packaging it and walking it over. In code: sending data to another process means serialising it, copying it through a pipe or socket, and rebuilding it on the other side. Starting a process costs more memory and time. What you buy is isolation: a crash, a memory corruption or a runaway allocation in one process cannot touch another's memory.
Python adds one more factor. In the standard CPython build, the global interpreter lock (GIL) lets only one thread execute Python bytecode at a time. Threads still help with I/O-bound work, because a thread waiting on the network or disk releases the GIL. But CPU-bound Python code gets no speed-up from threads; it needs processes, each with its own interpreter and its own GIL. Python 3.13 introduced an optional free-threaded build without the GIL, but the default build still has it as of September 2026.
Watch it run
The animation starts with the rule: a process owns its memory, and threads live inside one process and share every byte of it. Starting one is cheap, and handing data over costs nothing, because it is already there. Thread 1 writes counter = 5 straight into the shared heap, and thread 2 sees the 5 instantly: no copy, no message, no serialisation. Which is also the danger: every shared variable is a bug waiting for the wrong interleaving. Then processes instead. Each owns its memory, and neither can reach the other's. So sending work means copying it: pickle it, ship it, unpickle it on the far side. What you buy is a wall: one crash cannot corrupt a sibling, because the OS put it there. The last frame settles it for Python: I/O-bound work wants threads, CPU-bound work wants processes.
Threads vs Processes
Step 1 of 9
A process owns its memory. Threads live inside one process and share every byte of it.
The same interactive animation as the lesson — step through it with the controls.
The code
Every block keeps its top-level work under if __name__ == "__main__":. With the spawn start method, the default on Windows and macOS, each child process re-imports the main module, and anything outside the guard would run again in every child.
The same function writes 5 into a dictionary, once from a thread and once from a child process. The thread's write lands in the parent's dictionary; the child's write lands in its own copy. The process ids tell the same story: a thread shares its process's id, a child process has its own:
import os
import threading
import multiprocessing as mp
state = {"counter": 0}
def write_five(box):
box["counter"] = 5 # a write into whatever memory `box` lives in
def report_pid(q):
q.put(os.getpid())
if __name__ == "__main__":
t = threading.Thread(target=write_five, args=(state,))
t.start(); t.join()
print("after thread:", state) # after thread: {'counter': 5}
state["counter"] = 0
p = mp.Process(target=write_five, args=(state,))
p.start(); p.join()
print("after process:", state) # after process: {'counter': 0}
q = mp.Queue()
p = mp.Process(target=report_pid, args=(q,))
p.start()
child_pid = q.get()
p.join()
print(child_pid != os.getpid()) # True
ids = []
t = threading.Thread(target=lambda: ids.append(os.getpid()))
t.start(); t.join()
print(ids == [os.getpid()]) # True
What a hard crash takes down. os._exit ends the whole process immediately. Called from one thread, it kills the main thread too, so its last line never prints. Called from a child process, it ends only that child, and the parent reads the exit code and carries on:
import subprocess
import sys
KILL_FROM_THREAD = """
import os, threading, time
threading.Thread(target=lambda: os._exit(3)).start() # one thread dies hard
time.sleep(2)
print("main thread still running") # never printed
"""
def die_hard():
os._exit(3)
if __name__ == "__main__":
r = subprocess.run([sys.executable, "-c", KILL_FROM_THREAD], capture_output=True, text=True)
print(r.returncode, repr(r.stdout)) # 3 ''
p = mp.Process(target=die_hard)
p.start(); p.join()
print(p.exitcode, "and the parent carries on") # 3 and the parent carries on
The same work through a thread pool and a process pool, checked against a plain loop on 400 seeded random batches. Both pools return the same results in the same order; the process pool pickles every batch to its workers and every result back:
import random
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def sum_of_squares(chunk):
return sum(x * x for x in chunk)
if __name__ == "__main__":
random.seed(23)
batches = [[random.randint(-1000, 1000) for _ in range(random.randint(0, 50))]
for _ in range(400)]
expected = [sum(x * x for x in b) for b in batches] # one thread, no pool
with ThreadPoolExecutor(max_workers=4) as threads:
by_threads = list(threads.map(sum_of_squares, batches))
with ProcessPoolExecutor(max_workers=2) as procs:
by_processes = list(procs.map(sum_of_squares, batches, chunksize=50))
print(by_threads == expected == by_processes) # True
The complexity
Costs rather than big-O:
- Creation: a thread needs a stack; a process needs a new address space and, in Python, a new interpreter.
- Sharing data: free between threads; a copy plus serialisation between processes.
- Switching: between threads of one process is cheaper, because the address space stays the same.
- Failure: a thread that corrupts memory or exits hard takes its whole process down; a process fails alone.
Where it goes wrong
- Threads for CPU-bound Python. With the GIL, four threads doing arithmetic run no faster than one.
- Assuming a process sees the parent's later changes. A child gets a copy at start-up; updates on either side stay on that side.
- Sending huge objects to a process pool. Every argument is pickled; the copying can cost more than the work.
- Shared state without locks. Even
counter += 1is a read, an add and a write, and threads can interleave between them. - Forgetting the main guard. Under
spawn, top-level code runs again in every child.
When it shows up in interviews
It opens concurrency rounds and comes back in design questions: "how would you parallelise this job?", "why is my multithreaded Python not faster?", "why do browsers run each tab in its own process?" From there it leads to mutexes, semaphores and the producer-consumer queue, the usual way threads hand work to each other safely.
How to say it in an interview
"A process has its own address space; threads run inside a process and share its heap. So threads are cheap to start and share data for free, but every shared variable needs synchronisation, and one thread crashing the process takes the others with it. Processes are isolated by the OS, so one failure stays contained, but data has to be serialised and copied between them. In Python, the GIL in the standard build means only one thread runs bytecode at a time, so I use threads for I/O-bound work and processes for CPU-bound work."