PYTHON / ADVANCED PYTHON
Threads, processes, and the GIL
Choose threads or processes for a given workload by reasoning about when CPython's GIL is held and when it is released.
What you will learn
- Identify whether a workload is I/O-bound or CPU-bound before picking a concurrency tool
- Use ThreadPoolExecutor for waiting, ProcessPoolExecutor for computing
- Explain why threads share memory and processes do not
- Protect shared counters with threading.Lock even though the GIL exists
Understanding Threads, processes, and the GIL
CPython stores reference counts and other interpreter bookkeeping in ordinary C structures that are not safe to mutate from two OS threads at once. Rather than locking every object individually, CPython uses one global lock, the GIL, that a thread must hold to execute bytecode. Your threads are real OS threads and the kernel schedules them normally, but only one of them advances Python bytecode at any instant, so four threads running pure-Python loops finish in roughly the same total time as one, plus handoff overhead.
The GIL is released whenever the interpreter is about to wait for something outside itself: time.sleep, socket reads and writes, file I/O, subprocess.wait, and many C extensions such as compiled parts of zlib or NumPy explicitly drop it around long computations. That is the whole mental model. Threads buy you overlap in the time your program spends blocked, not in the time it spends executing bytecode. A program fetching 200 URLs is blocked almost the entire time, which is why threads make it dramatically faster.
When you genuinely need parallel bytecode execution, use processes. Each process has its own interpreter, its own GIL, and its own memory, so they run truly in parallel on separate cores. The cost is that nothing is shared: arguments and return values are pickled and copied through a pipe, startup takes milliseconds, and a global you mutated in the parent is invisible in the child. That tradeoff, shared memory with serialized execution versus isolated memory with parallel execution, is the real decision you are making.
import multiprocessing
import threading
state = {"value": 0}
def bump(tag):
state["value"] += 1
print(f"{tag} incremented to {state['value']}")
if __name__ == "__main__":
p = multiprocessing.Process(target=bump, args=("process",))
p.start()
p.join()
print("parent after process:", state["value"])
t = threading.Thread(target=bump, args=("thread",))
t.start()
t.join()
print("parent after thread:", state["value"])
In CPython only one thread runs bytecode at a time, so threads overlap waiting while processes overlap computing.
Worked examples
Threads overlap blocking calls
Four half-second sleeps finish in about half a second because time.sleep releases the GIL.
import threading
import time
def nap(seconds):
time.sleep(seconds)
start = time.perf_counter()
threads = [threading.Thread(target=nap, args=(0.5,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.perf_counter() - start
print("total sleep requested: 2.0s")
print("wall clock under 1.0s:", elapsed < 1.0)
Example explained
Line 1time.sleep drops the GIL before blocking, so the other three threads keep running.
Line 2The loop of t.start() calls does not wait; each thread begins sleeping immediately.
Line 3The second loop of t.join() calls blocks the main thread until every worker returns.
Line 4Replacing time.sleep with a pure-Python counting loop would make elapsed roughly the serial total instead.
Processes for CPU-bound work
ProcessPoolExecutor runs prime counting in separate interpreters, each with its own GIL.
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit):
found = 0
for x in range(2, limit):
if all(x % d for d in range(2, int(x ** 0.5) + 1)):
found += 1
return found
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=2) as pool:
counts = list(pool.map(count_primes, [10000, 20000]))
print(counts)
print("total:", sum(counts))
Example explained
Line 1count_primes is a module-level function so it can be pickled and sent to a worker.
Line 2pool.map returns results in submission order even though the workers finish out of order.
Line 3The two integer arguments and two integer results are cheap to pickle, which is why this parallelizes well.
Line 4Swapping in ThreadPoolExecutor gives the same numbers but no speedup, since the loop holds the GIL.
The GIL does not make += atomic
A shared counter needs an explicit lock because one Python statement is several bytecodes.
import threading
counter = 0
lock = threading.Lock()
def add(n):
global counter
for _ in range(n):
with lock:
counter += 1
threads = [threading.Thread(target=add, args=(100_000,)) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print("expected:", 4 * 100_000)
print("actual: ", counter)
Example explained
Line 1counter += 1 compiles to load, add, store, and the interpreter may switch threads between them.
Line 2Without the lock, two threads can load the same value and both store value+1, losing an update.
Line 3with lock guarantees the load-add-store sequence completes before another thread enters.
Line 4The lock is what makes the result reproducible; the GIL alone only guarantees single bytecode steps.
Important notes
Returning huge objects from a process pool can be slower than doing the work serially, because everything crosses the boundary as pickled bytes; aggregate inside the worker and return a small result.
The GIL is a CPython implementation detail, not a language rule: Jython and IronPython have none, and CPython 3.13+ ships an optional free-threaded build where these threading tradeoffs change.
Common mistakes
Throwing threads at a number-crunching loop and concluding Python cannot do concurrency; the version with threads is often slightly slower than serial because of GIL handoffs and lock contention.
Calling multiprocessing.Process at module level without the __name__ guard; on spawn and forkserver start methods the child re-imports the module and either raises RuntimeError or spawns processes endlessly.
Assuming the GIL makes shared-state updates safe, so counter += 1 or list index bookkeeping loses updates under load and produces totals that are slightly too low and never reproducible.
Try it yourself
Change, predict, then run
Write a function that sums x * x for x in range(3_000_000) and run four copies of it with ThreadPoolExecutor(4), then with ProcessPoolExecutor(4), printing the elapsed time of each. Then replace the body with time.sleep(1) and rerun to see the ranking flip.
Open the Python workspaceCheck your understanding
One script spends its time waiting on 200 HTTP responses; another spends its time multiplying matrices with pure-Python loops. Why does adding threads speed up the first but not the second?
- Socket waits release the GIL, so other threads execute bytecode meanwhile, while the arithmetic loop holds the GIL from start to finish
- The operating system gives I/O-bound threads higher scheduling priority than CPU-bound threads
- The GIL is released on every function call, and HTTP code performs many more calls than an arithmetic loop
- Network code runs in C and is simply faster; the GIL plays no role in either script
Show answer
Threads help only where the interpreter gives up the GIL, which happens around blocking calls like socket reads; a pure-Python loop never releases it, so the threads take turns instead of running together. Priority is not the mechanism: the kernel schedules both kinds of threads the same way, and even at equal priority the CPU-bound threads cannot run concurrently because only the GIL holder may execute bytecode.