Home › Session 07
Runtime · Session 07

The GIL & Concurrency

Python can start as many threads as you like, and will run one of them at a time. That single sentence explains why your threaded program got slower, why your counter came out one short, and why the fix is sometimes a lock, sometimes a second process, and sometimes neither. Here's the lock itself, and what it's actually protecting.

~50 minutes
6 interactive demos
Builds on Sessions 01, 02 and 05
Learning goal

The GIL exists because of refcount.

Session 01 showed that every object carries a count of how many names point at it. That count is a plain number in memory. Let two threads change it at the same instant and it goes wrong without a sound. Too high and the object never gets freed, too low and it gets freed while you're still using it. CPython's answer is one lock around the whole interpreter. Everything else in this session follows from that.

Thread Lock Process coroutine event loop
Recommended path
1 Watch one row. Demo 1 adds a grey box at the bottom of the memory panel. The GIL line in it names the only thread allowed to run.
2 Lose a deposit. Demo 2 shows a correct-looking line of code dropping an update, with the GIL working perfectly the whole time.
3 Then pick a tool. Demos 4 to 6 are three different escapes: release the lock, own a second one, or never contend for it at all.
Interactive lab

The memory panel gained a new box

Below the call frames there is now a grey box labelled CPython interpreter. It isn't a call frame. It's the runtime itself, and its GIL row names whichever thread currently has permission to execute Python. Every frame above it that isn't that thread is alive, ready, and not running.

Tip: at every step, read the GIL row first, then find the frame whose badge says running. They always agree.
1 Choose a demo
›
2 Step with → or Play
›
3 Watch who holds the lock
›
4 Read the model below
One frame per live thread: solid means running, dashed means waiting
Thread, Lock, Process and coroutine objects
→ next ← back Space play R reset
python
1 / 1
Memory
stack heap
1

One thread, and it already holds the lock

Press Play or use Next to watch the interpreter lock change hands.

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.

Model
The GIL is a property of CPython, not of Python
The language doesn't require it. Jython and IronPython never had one, PyPy has one for the same reasons CPython does, and CPython itself can now be built without it (see A future without it). "Python is single-threaded" is wrong twice over. The threads are real OS threads, and the restriction belongs to one implementation.
Try this in a REPL

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.

The handover

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.

Tick 5 ms have passed. Another thread is waiting, so the interpreter raises a "please drop it" flag. requested
Safe point The running thread reaches a loop jump or a call and notices the flag. The current bytecode always finishes. noticed
Release It drops the GIL and queues up again behind everybody else. Its frame, its locals and its half-finished line all stay exactly as they were. suspended
Reacquire Some other thread takes it and runs. Eventually the first one gets it back and carries on from the same bytecode. resumed
Care
Nothing can force a handover that never reaches a safe point
A C extension that goes away for two seconds without releasing the GIL freezes every other Python thread for two seconds, and there is no timer that can help. This is why a well-behaved extension releases the lock around its own long computations, and why a badly-behaved one makes a whole web server stutter.
Try this in a REPL

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 dangerous half-truth

"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
Why it is easy to miss

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.

Why that is worse

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.

Key
A bug that appears one run in three is worse than one that appears every time
It passes your tests. It passes code review. It shows up as a support ticket six weeks later, on a busier machine, and it won't reproduce on yours. Don't reason about whether a switch will land in your gap. Remove the gap.

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.

python
# the gap is open: another thread can run between get() and set() acct.set(acct.get() + 1) # the gap is closed: nobody else can be inside these lines with lock: acct.set(acct.get() + 1)
Thread-1
->
holds the GIL and holds the Lock running, and safe from interference
Thread-2
->
waiting on the Lock @ 0x7fa300c0 not in the GIL queue at all, a different wait
Thread-3
->
waiting for the GIL ready to run, just not chosen yet

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".

Better
Usually you should not be writing locks at all
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.
Try this in a REPL

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.

Choosing a tool

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
Tip
You can test the answer by changing one word
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.

Separate rooms

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.

python
import multiprocessing as mp def work(n): return n * n if __name__ == "__main__": # required, not a style choice print(mp.get_start_method()) # 'spawn' on Windows and macOS with mp.Pool(4) as pool: print(pool.map(work, range(10)))
Care
Run this from a file, never from the REPL
Where the start method is 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:

Best Return a value from the function and let Pool.map or ProcessPoolExecutor collect it. return
Streaming A multiprocessing.Queue or Pipe, for results that arrive as they are produced. Queue
Large data shared_memory or a Manager, when copying is what is actually costing you. shared
Never Mutating a global and hoping. It works in your fork-based test on Linux and stops working elsewhere without telling you. no
Cost
Everything you send has to be picklable, and pays for the trip
Lambdas, nested functions, open file handles, database connections and locks cannot be pickled, and you find out when you submit the job. Even when it works, a process pool over a large array can spend more time copying than computing, which is why the benchmark above used a task big enough to be worth the postage.
The third option

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.

Key
Session 05, with a scheduler attached
A generator suspends at 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
Watch
A forgotten await fails quietly
Calling fetch(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.
What you should be able to draw

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.

What happens next

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().

What changes

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.

What does not

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.

Good
Code that was already correct stays correct
If your shared state is behind a 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.
Try this in a REPL

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.

Common mistakes

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.

Check
Two questions catch nearly all of it
Is this code waiting or thinking? That picks threads, processes or 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.
Check your understanding

Can you predict the memory?

Every question comes back to the same two: who holds the lock, and where could a switch land?

1. Why does CPython have a Global Interpreter Lock at all?
The threads are genuine OS threads, and the language says nothing about this. The GIL is CPython's way of keeping its own shared structures, refcounts first among them, correct without putting a lock on every object.
2. Four threads each run a tight loop of pure-Python arithmetic on a four-core machine. What happens?
They interleave rather than overlap, and every handover costs something. Measured while writing this session, 1.81 s serially became 2.49 s across two threads. Processes are the fix.
3. Two threads run acct.set(acct.get() + 1) once each, starting from 0. What can the balance be?
The GIL keeps one bytecode indivisible, not one line. A switch at the call boundary lets both threads read 0, and the second write lands on top of the first. Sometimes it does, sometimes it doesn't, and that's what makes it dangerous.
4. Four threads each call time.sleep(1). Roughly how long does the whole thing take?
Blocking calls hand the GIL back before they wait. All four threads sit outside the interpreter at the same time and nobody holds the lock at all. Measured: 2.01 s serially, 0.50 s across four threads.
5. A child process appends to a list it was given as an argument. What does the parent see afterwards?
Processes do not share a heap. The argument was pickled and rebuilt at a different address in a different address space. To get data back, return it, or send it through a Queue or Pipe.
6. Inside a coroutine you call time.sleep(2) instead of await asyncio.sleep(2). What happens?
There is one thread and the loop is running on it. A synchronous block holds that thread, so nothing else can be resumed. await is what hands control back, and a blocking call never does.
7. On a free-threaded build (Python 3.13+, where sys._is_gil_enabled() returns False), what happens to the lost-deposit bug from Demo 2?
The race was never caused by the GIL. It survived despite it. Removing the lock removes the accidental protection of "only one thread at a time", so code that happened to get away with it stops getting away with it. Your own Lock still works, and is still the answer.