Why there is a lock at all
In plain words Imagine a room full of people and one microphone. Anyone can be in the room, but only the person holding the microphone may speak. It looks like a strange rule until you hear the room with four people talking at once. Python's microphone is called the Global Interpreter Lock, and the noise it prevents isn't people talking. It's threads editing the same numbers.
Every object in CPython carries a reference count, and Session 01 showed you it moving. Incrementing that count isn't one indivisible machine operation. It's a read, an add and a write. Two threads doing that at the same
instant can both read 4 and both write 5, so one
increment is lost. A count that ends up too high leaks the object forever.
A count that ends up too low frees it while your code is still holding it,
and the next line reads memory that no longer belongs to Python.
Shared bookkeeping
Refcounts, the small-integer cache, the free lists, the import machinery. Every thread touches all of it, constantly, on almost every line.
A lock on every object
Correct, and expensive. You'd pay for locking on every operation in every program, including the single-threaded ones, which is most of them.
One lock, held by one thread
Nearly free when one thread runs, and it makes the whole interpreter safe in one go. The bill only arrives when you want two threads computing.
import sys, then a = [1, 2, 3] and
sys.getrefcount(a). Bind a second name, b = a, and
look again. That number is the thing the GIL protects, and it changes on almost every line you write.
Five milliseconds, then pass the microphone
In plain words Nobody gets to hold the microphone forever. A timer runs, and when it goes off the current speaker is asked to hand it over. They don't stop mid-syllable. They finish the word they're on. And if they walk off to fetch something, they hand it over straight away without being asked.
The interpreter sets a flag when a thread has held the GIL for sys.getswitchinterval() seconds, which is 5 ms by default. The running thread only notices that flag at certain points in the bytecode, chiefly a jump back to the top of a loop and a call into a function. It's never checked in the middle of a short straight run of bytecodes. A thread that instead calls something that waits (time.sleep, a socket read, a disk write) releases the GIL itself, before it starts waiting.
sys.getswitchinterval() prints 0.005.
sys.setswitchinterval(0.05) makes threads swap ten times less
often, and sys.setswitchinterval(0.0001) ten times more. Time a
two-thread CPU loop at each setting. Faster swapping doesn't buy you throughput. It buys you overhead.
"The GIL makes Python thread-safe" is false
In plain words The microphone rule stops two people speaking over each other. It does not stop one person reading a number off the whiteboard, being interrupted, and writing their answer back after somebody else has already changed it. The number they wrote is arithmetically correct and completely wrong.
The GIL guarantees that one bytecode completes before another begins. It makes no promise about one line of your source, which is almost always several bytecodes. acct.set(acct.get() + 1) compiles to a load, a call, an add and another call, and a call is one of the places the interpreter checks whether to switch threads.
| What the GIL promises | What it does not promise |
|---|---|
| One bytecode finishes before the next starts | One line of your code finishes before another thread runs |
| Reference counts stay correct | Your counters, totals and caches stay correct |
| The interpreter never corrupts its own structures | Your dictionary is in a sensible state halfway through an update |
| You will not get a segfault from plain Python code | You will not get a wrong answer |
It usually works
Run count += 1 in a tight loop across four threads on CPython 3.13 and you'll very likely get the right total. It did six runs out of six while I was writing this session. The whole read-add-write sits between two safe points, so nothing gets in.
Until it does not
Move the same logic into a getter and a setter and the calls open a gap. The same script, on the same machine, lost 139,004 of a million increments on one run, and lost none on the next.
Removing the gap
A threading.Lock is a plain object with two states. One thread at a time may hold it, and anybody else asking is parked until it's released. Always take it with with, so it's released even when the body raises.
Those are two different queues, and demo 3 shows both at once. Getting them confused is how people conclude that "locks do nothing in Python because there's already a lock".
queue.Queue is already correct: producers put,
consumers get, and no shared variable exists for anyone to
race on. concurrent.futures hands results back as return
values. Shared mutable state plus hand-written locking is the last resort, not the default.
Write the Account class from Demo 2, run it across four
threads doing 100,000 deposits each, and print the total. Run it ten times.
Then wrap the line in with lock: and run it ten more times. The first set of numbers is the lesson.
Is your code waiting, or is it thinking?
In plain words If your program is slow because it keeps waiting for other people (a website, a database, a disk), then doing all the waiting at once helps enormously, and that's what threads do. If it's slow because it's thinking hard, threads can't help, because thinking needs the microphone.
A thread that blocks on I/O has released the GIL, so every other thread is free to run. Four one-second sleeps across four threads finished in 0.50 s instead of 2.01 s. A thread that computes never releases it voluntarily, so two of them simply take turns and pay the swapping cost on top: the same work took 1.81 s serially and 2.49 s across two threads.
| Your bottleneck | Reach for | Why it works | What it costs |
|---|---|---|---|
| Waiting on network, disk, database, a subprocess | threading, or ThreadPoolExecutor |
Every blocking call releases the GIL, so the waits overlap | Shared objects mean you now own the race conditions |
| Thousands of simultaneous network waits | asyncio |
One thread, one loop, no OS thread per connection | Async all the way down, since one blocking call stalls everything |
| Heavy computation in pure Python | multiprocessing, or ProcessPoolExecutor |
A second interpreter has a second GIL, so a second core is usable | Startup time, and every argument and result is copied |
| Heavy computation inside NumPy, hashing, compression | threading is fine here |
Those libraries release the GIL while they work in C | Only applies while you are inside the library, not around it |
concurrent.futures gives you ThreadPoolExecutor
and ProcessPoolExecutor with an identical API. Write the code
against one, swap the class name, and time both. That measurement beats any argument about what your bottleneck "should" be.
Waiting overlaps: threads and asyncio both turn
n waits into roughly one wait.
Thinking does not overlap inside one interpreter: threads interleave it and add overhead. Only a second process adds a second core.
Neither fixes an algorithm. Four processes on an O(n²) loop is still an O(n²) loop.
A second process brings its own lock, and its own memory
In plain words If the trouble is that everyone is sharing one microphone, build a second room with its own. The catch is immediate and total: nothing said in one room is heard in the other. Anything you want the other room to know has to be written down, carried across, and read out again.
multiprocessing.Process starts a second operating-system process running a second CPython interpreter, with its own heap and its own GIL. The two really do execute Python at the same instant. The benchmark that took 1.81 s serially and 2.49 s across two threads took 1.08 s across two processes.
Arguments and results are pickled to bytes and rebuilt on the
other side, so the child gets equal values at different addresses, never the
same objects.
A thread shares your objects
Same heap, same addresses, same id(). Mutate a list in one thread and every other thread sees it, which is convenient, and is also where every bug in the section above comes from.
A process shares your values
Different heap, different addresses. Mutate the copy in the child and the parent never finds out. No locking needed, because there's nothing shared left to lock.
The guard is not optional
To reach your child function, the new process has to import your module, which re-runs every line at the top level of it. Without the
guard, that includes the line that starts a process, so the child starts a
grandchild, and so on until something gives out.
spawn, the child imports __main__, and an interactive session isn't importable.
Every multiprocessing example in this session belongs in a
.py file. Check what your platform does with
mp.get_start_method() before you assume.
Getting something back
Since mutation does not travel, the child has to hand results over explicitly. In rough order of how often you should reach for them:
Pool.map or ProcessPoolExecutor collect it.
return
multiprocessing.Queue or Pipe, for results that arrive as they are produced.
Queue
shared_memory or a Manager, when copying is what is actually costing you.
shared
One thread, one to-do list, no lock to fight over
In plain words
Think of one person cooking dinner. They put the kettle on and, instead of
standing there, go and chop onions. They aren't two people and they never do two things at once. They just never stand still while something else is boiling. asyncio is that, and the await keyword is
where they put a job down.
An async def function returns a coroutine object when called and runs none of its body, just like a generator in Session 05. The event loop drives those objects,
running one until it hits an await that cannot complete
immediately. At that point the coroutine suspends, keeping its frame, and the
loop picks up the next ready one. Two one-second waits took
0.50 s under gather, against 1.01 s awaited one after the other, on a single thread, with threading.enumerate() reporting only MainThread.
Threads are pre-emptive
Your code can be interrupted anywhere the interpreter allows it, and you cannot see where by reading the source. That is why you need locks, and why Demo 2 lost a deposit.
Coroutines are cooperative
A coroutine is only ever suspended at an await, a word you typed. Between two awaits nothing else can run, so plain
read-modify-write of a local structure is safe without a lock.
yield and someone calls
next() to resume it. A coroutine suspends at
await and the event loop resumes it. Same paused frame, same kept locals. The only new idea is who decides when to continue, and what they were waiting for.
The one mistake that undoes all of it
There is one thread. A synchronous call that blocks it blocks the loop, and every other coroutine stops, including the ones that were just about to finish. This isn't a subtle performance issue. It turns a concurrent program back into a sequential one.
| Blocks the whole loop | Use instead |
|---|---|
time.sleep(1) |
await asyncio.sleep(1) |
requests.get(url) |
An async client, awaited |
open(path).read() on a slow disk |
await asyncio.to_thread(read_it, path) |
| A long pure-Python computation | A process pool, awaited through run_in_executor |
await fails quietlyfetch(url) without awaiting it builds a coroutine
object and throws it away. Nothing runs, no exception is raised, and you
get RuntimeWarning: coroutine 'fetch' was never awaited on stderr, which is easy to miss in a busy log. If an async function
seems to do nothing at all, look for the missing keyword first.
One thread. One event loop object holding a list of ready tasks and a list of timers. Several coroutine objects on the heap, each with a paused frame and a line number it stopped at. No second stack, no GIL contention, and not one lock, because nothing ever ran at the same time as anything else.
A future without it, and why your bugs survive
In plain words The microphone rule is finally being lifted. There is now a build of CPython with no GIL at all, where threads really can compute at the same time. It's a big change, and it makes the mistakes in this session more likely to bite, not less.
PEP 703
added an optional free-threaded build. Python 3.13 shipped it as
experimental and Python 3.14 made it officially supported, alongside the
ordinary build, which still has the GIL and remains the default. On 3.13 or
newer, ask the interpreter which one you are in with
sys._is_gil_enabled().
CPU-bound threads finally scale
Four threads on four cores computing at once, sharing objects directly,
with none of the pickling that multiprocessing charges you.
That is what all the work was for.
Every race in this session
The lost deposit in demo 2 isn't caused by the GIL. It happens despite it. Remove the lock and the two threads no longer take turns at all, so the gap you were relying on getting away with is wide open.
Lock, or lives in a queue.Queue, or doesn't exist at all because every task returns a value, then free-threaded Python is pure upside. The work you did to make threads safe is the same work that makes them fast later.
import sys; sys._is_gil_enabled() exists from Python 3.13 onwards, and raises AttributeError before that. On a normal build it prints True. Free-threaded builds are installed separately (on most platforms the executable has a t suffix, such as python3.14t) and print False. The same
script, two answers, and only one of them runs your threads in parallel.
Six ways this goes wrong
Adding threads to speed up a computation
The classic. The work is pure Python, so every thread needs the GIL for every bytecode, so they take turns and pay for the swapping on top. Measure before and after, and you'll usually find it got slower. Use processes, or a library that releases the GIL, or a better algorithm.
Trusting the GIL to protect your data
"Only one thread runs at a time, so I don't need a lock." Only one thread runs one bytecode at a time. Anything you write as one line and think of as one action (read-add-write, check-then-act, append-if-missing) is several, and a switch can land in the middle.
Blocking the event loop
One time.sleep, one synchronous HTTP call or one long
computation inside a coroutine stops every other coroutine dead. The
symptom is an async program that is exactly as fast as the sequential one
it replaced. Push the blocking part out with
asyncio.to_thread or an executor.
Omitting if __name__ == "__main__":
Wherever the start method is spawn, the child re-imports your
module, re-runs the line that started it, and spawns another child. The
error is usually a wall of RuntimeError about the bootstrapping
phase. The guard is part of the API, not a convention.
Never joining, or never awaiting
A thread you don't join() may not have finished when you read its results. A coroutine you don't await never runs at all and only whispers a RuntimeWarning. In both cases the program produces an answer, which is why the bug survives so long.
Taking two locks in two different orders
One thread holds A and wants B; another holds B and wants A; neither will ever move again, and your program simply stops with no error at all. Fix it by always taking locks in one fixed order, by holding one lock at a time, or by not sharing the state in the first place.
asyncio. Between which two lines could another thread run? That finds the race. If the answer to the second is "anywhere", you need a lock, a queue, or no sharing at all.