The GIL, Processes, and Shared State
Why more threads do not speed up a calculation
In this lesson
Two lessons ago you were told that threads do nothing for calculation, and asked to take it on trust. Here is the reason, and it is not a flaw in your code.
This lesson also covers the failure mode unique to concurrent programs — the one where nothing crashes, no exception is raised, and the answer is simply wrong.
Explain it like I’m 5
The GIL is a single microphone in a room. Extra speakers do not help when only one person may talk at a time. For genuinely parallel talking, you need separate rooms.
The GIL, in one paragraph
CPython (the Python you downloaded) protects its internals with a Global Interpreter Lock. Only one thread may run Python code at any single instant. Threads take turns holding it, swapping many times a second, so they interleave beautifully; they just never run Python code at the same moment.
For waiting, this costs nothing at all: a thread releases the GIL the moment it blocks on the network or the disk, which is exactly why thread pools work so well for I/O. For calculation, it is decisive. Four threads doing arithmetic take turns on one microphone and finish in about the time one thread would, plus the cost of the swapping.
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def count_primes(limit):
"""Pure calculation: no waiting anywhere."""
total = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
total += 1
return total
JOBS = [1_000_000] * 4
if __name__ == "__main__":
start = time.perf_counter()
[count_primes(j) for j in JOBS]
print(f"one at a time: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(count_primes, JOBS))
print(f"4 threads: {time.perf_counter() - start:.1f}s")
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:
list(pool.map(count_primes, JOBS))
print(f"4 processes: {time.perf_counter() - start:.1f}s")
one at a time: 5.7s 4 threads: 5.8s 4 processes: 1.6s
What processes cost
Processes are not free, which is why they are not simply the default.
They take time to start. Each one is a fresh Python interpreter. That is tens of milliseconds each, irrelevant next to six seconds of work, ruinous if each job takes a millisecond.
They share no memory. Arguments and return values are pickled, shipped between processes, and unpickled. Send a large DataFrame to four workers and the copying can cost more than the calculation. It also means anything you send must be picklable, which rules out open files, database connections, and lambdas.
The practical rule: reach for processes when the jobs are chunky and the data passed is small. Four jobs of two seconds each is ideal. Ten thousand jobs of one millisecond is a disaster.
The bug that does not crash
Now the failure mode unique to concurrency. When two workers change the same variable, you can lose data without a single error appearing.
counter += 1 looks indivisible. It is not. It is three steps: read the value, add one, write it back. A worker can be interrupted between any two of them. If both workers read 5 before either writes, both write 6, and one increment vanishes. That is a race condition, and its defining nastiness is that it depends on timing — so it passes your tests, and fails in production.
Real races are unpredictable, so here it is with the interleaving written out explicitly. This is one way two threads can genuinely land. Both workers want to add 1, so the counter should reach 2. Fill in the four steps in the order the comments give, and see what you actually get.
counter = 0
# Two workers each want to add 1 to the counter.
# An increment is really three steps: read, add, write.
# Here is one interleaving that a real thread swap can produce:
# TODO: 1. worker A reads the counter into a_read
# TODO: 2. worker B reads the counter into b_read (A has not written yet!)
# TODO: 3. worker A writes back a_read + 1
# TODO: 4. worker B writes back b_read + 1
print("two increments, counter =", counter)
print("should have been: ", 2)
Each read is a_read = counter and b_read = counter, both before either write. Then counter = a_read + 1 followed by counter = b_read + 1. Both workers read zero, so both write one, and the counter ends at 1 instead of 2.
counter = 0
# Two workers each want to add 1 to the counter.
# An increment is really three steps: read, add, write.
# Here is one interleaving that a real thread swap can produce:
a_read = counter # 1. worker A reads 0
b_read = counter # 2. worker B also reads 0; A has not written yet
counter = a_read + 1 # 3. worker A writes 1
counter = b_read + 1 # 4. worker B writes 1, overwriting A entirely
print("two increments, counter =", counter)
print("should have been: ", 2)
two increments, counter = 1
should have been: 2
Locks, and the advice that usually beats them
The textbook fix is a lock: with lock: around the read-modify-write, so no other worker can interleave. It works, and you should know it exists.
The better everyday advice is simpler: do not share. Give each worker its own data, have it return a result, and combine the results yourself when everything is finished. A pool already does this: map and submit hand you return values, and if you only ever use those, races cannot occur.
Locks are also easy to get wrong in ways that are worse than the original bug: forget one and the race remains, take two in the wrong order and the program hangs forever with no error at all. Returning results has none of these failure modes, which is why it is the default worth reaching for.
Common mistake: Blaming your code when threads do not speed up a calculation
The same pattern made network calls five times faster, so the code looks right.
It is the GIL, not your code. Switch to ProcessPoolExecutor for CPU-bound work.
Common mistake: Sharing a mutable list or counter across workers
It works every time on small test data, where the timing rarely lines up badly.
Have each worker return its own result and combine them afterwards. If you truly must share, guard every read-modify-write with a lock.
Common mistake: Reaching for processes when the work was I/O-bound
Processes were introduced as the more powerful option, so they sound like the better one.
For waiting, threads are lighter and need no pickling. Use processes only when the CPU is genuinely the bottleneck.
Common mistake: Forgetting the if __name__ == "__main__" guard
Nothing else in Python needs it, so it looks like boilerplate to skip.
Worker processes re-import your file. Without the guard they re-run your script and spawn pools endlessly.
Why do threads not speed up CPU-bound Python?
Threads take turns holding the GIL. That is invisible while waiting, and decisive while calculating.
What does a process pool cost you?
Each process is a fresh interpreter, and arguments and results are pickled across the boundary.
What is the simplest way to avoid a race condition?
No shared data means nothing to corrupt. Locks work too, but they are easier to get wrong.
Mini exercise (medium)
Two workers, each adding 1 to a counter three times, so the total should be 6. racy_total() is supplied and shares one counter with the bad interleaving. Write safe_total() the other way: let each worker count its own three increments independently, then add the two results together.
Take the wheel. Complete the code, hit Run, and check your output right here.
def racy_total():
"""Two workers share one counter. Each adds 1, three times over."""
counter = 0
for _ in range(3):
a_read = counter # both workers read before either writes
b_read = counter
counter = a_read + 1
counter = b_read + 1 # A's increment is overwritten
return counter
def safe_total():
"""The same six increments, with nothing shared."""
return 0 # TODO: give each worker its own total, then add them up
print("shared counter:", racy_total())
print("each returns: ", safe_total())
print("should be: ", 6)
safe_total needs no shared counter at all. Give each worker its own local total, count up to 3 in each, and return the sum of the two.
def racy_total():
"""Two workers share one counter. Each adds 1, three times over."""
counter = 0
for _ in range(3):
a_read = counter # both workers read before either writes
b_read = counter
counter = a_read + 1
counter = b_read + 1 # A's increment is overwritten
return counter
def safe_total():
"""The same six increments, with nothing shared."""
a_total = 0
b_total = 0
for _ in range(3):
a_total += 1
for _ in range(3):
b_total += 1
return a_total + b_total
print("shared counter:", racy_total())
print("each returns: ", safe_total())
print("should be: ", 6)
shared counter: 3
each returns: 6
should be: 6
assert safe_total() == 6, "six increments should total six when nothing is shared"
assert racy_total() == 3, "leave racy_total alone — losing half the increments is the point"
assert safe_total() != racy_total(), "the two approaches must disagree; that disagreement is the bug"
print("✓ Looks good!")