Topic 12.3
Threads with threading
In one line
A thread is a flow of code that runs inside your process and shares its memory. You start threads with threading.Thread, wait for them with join, and protect shared data with a Lock; queues and thread-local data are the safer ways to share and separate state.
Think of it like this
Two clerks sharing one notebook at a shop counter. Both can serve customers at once, which is fast. But if both read the stock number 10, both sell one item, and both write 9, the notebook says 9 when the real stock is 8. A race condition is exactly this. A lock is a pen that only one clerk may hold: whoever has the pen reads, updates and writes the number before handing the pen over.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Thread
- A flow of code inside a process, sharing memory with the other threads.
- start / join
startbegins running the thread;joinwaits until it has finished.- Race condition
- A bug where the result depends on the timing of threads.
- Lock (mutex)
- An object only one thread can hold at a time, used to guard shared data.
- Critical section
- The piece of code that must run by one thread at a time.
- Deadlock
- Threads waiting for each other forever, each holding something the other needs.
- Daemon thread
- A background thread that is killed when the main program ends.
- Sentinel
- A special value placed in a queue to tell a consumer to stop.
- Thread-local
- Data that each thread sees as its own private copy.
Step by step
01Start and join
A thread runs a function. start() returns at once and the new thread runs alongside the main one; join() blocks until it ends. Without join, the main thread might finish and print its result before the workers are done.
Important: the Run button on this page cannot start threads. The browser sandbox does not allow a web page to create new threads for Python, so Thread.start() raises RuntimeError. That is why the thread programs in this topic were run on a normal Python and their output is pasted next to them. Everything around threads that does not need a new thread (locks, queues, thread-local data) runs live below.
import threading
def work(n):
print("working on", n)
t = threading.Thread(target=work, args=(1,))
t.start() # runs work(1) in a new thread
t.join() # wait for it02How a race loses an update
The next example does not need real threads. It splits two withdrawals into their three steps (read, calculate, write) and runs the steps in an unlucky order, the way the operating system could. Both read 100. A writes 70, then B writes 50. The final balance is 50, but two withdrawals of 30 and 50 should leave 20.
This is a lost update, and it is the same bug as counter += 1 from two threads. Simulating the schedule by hand makes it deterministic so you can see it.
The simulation uses generators. Recall from Topic 9.2: a generator pauses at each yield and continues where it stopped when you call next on it. Here each pause stands for a moment where the operating system could switch threads.
03Fix it with a lock
Wrap the read-modify-write in with lock:. A thread that arrives while another is inside waits at the with line. The result is now always correct: four threads adding 10,000 each always give 40,000. The cost is that the locked part runs one thread at a time, so keep it as small as you can.
lock = threading.Lock()
counter = 0
def work(times):
global counter
for _ in range(times):
with lock: # read, add, store as one unit
counter += 104Locks and queues without threads
A lock is only an object with a state, so its API runs anywhere: locked() tells you if it is held, acquire(blocking=False) returns False instead of waiting, and acquire(timeout=...) waits up to that long. release() on a lock that is not held raises RuntimeError. A queue.Queue is first-in first-out and can have a maximum size; put_nowait and get_nowait raise queue.Full and queue.Empty.
05Producer and consumer with a queue
A consumer thread loops forever on queue.get(). The producer puts the work, then one sentinel. jobs.join() waits until task_done() was called for every item, and worker.join() waits for the thread to exit. No lock appears in the code, because the queue does the locking. Shared results are only appended by one thread here.
def consumer():
while True:
item = jobs.get()
if item is STOP:
jobs.task_done()
break
results.append(item.upper())
jobs.task_done()06Deadlock, and how a timeout helps
Thread 1 holds lock A and wants B; thread 2 holds B and wants A. With a plain acquire() both would wait forever and the program would hang. With a timeout, thread 1 gives up after 0.1 seconds and releases A, which lets thread 2 finish. In real code a timeout is a safety net: the real fix is a fixed lock order.
07Daemon threads and thread-local data
A thread created with daemon=True is abandoned when the main thread ends, so never use it for work that must complete. Thread-local data is the opposite of shared data: set local.name = "x" in one thread and other threads see their own name (or an AttributeError if they never set it). Frameworks use it to keep a per-thread request or connection.
Try it yourself
- 1
Reorder the schedule
In the race example change the schedule to
[a, a, a, b, b, b]so A finishes before B starts. Predict the final balance first. It is 20, because B now reads the balance A already updated. The bug is not in the code of either thread: it is in the interleaving. - 2
Poke the lock
In the lock example call
lock.release()twice in a row and read the error. Then replace the manualacquire/releasewithwith lock:and notice you can no longer forget the release. - 3
Run the thread programs on your computer
Save the fourth example as
counter.pyand runpython counter.py. Then delete thewith lock:line (and un-indent its body) and run it several times. On many machines the total stays 40000 for small loops because switches are rare; increase10_000to1_000_000and the lost updates become much more likely. A bug that depends on timing is exactly why locks are needed even when your test passes.
Code & diagrams
This is the browser sandbox talking, not a bug in your code. The same program works on a normal Python.
Expected output
RuntimeError: can't start new thread
Current thread: MainThread
Threads alive: 1Each generator is a thread's three steps. We choose the order in which they run, so the bug happens every time.
Expected output
A read 100
B read 100
A calculated 70
B calculated 50
A wrote 70
B wrote 50
Final balance: 50 (it should be 20)The objects work in a single thread, so you can learn their API here.
Expected output
locked at start: False
after acquire: True
second try, non-blocking: False
second try, 0.01 s timeout: False
inside with: True
after with: False
RLock taken twice by one thread: fine
main's value
queue is full
summarise translate | size now: 0
queue is emptyNot runnable in the browser. Verified with Python 3.12 on a desktop. The output is the same every run because the lock makes the counter exact and join waits for every thread.
import threading
counter = 0
lock = threading.Lock()
local = threading.local()
def work(name, times):
global counter
local.name = name # each thread sees only its own value
for _ in range(times):
with lock: # only one thread inside at a time
counter += 1
assert local.name == name
threads = [threading.Thread(target=work, args=(f"worker-{i}", 10_000)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join() # wait until each one has finished
print("Final counter:", counter)
print("Threads still alive:", threading.active_count())Output
Final counter: 40000
Threads still alive: 1Not runnable in the browser. Verified with Python 3.12 on a desktop. T1 is impatient (0.1 s) and T2 patient (0.6 s), so the outcome is the same every time.
import queue
import threading
jobs = queue.Queue()
results = []
STOP = None # a sentinel: "no more work"
def consumer():
while True:
item = jobs.get()
if item is STOP:
jobs.task_done()
break
results.append(item.upper())
jobs.task_done()
worker = threading.Thread(target=consumer)
worker.start()
for prompt in ["summarise", "translate", "classify"]:
jobs.put(prompt)
jobs.put(STOP)
jobs.join() # wait until every item has been processed
worker.join()
print("Results:", results)
# Deadlock: each thread holds one lock and waits for the other's.
lock_a, lock_b = threading.Lock(), threading.Lock()
both_hold_one = threading.Barrier(2)
report = []
def grab(first, second, name, patience):
with first:
both_hold_one.wait() # make sure the bad timing really happens
got = second.acquire(timeout=patience)
report.append(f"{name} got the second lock: {got}")
if got:
second.release()
t1 = threading.Thread(target=grab, args=(lock_a, lock_b, "T1", 0.1))
t2 = threading.Thread(target=grab, args=(lock_b, lock_a, "T2", 0.6))
t1.start(); t2.start(); t1.join(); t2.join()
print(sorted(report))Output
Results: ['SUMMARISE', 'TRANSLATE', 'CLASSIFY']
['T1 got the second lock: False', 'T2 got the second lock: True']Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Release a lock you do not hold
Call release() on a lock that was never acquired.
import threading
lock = threading.Lock()
lock.release()Myth vs fact
Myth
Because of the GIL I never need locks in Python.
Fact
The GIL can switch threads between the steps of counter += 1, so updates get lost (Topic 12.2). Any shared data that is changed by more than one thread needs protection.
Myth
If my test passed, the threaded code is correct.
Fact
Race conditions depend on timing and may need millions of operations to appear. Design for safety (queues, locks) instead of trusting a green run.
Myth
A daemon thread is a faster thread.
Fact
It only changes what happens at exit: daemon threads are killed when the main program ends. It does not change speed.
Myth
Adding more locks makes code safer.
Fact
More locks mean more chances of deadlock and less parallelism. Fewer, smaller, consistently ordered locks are safer.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Prefer message passing (
queue.Queue) or immutable data over shared mutable state with locks. Most thread bugs are shared-state bugs, and a design with one owner per piece of data cannot race. - ▸
Thread safety is a property of your sequence of operations, not of single calls.
if key not in cache: cache[key] = compute()is two operations, so two threads can both compute. Hold a lock across the whole check-and-set, or use a library that provides an atomic operation. - ▸
Other synchronisation tools exist:
Event(one thread signals others),Condition,Semaphore,Barrier(used above to force the deadlock timing), andthreading.Timer. Exceptions raised inside a thread do not reach the main thread;threading.excepthookis called instead, and a thread pool (Topic 12.4) returns them through futures, which is one more reason to prefer pools. - ▸
In a free-threaded build (Topic 12.2) threaded code runs on several cores, and races that rarely showed up under the GIL become frequent. Code that was only safe by luck gets exposed, so run your thread tests there too if you plan to support it.
- ▸
A non-daemon thread keeps the interpreter alive: at exit Python waits for every non-daemon thread to finish, so a forgotten worker loop makes the program hang after its last line. Give long-running workers a stop signal (a
threading.Eventthey check, or a sentinel in their queue) andjointhem. Signal handlers andKeyboardInterruptare delivered to the main thread only, so keep the main thread responsive (loop onjoin(timeout=...)) if Ctrl+C must stop the program. - ▸
Lockis not reentrant: the same thread acquiring it twice deadlocks itself, which is the caseRLockexists for. Better still, design so a locked function never calls another locked function. From Python 3.13,queue.Queue.shutdown()signals consumers to stop without a sentinel.
Remember this
- 1
threading.Thread(target=fn, args=(...))creates a thread,.start()runsfnin it, and.join()waits until it is finished. AThreadobject can be started once. All threads of a process share the same variables, which is what makes them handy (no copying) and dangerous (everyone can change everything). - 2
A race condition is a bug where the result depends on the exact timing of threads. The classic cause is a read-modify-write on shared data (
counter += 1, Topic 12.2). It is the worst kind of bug to debug, because it may appear once in a thousand runs and vanish when you add aprint. - 3
A Lock (a mutex) lets one thread at a time into a section of code. Use it as
with lock:so it is always released, even if an error occurs. (Recall from Topic 7.4:withguarantees cleanup when its block ends.) Keep the locked section small.acquire()can also be non-blocking or have a timeout, andRLocklets the same thread take a lock again, which matters when one locked function calls another. - 4
A deadlock happens when threads wait for each other forever: thread 1 holds lock A and waits for B, thread 2 holds B and waits for A. Nothing is wrong with either thread alone. Prevent it by always taking locks in the same order, holding as few as possible, and using timeouts so a stuck thread can report instead of hanging.
- 5
**
queue.Queueis the safest way to pass work between threads. It is thread-safe** already, meaning it is safe to use from many threads at once, so you need no lock in your code. Producersputitems and consumersgetthem, andjoinwaits until every item has been marked done withtask_done. A special value (a sentinel) such asNonetells a consumer to stop. Prefer sending messages through a queue over sharing variables behind locks. - 6
A daemon thread (
daemon=True) does not stop the program from exiting: when only daemon threads remain, Python ends and kills them mid-work. Use them only for background jobs where losing the last bit of work is fine. Thread-local data (threading.local()) gives every thread its own copy of the attributes you set on it, useful for things like a per-thread database connection. For I/O-heavy work you will usually use a pool (Topic 12.4) or asyncio instead of managing threads by hand.
Explain it without notes
What is a race condition? Give an example with a counter.
How does a Lock prevent a race, and why use with lock: instead of acquire/release?
What is a deadlock, and how do you prevent one?
Why is queue.Queue usually better than shared variables with locks?
Your script prints its last line but the process never exits, and it uses threads. What is the likely cause, and what do you change?
Practice
A shared counter starts at 0 and many threads add to it. Write a function work(times) that adds 1 to the global counter times times, with each addition inside with lock: so no update is lost. Call work(5) once and print counter.
Write a function try_take(lock) that returns "got it" if the lock could be acquired without waiting (and releases it), and "busy" otherwise. Test it on a free lock and a held lock.
Write a function drain(q) that takes every item out of a queue.Queue without blocking and returns them as a list.
Trade-offs
- ↔
Locks: simple and flexible, but easy to forget, easy to hold too long, and the source of deadlocks.
- ↔
Queues: safe and easy to reason about, but they add a hand-off and work best when the design is producer/consumer.
- ↔
Threads in general: cheap sharing and good for blocking I/O, but timing bugs are hard to reproduce, so many teams prefer pools or asyncio for new code.
Done when you can
Done when you can start and join a thread and explain why the Run button here cannot.
Done when you can explain a lost update with the three steps read, calculate, write.
Done when you can protect a counter with
with lock:and explain why it must wrap the whole read-modify-write.Done when you can describe a deadlock and name two ways to prevent it.
Done when you can build a producer and consumer with
queue.Queueand a sentinel.