Python Module 14 – Concurrency and parallelism: threads, processes, interpreters
Thread safety: locks, events and queues
Protect shared data with Lock and RLock, signal between threads with Event and Semaphore, and hand work to worker threads through a queue.Queue.
What you will learn
- Protect shared state with Lock and RLock
- Use Event, Semaphore and Condition to coordinate threads
- Build a producer-consumer pipeline with queue.Queue and shut it down cleanly
- Reproduce overselling and a deadlock, and prevent both
- Limit a pipeline's memory with a queue's maxsize, and stop its workers on request
Before you start
What you’ll learn · Before you start
What you will learn
- Protect shared state with Lock and RLock
- Use Event, Semaphore and Condition to coordinate threads
- Build a producer-consumer pipeline with queue.Queue and shut it down cleanly
- Reproduce overselling and a deadlock, and prevent both
- Limit a pipeline's memory with a queue's maxsize, and stop its workers on request
Before you start
On this page
Four threads make 250 deposits of 10 each into one wallet, so the balance should end at 10,000. Without protection
most of the money vanishes, because each thread reads the balance, waits a moment and writes back a sum built on an
old value. A lock fixes it: with lock: around the read, the change and the write lets only one thread at a time
run them, so they act as one step.
Other tools make threads wait for each other. An event is a flag that one thread sets and others wait for, a
semaphore lets at most a fixed number of threads past at once, and a condition wakes a waiting thread after
another one changes shared data. A queue, queue.Queue, hands work from producer threads to consumer threads and
does the locking itself, so threads exchange work instead of sharing data.
A lock makes the update one step
Each deposit below waits 0.1 milliseconds between its read and its write. The program makes the 1,000 deposits twice, without a lock and with one:
import threading
import time
class Wallet:
def __init__(self):
self.balance = 0
self._lock = threading.Lock()
def deposit_unsafely(self, amount):
current = self.balance
time.sleep(0.0001) # a short wait between the read and the write
self.balance = current + amount
def deposit(self, amount):
with self._lock: # one thread at a time from here ...
current = self.balance
time.sleep(0.0001) # the same wait, now inside the lock
self.balance = current + amount # ... to here
def run(locked):
"""Four threads make 250 deposits of 10 each into one new wallet."""
wallet = Wallet()
deposit = wallet.deposit if locked else wallet.deposit_unsafely
def make_deposits():
for _ in range(250):
deposit(10)
start = time.perf_counter()
threads = [threading.Thread(target=make_deposits) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
return wallet.balance, time.perf_counter() - start
balance, _ = run(locked=False)
print(f"without a lock: balance {balance:,} after 1,000 deposits of 10 (expected 10,000)")
balance, took = run(locked=True)
print(f"with a lock: balance {balance:,} after 1,000 deposits of 10 (expected 10,000)")
print(f"the locked run took {took:.2f} s: its waits ran one at a time") Output
without a lock: balance 2,510 after 1,000 deposits of 10 (expected 10,000) with a lock: balance 10,000 after 1,000 deposits of 10 (expected 10,000) the locked run took 0.23 s: its waits ran one at a time
This output changes from run to run: how many deposits survive without the lock depends on how the threads happen to be scheduled, and the time is measured on each run; the locked balance is the same every time
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 wallet_lock.py
Without the lock about a quarter of the money arrived, a different amount on every run; with it, the balance is
right. with self._lock: calls acquire() when the block starts and release() when it ends, however it ends, as a
try and finally would, and every other thread that reaches the same with waits meanwhile. Step through two
deposits of 10 and watch when thread B gets to read the balance:
The drawing shows the whole scene; its 12 steps are listed below.
Every step as text
- Two deposits, no lock. Threads A and B each deposit 10 into a wallet whose balance is 0, with deposit_unsafely(). Each deposit reads the balance, waits a moment and writes back the sum.
- A reads 0. Thread A reads the balance, 0, and starts its short wait. A thread that waits lets the other threads run.
- B reads 0 too. Thread B runs during A's wait and reads the balance. A has not written yet, so B reads the same 0.
- A writes 10. A's wait ends and it writes its sum, 0 + 10.
- B overwrites it. B writes its own sum, also built on the 0 it read, so the balance stays 10 and A's deposit is gone. Nothing raised an error.
- Now with a lock. The same two deposits through deposit(). Its read and its write sit inside with self._lock, which a thread takes if it is free and waits for if another thread holds it.
- A takes the lock. A reaches the with statement first. The lock is free, so acquire() takes it at once.
- A reads 0. A reads the balance, 0, and starts its wait, this time while it holds the lock.
- B has to wait. B reaches the same with statement while A holds the lock, so its acquire() blocks. B cannot read the balance until A has finished.
- A writes and releases. A writes 0 + 10 and leaves the with block, which releases the lock however the block ends.
- B goes next. B's acquire() returns now. It reads the balance A has just written, 10.
- Both deposits counted. B writes 10 + 10 and releases the lock. Both deposits arrived, because B read only after A had written; the price is that B waited for the whole of A's update, wait included.
The locked run was slower: the waits ran one after another, because each sat inside the lock. Two rules follow.
- Keep the locked block short. Read, decide and write inside it; do slow work, such as a network call, before or after it.
- Never call code you do not control while you hold a lock. A callback may be slow, may wait for another thread, or may try to take the same lock.
One method that skips the lock
A lock protects only the code that takes it. A withdraw() that changes self.balance without with self._lock:
can run in the middle of a deposit, and the lost update is back.
A plain Lock cannot be taken twice by one thread: a second acquire() from its holder waits for itself for ever, a
A state in which threads wait for each other for ever, such as two threads that each hold one lock and wait for the other's. Nothing fails and nothing runs, so the program simply stops moving.. An A lock, threading.RLock(), that the thread holding it may take again without waiting. It counts the acquires, and only its owner may release it, once for each acquire. may be taken
again by the thread that holds it, which suits locked methods that call other locked methods of the same object.
Key idea
A lock turns a read, a change and a write into one step that other threads wait for: keep it short, and take the same lock everywhere the data changes.
Checkpoint
Check: A lock around a read, a change and a write
Events, semaphores and conditions make threads wait for each other
Locks protect data; the other tools hold threads back until something has happened. This program starts six download threads that wait for a start signal and then download at most two at a time:
import threading
import time
ready = threading.Event() # a flag: False until set()
slots = threading.Semaphore(2) # at most 2 threads inside at once
inside = most_inside = finished = 0
counter_lock = threading.Lock()
def downloader():
global inside, most_inside, finished
ready.wait() # every thread waits here until the main thread sets the flag
with slots:
with counter_lock:
inside += 1
most_inside = max(most_inside, inside)
time.sleep(0.05) # a download
with counter_lock:
inside -= 1
finished += 1
threads = [threading.Thread(target=downloader) for _ in range(6)]
for thread in threads:
thread.start()
time.sleep(0.1)
print(f"before set(): flag {ready.is_set()}, downloads finished: {finished} of 6")
ready.set()
for thread in threads:
thread.join()
print(f"after set(): flag {ready.is_set()}, downloads finished: {finished} of 6, most at once: {most_inside}") Output
before set(): flag False, downloads finished: 0 of 6 after set(): flag True, downloads finished: 6 of 6, most at once: 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 signals.py
Step through the same run, from the six blocked threads to the last download:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Six threads start. The main thread starts six downloader threads. Each one calls ready.wait() first, and the flag starts out false, so all six block there.
- Nothing moves yet. The main thread sleeps for 0.1 s and then counts. No thread has got past wait(), so no download has finished.
- The flag is set. The main thread calls ready.set(). Every thread waiting on the event wakes at once, and any later wait() returns at once too.
- Two permits handed out. All six threads reach with slots, but the semaphore has only two permits. Two threads take them and start downloading; the other four wait inside acquire().
- Permits come back. A download takes 50 ms. When a thread leaves its with slots block, its permit goes back and one of the waiting threads takes it, so two downloads are always in progress.
- Six of six. After three rounds of two, every download has finished. The main thread's join() calls return, and the busiest moment had two threads inside, as the program printed.
All six waited at ready.wait() until set(), and the busiest moment had two downloads in progress, one per permit
of the semaphore.
| Criterion | Lock | RLock | Event | Semaphore | Condition |
|---|---|---|---|---|---|
| What it does | one thread at a time | a lock its holder may take again | a flag: set(), clear(), wait() | a counter of permits | waits until another thread calls notify() |
| Typical use | a read-change-write on shared data | locked methods calling each other | start now, stop now, config loaded | at most n downloads or connections | wait until a shared value changes |
| Released by | any thread | only the thread that took it | nothing to release | any thread | its lock, while it waits |
| When to choose | Protecting data | Re-entrant code | One-off signals | Limits on concurrency | Custom waiting rules |
An Event starts out false: wait() blocks until another thread calls set(), or until its timeout runs out. A
A counter of permits, such as threading.Semaphore(2): each acquire() takes a permit and waits while none is left, and each release() gives one back, so at most that many threads are inside at once. holds a number of permits; each acquire() takes one and waits
when none is left, and a BoundedSemaphore raises ValueError if code releases more permits than it took. A
Condition pairs a lock with a place to wait: wait() releases the lock and sleeps, and notify(), called by the
thread that changed the data, wakes a sleeper, which takes the lock back before wait() returns. Few programs need
one directly, because the most common thing to wait for is new work, which a queue already handles.
Checkpoint
Check: Events, semaphores and conditions
A queue hands work over and does the locking itself
The safest shared data is data that only one thread touches. In the
A design in which producer threads put work into a queue and consumer threads take it out, so each item goes to one consumer and the threads share the queue instead of the data. pattern, producer threads put() work into a queue.Queue,
consumer threads get() it out, and the queue does all the locking:
A producer, a bounded queue and two consumers
Text description of the diagram
1. The producer thread puts each order into a queue.Queue created with maxsize=3. When three orders are already waiting, put() blocks until a consumer has taken one, so the producer can never run far ahead. 2. Two consumer threads, packer-1 and packer-2, each call get() in a loop. get() blocks while the queue is empty and hands every order to exactly one consumer. 3. After finishing an order, a consumer calls task_done(). The queue's join() returns once task_done() has been called for every order that was put in. 4. shutdown() closes the queue: further put() calls raise ShutDown, and a consumer waiting in get() raises ShutDown once the queue is empty, which ends its loop.
import queue
import threading
import time
orders = queue.Queue(maxsize=3) # put() waits while 3 orders are already queued
packed = []
packed_lock = threading.Lock()
def packer(name):
while True:
try:
order = orders.get() # waits for an order; raises ShutDown once the queue is shut down and empty
except queue.ShutDown:
return
time.sleep(0.02) # packing takes a while
with packed_lock:
packed.append((order, name))
orders.task_done() # tells join() this order is finished
workers = [threading.Thread(target=packer, args=(f"packer-{n}",)) for n in (1, 2)]
for worker in workers:
worker.start()
for number in range(1, 9):
orders.put(f"order-{number}") # the producer: here, the main thread
orders.join() # waits until every order has been marked done
orders.shutdown() # no more orders: packers waiting in get() now raise ShutDown
for worker in workers:
worker.join()
print(f"packed {len(packed)} orders: {', '.join(sorted(order for order, _ in packed))}")
print(f"both packers worked: {len({name for _, name in packed}) == 2}")
try:
orders.put("order-9")
except queue.ShutDown:
print("a put() after shutdown() raises queue.ShutDown") Output
packed 8 orders: order-1, order-2, order-3, order-4, order-5, order-6, order-7, order-8 both packers worked: True a put() after shutdown() raises queue.ShutDown
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline.py
Step through the run and watch the queue do the waiting for both sides:
The drawing shows the whole scene; its 10 steps are listed below.
Every step as text
- The packers wait. Two packer threads start and call orders.get(). The queue is empty, so get() blocks in both of them until an order arrives.
- The first orders. The main thread puts order-1 and order-2. Each get() that was waiting takes one of them, so both packers start packing at once.
- The queue fills up. Packing takes 20 ms, and the main thread keeps putting orders. With both packers busy, order-3, order-4 and order-5 wait in the queue, which is now full.
- put() waits. The next put() blocks, because the queue already holds its maxsize of three orders. The producer cannot run ahead of the packers.
- A packer finishes one. A packer finishes an order, calls task_done() and takes the next one with get(). That frees a place, so the waiting put() goes through.
- join() waits for the count. All eight orders are in. orders.join() blocks until task_done() has been called once for every order that was put in, packed or not yet.
- Eight done. The packers mark the last orders done and join() returns. The queue is empty again, and each order went to exactly one packer.
- shutdown(). The main thread calls orders.shutdown(). The queue accepts nothing more, and a get() on a shut-down, empty queue raises queue.ShutDown.
- The packers stop. Both packers were waiting in get(), so both get queue.ShutDown, leave their loops and end. No sentinel and no daemon thread were needed.
- A late put() fails. The program tries one more put(order-9) after shutdown() and gets queue.ShutDown, so no order can slip into a queue that nobody reads.
Queue(maxsize=3)holds at most three orders, soput()waited whenever the packers fell behind. Without amaxsizethe queue grows without limit.- Each packer called
task_done()after an order, andorders.join()returned only when all eight were done. shutdown(), new in Python 3.13, then closed the queue: the packers waiting inget()raisedqueue.ShutDownand left their loops, and the lateput()raised it too.
Each order went to exactly one packer, so the only lock in the program guards the shared results list.
Older programs end their consumers with a sentinel: A value that marks the end of the data instead of a count, such as the b"" that read() returns at the end of a file. iter(func, sentinel) calls func() until it returns that value. More in “How iteration works: iterables and iterators”, a value such as None that means
“no more work”. shutdown(immediate=True) drops the orders still queued, so the consumers’ next get() raises at
once, and daemon threads simply stop when the program exits.
Checkpoint
Check: Handing work over with a queue
Check yourself
Practise the three ideas above, locks, signals and queues, with questions that start where your answers so far were weakest.
Practice that adapts to you
Practise this lesson
10 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
A seat counter that cannot oversell, and a deadlock reproduced Pro: opens with a Pro, Premium or Ultimate pass
Overselling is the race that costs real money, and a deadlock is the bug that careless locking adds in its place. This part oversells seats on purpose, fixes it with the payment outside the lock, reproduces a deadlock between two locks with a time limit so that the program does not hang, and ends with an exercise.
In this part:
- Forty passengers, five seats: overselling reproduced and fixed
- Two locks in opposite orders: a deadlock reproduced
- Exercise: a seat inventory that can’t oversell
- A seat inventory that can't oversell
2 diagrams · 2 runnable examples (Python) · 1 exercise with sample tests · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Bounded, stoppable pipelines, with the numbers measured Pro: opens with a Pro, Premium or Ultimate pass
A queue between threads decides how much memory a pipeline may use, and an event decides how quickly it can stop. This part measures what a bounded queue does to a fast producer, stops a worker on request in a fraction of a millisecond, and compares the four ways to end a pipeline.
In this part:
- Backpressure measured: a fast producer, a slow consumer
- A stop event ends a worker without waiting out its pause
- Four ways to end a pipeline, compared
2 runnable examples (Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
What is the difference between threading.Lock and threading.RLock, and when do you need an RLock?
A
Lockis either locked or unlocked, and it does not remember who locked it: once any thread holds it, every furtheracquire()waits, even from the thread that already holds it, and any thread may release it. AnRLock, a reentrant lock, has an owner and a count: the thread that holds it may acquire it again without waiting, it must release it once for each acquire, and only the owner may release it. You need anRLockwhen code that holds a lock calls other code that takes the same lock, typically one locked method of a class calling another. With a plainLockthat call would wait for itself forever. When no such nesting exists, a plainLockis simpler, and an accidental nested acquire shows up at once, as a hang, instead of passing unnoticed. -
Mid to senior
A booking service runs on a thread pool. Its code checks
if seats_left > 0, charges the card, then decrementsseats_left. During a burst it sold 3 more tickets than the train had seats. Explain the bug, fix it, and say what changes when the count lives in a database. -
Senior to staff
A service that uses two locks freezes about once a week under load: no CPU use, no errors, requests just stop. How do you confirm that it is a deadlock, find the two code paths involved, and prevent it from coming back?
-
Mid to senior
Design a pipeline that resizes 80,000 product photos of about 3 MB each: one thread reads files, three threads resize, one thread writes the results. How do you bound memory, report failed photos, and stop cleanly on Ctrl+C?
Key takeaways
- Code inside
with lock:runs in one thread at a time, so a read, a change and a write inside it lose no update. - Keep locked blocks short, take the same lock wherever the data changes, and call no outside code while holding it.
- An
Eventis a flag threads wait for, aSemaphore(n)lets n threads past at once, and aConditionwakes a waiter after a change. - A
queue.Queuehands work over and does the locking:maxsizebounds it,task_done()andjoin()count finished work, andshutdown()ends the consumers.
References
- threading: Lock objects (Python Software Foundation)
- threading: RLock objects (Python Software Foundation)
- threading: Condition objects (Python Software Foundation)
- threading: Semaphore objects (Python Software Foundation)
- threading: Event objects (Python Software Foundation)
- threading: using locks, conditions, and semaphores in the with statement (Python Software Foundation)
- queue: a synchronized queue class (Python Software Foundation)
- queue.Queue.shutdown() (Python Software Foundation)
- Glossary: deadlock (Python Software Foundation)
- signal: signals and threads (Python Software Foundation)
- faulthandler.dump_traceback() (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress