On this page
Description
Threads let one Python program do several things at once: download ten files while a progress bar updates, or serve many web requests while some of them wait on a database. Python's threading module makes them easy to start - and Python's GIL (global interpreter lock) makes them behave differently from threads in Java or C++. This lesson explains what the GIL does and doesn't do for you, the tools for making threads cooperate safely, and how threads start, fail and stop.
Here's the belief that causes the most trouble: "the GIL makes Python code thread-safe". Look at this deposit:
def deposit(amount: int) -> None:
current = account.balance
audit(amount) # any call between the read and the write...
account.balance = current + amount # ...lets another thread's deposit be overwritten
Four threads making 200,000 one-dollar deposits each should end with 800,000. Three runs on Python 3.14 ended with 711,045, 740,304 and 716,669. By the end of the first two sections you'll know exactly where the missing money went, and the three-line fix.
We'll go from simple to more technical:
- What the GIL is, and when threads help (waiting) or don't (computing).
- What the GIL protects - and the read-then-write races it doesn't.
- Locks, events, queues, conditions and semaphores.
- How threads start, fail and end.
The GIL
In standard CPython, only one thread runs Python code at any moment. The thread that's running holds the GIL; the others wait for it. Python hands the GIL to another thread regularly - it asks the running thread to let go every 5 milliseconds (sys.getswitchinterval() is 0.005) - and a thread also lets go whenever it waits: for the network, a file, time.sleep, or a lock.
That gives the most important rule about Python threads:
- Threads help when the work is waiting. Four threads that each wait 0.2 s for a web request finish in about 0.2 s, not 0.8 s - while one waits, another runs.
- Threads don't help when the work is computing. Four threads each summing five million numbers took 0.47 s; doing the four sums one after another took 0.46 s. Only one of them can compute at a time. For CPU-heavy work, use processes (the next skill) or a library written in C that releases the GIL, such as NumPy.
What the GIL protects, and what it doesn't
Because only one thread runs at a time, a single operation on a built-in object can't be interrupted halfway. These are safe to call from many threads at once:
items.append(x) # list
cache[key] = value # dict
queue_deque.popleft() # collections.deque
What isn't safe is a sequence of steps that must happen together. Python can switch threads between any two of them. In the deposit:
- Thread A reads the balance: 100.
- Thread A calls
audit- and Python switches to thread B. - Thread B reads the balance: still 100, adds its 10, writes 110.
- Thread A comes back and writes its own 100 + 10 = 110. Thread B's deposit is gone.
The same race hides in "check, then act": if key not in cache: cache[key] = load(key) can load twice. Even count += 1 is a read, an add and a write; in the standard build a switch rarely lands in the middle of it, but rarely isn't never, and on the free-threaded build (a later skill) it loses updates all the time.
The fix is a lock around the steps that belong together:
lock = threading.Lock()
def deposit(amount: int) -> None:
with lock: # only one thread at a time in here
current = account.balance
audit(amount)
account.balance = current + amount
with lock: releases the lock even if the block raises.
One thing Python spares you, compared with Java: there's no "visibility" problem and no volatile. When one thread assigns to a shared variable, every other thread sees the new value. Python's thread-safety problem is only about steps being interrupted.
The synchronization tools
| Tool | What it's for |
|---|---|
Lock | Only one thread at a time in a block of code |
RLock | A lock the same thread may take again (re-entrant) |
Event | A flag threads can wait on: "stop now", "data is ready" |
queue.Queue | Handing work from producer threads to consumer threads |
Condition | Waiting until some state becomes true |
Semaphore / BoundedSemaphore | At most N threads doing something at once |
stop = threading.Event()
while not stop.wait(timeout=1.0): # sleeps up to 1 s; stop.set() wakes it at once
poll()
jobs = queue.Queue(maxsize=100) # put() waits when full, get() waits when empty
Details worth knowing:
- A
Lockcan't be taken twice by the same thread. If a method holding the lock calls another method that takes the same lock, the thread waits for itself forever. Use anRLockthere - or, better, keep locked sections small and don't call out while holding them. queue.Queueis the simplest safe design. Instead of sharing data and locking it, threads pass work through a queue;task_done()andjoin()let you wait until every item has been processed.- A
Conditionmust be held to wait or notify, and you wait in a loop, because by the time a thread wakes up the state may have changed again:
cond = threading.Condition()
with cond:
while not items: # always check again after waking
cond.wait() # releases the lock while waiting
item = items.pop()
# producer: with cond: items.append(job); cond.notify()
cond.notify() outside with cond: raises RuntimeError: cannot notify on un-acquired lock. cond.wait_for(lambda: items) writes the loop for you.
Semaphore(n)limits concurrency - say, at most 10 calls in flight to a fragile partner API. Arelease()without a matchingacquire()silently adds a permit (a test'sSemaphore(2)ended up allowing 3).BoundedSemaphoreraisesValueError: Semaphore released too many timesinstead. Use it aswith sem:so acquire and release always pair.
How threads start, fail and end
t = threading.Thread(target=import_file, args=(path,), name="worker")
t.start() # begins running import_file(path) in the new thread
t.join() # waits for it to finish
Three behaviors surprise people:
- An exception in a thread doesn't reach
join(). It's printed to stderr asException in thread worker:with the traceback, that thread ends, andjoin()returns normally - the main program carries on as if nothing happened. Catch exceptions inside the target, or useconcurrent.futures.ThreadPoolExecutor, wherefuture.result()re-raises the thread's exception. - Daemon threads are killed at exit. Python waits for all normal threads before the program exits. A thread started with
daemon=Trueis stopped as soon as the main thread finishes - mid-task, without running itsfinallyblocks. Use daemon threads only for work that's fine to abandon. threading.local()outlives a task in a pool. It gives each thread its own copy of some attributes - but a pool reuses threads, so a value set by one task (say, the current user) is still there for the next task on that thread. Reset it at the start of each task, or pass the value explicitly (or usecontextvars).
Which tool for which job?
| Situation | Use |
|---|---|
| Many slow network or disk calls | threads (or ThreadPoolExecutor) |
| Heavy computation in Python | processes, not threads |
| Several steps on shared data | a Lock around them |
| Producer and consumer threads | queue.Queue |
| Telling a thread to stop | threading.Event |
| At most N calls at once | BoundedSemaphore(N) |
| Getting a thread's result or exception | ThreadPoolExecutor and future.result() |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| Trusting the GIL to protect read-then-write | lost updates | a Lock around the sequence |
if key not in d: d[key] = ... from many threads | work done twice | a lock, or d.setdefault for simple values |
| Threads for CPU-bound work | no speed-up | processes |
Taking the same Lock twice in one thread | the thread hangs | RLock, or restructure |
cond.wait() inside an if | acts on a state that changed | a while loop, or wait_for |
An unmatched Semaphore.release() | the limit silently grows | BoundedSemaphore and with |
Relying on join() to report errors | exceptions are only printed | ThreadPoolExecutor and result() |
| Important work in a daemon thread | killed at exit | a normal thread, joined |
threading.local in a pool | values leak to the next task | reset per task, or pass them in |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. Where did the missing deposits go?
Show answer
Python switched threads between a deposit's read and its write. Two threads read the same balance, and the second write overwrote the first. The GIL only makes single operations uninterruptible; put a Lock around the read, the call and the write.
2. A worker thread raises an exception. What does join() do?
Show answer
It returns normally. The exception was printed to stderr and ended only that thread. Use ThreadPoolExecutor and future.result() if you need the exception re-raised in the caller.
3. Four threads each crunch numbers for 1 second. Roughly how long does the program take in standard CPython?
Show answer
About 4 seconds. Only the thread holding the GIL can run Python code, so CPU-bound threads take turns. Use processes to use several cores.
Key takeaways
- Only one thread runs Python code at a time: threads overlap waiting, not computing.
- The GIL makes single operations on built-ins safe, not sequences - lock read-then-write and check-then-act.
- No
volatilein Python: the issue is interruption, not visibility. Lock/RLock,Event,Queue,Condition(in a loop),BoundedSemaphore.- Thread exceptions don't reach
join(); daemon threads die at exit; thread-locals outlive pool tasks.