When two threads touch the same data at the same time, they can step on each other in ways that produce nonsense values, silently lose updates, or cause your program to hang. Python's threading module ships a small toolkit for keeping threads out of each other's way: Lock, RLock, Semaphore, Event, Condition, and Barrier. This lesson walks through what each of those primitives is for, how to use them, and how to avoid deadlocks.
The fastest way to see the problem is to write a thread-unsafe program and watch it produce a wrong answer. Picture a shared stock counter for a popular product. Many threads are processing orders, and each one decrements stock by 1 when an order goes through. Start with 100,000 units of stock and run 10 threads, each processing 10,000 orders. The final count should be zero.
The number is non-zero, and it's different every time. Run it again and you might see 41209 or 0 or 8. That's a race condition: the result depends on the unpredictable interleaving of threads.
The line stock -= 1 looks atomic, but it isn't. At the bytecode level it splits into three steps: load the current value, subtract one, store it back. If thread A loads 42 and gets paused before storing, thread B can also load 42, subtract one, and store 41. When A resumes, it stores 41 too. Two decrements happened, but the counter only went down by one. That's a lost update.
The diagram shows two threads racing through the same three-step decrement. Both load 42, both write 41, and the second write silently overwrites the first. The fix is to make those three steps run as one indivisible unit, which is exactly what a Lock gives you.
A Lock is a binary primitive with two states: locked and unlocked. Only one thread can hold the lock at a time. Other threads that try to acquire it block until the holder releases it. That's enough to fix the stock counter.
Every run produces the same answer. The lock turns "read, subtract, write" into one step that no other thread can break into. While Thread A holds the lock, Thread B blocks on acquire() until A calls release().
Two threads competing for a lock look like this:
Thread B sits in a blocked state until Thread A releases. Once released, B wakes up, acquires the lock, and runs its own update. The protected region (between acquire() and release()) is called a critical section.
The try/finally pattern matters. If the protected code raises an exception, you still need to release the lock, otherwise every other thread blocks forever. Python's with statement does this for you because Lock implements the context manager protocol:
The with stock_lock: form is the idiomatic spelling. It acquires on entry and releases on exit, even if the body raises. Use it everywhere unless you have a specific reason to call acquire/release directly (for example, when the acquire and release happen in different functions).
A subtle point: keep the critical section small. Anything you do inside the with block runs serially, with all other threads blocked. If you read from the network or compute for a long time while holding a lock, you've turned a multi-threaded program into a single-threaded one with extra overhead.
Cost: acquire() and release() are cheap on an uncontested lock (a handful of microseconds). Under heavy contention they become much more expensive because waiting threads sleep and wake up. The fix is usually to shrink the critical section or to shard data so different threads touch different locks.
Lock.acquire() also takes an optional timeout and blocking parameter. lock.acquire(blocking=False) returns immediately with True if the lock was free or False if it was already held. lock.acquire(timeout=2.0) waits at most 2 seconds. Both are useful when you want to attempt work without blocking indefinitely.
A regular Lock doesn't know who owns it. If the same thread tries to acquire a Lock it already holds, it deadlocks against itself. That sounds avoidable until you write code where a locked method calls another locked method on the same object.
Consider a cart that protects its internal state with a lock. The add_item method locks, the total method locks, and there's a helper add_with_total that wants to call both:
The outer with self.lock: in add_with_total acquires the lock. Then add_item tries to acquire the same lock, but Lock doesn't grant a second acquisition to the same thread. The thread blocks against itself, waiting for itself to release. The program hangs.
RLock solves this. It tracks the owning thread and a counter. The same thread can call acquire() multiple times in a row without blocking; each call bumps the counter. The lock is fully released only when release() has been called the same number of times.
The outer with self.lock: in add_with_total increments the counter to 1. The inner call from add_item bumps it to 2. When add_item's with block exits, the counter goes back to 1. The outer block exits, the counter goes to 0, and the lock is fully released. Other threads waiting on the RLock can now acquire it.
When should you reach for RLock over Lock? The honest answer is: prefer Lock if your design allows it, because plain locks are slightly cheaper and make accidental re-entry surface as a deadlock instead of silently working. Use RLock when:
| Feature | Lock | RLock |
|---|---|---|
| Reentrant by same thread | No, deadlocks | Yes, counted |
| Tracks owner | No | Yes |
| Overhead | Lower | Slightly higher |
acquire/release must balance | Per acquisition | Per acquisition (counter must reach zero) |
| Use case | Simple critical sections | Methods that call each other while holding the lock |
A common mistake with RLock is releasing it from a different thread than the one that acquired it. That raises RuntimeError. Both Lock and RLock require the holding thread to release.
A lock allows exactly one thread through at a time. A semaphore allows up to N threads through at a time. You pass N to the constructor, each acquire() decrements an internal counter, and each release() increments it. If the counter is at zero, acquire() blocks until someone releases.
The classic use case is rate-limiting a shared resource. Say the product-pricing service caps you at 5 simultaneous requests. More than 5 outstanding calls and it starts rejecting. You want many threads to be able to call it, but at most 5 at any moment.
Twelve threads start, but only five make it inside the with block at once. The other seven block on api_semaphore.acquire() until a slot frees up.
A semaphore initialised with Semaphore(1) behaves like a lock, but there's a key difference: Semaphore.release() can be called by any thread, not just the one that acquired. That makes it useful for signaling patterns where one thread acquires a slot and another releases it after the work completes. If you want lock-like behavior, use Lock; if you want a counted resource, use Semaphore.
A common gotcha: a plain Semaphore lets release() be called more times than acquire(). That bumps the internal counter above its initial value, which means future acquires won't block when you expect them to. BoundedSemaphore is the safer version: it raises ValueError if release() would push the counter above the initial value.
If you're using a semaphore to model a fixed pool of resources (3 connections to a payment service, 5 worker slots, 10 cached database handles), BoundedSemaphore will catch the bug where some code path releases without a matching acquire.
An Event is a thread-safe boolean flag with a built-in wait. One or more threads can call event.wait() and block until another thread calls event.set(). Once set, the flag stays set until event.clear() is called.
This is the right tool when you want to broadcast "something has happened" to any number of waiting threads. A simple example: order workers wait for a "system ready" signal before they start processing.
All three workers block on wait(). The main thread does its warmup, then set() releases all of them at once. After set(), any subsequent wait() returns immediately, because the flag is still set.
event.wait(timeout=5.0) waits up to 5 seconds and returns the current flag value (True if set, False if the timeout fired). That's the standard way to wait for a signal but bail out if it never arrives.
Events are one-way by default: set it and forget it. If you want to reuse the same event for repeated cycles, you clear() it before each round. Be careful: clearing while another thread is in the middle of wait() can cause races. If you need repeated synchronization with reuse, look at Condition or Barrier.
| Use case | Right primitive |
|---|---|
| One thread tells many threads "go" | Event |
| Threads wait for a state change with a predicate | Condition |
| N threads must all reach a point before any continues | Barrier |
| One thread at a time inside a critical section | Lock or RLock |
| Limit N threads inside a region | Semaphore or BoundedSemaphore |
Condition is the heavyweight signaling primitive. It combines a lock with the ability to wait for a state change and notify waiters when the state changes. The classic use is producer/consumer: producers add items to a shared buffer, consumers take them out, and each side blocks gracefully when the buffer is in the wrong state for them.
Think of an order queue. Producer threads accept new orders and push them onto a list. Consumer threads pull orders off and process them. The consumer should wait if the list is empty. The producer should wait if the list is at capacity.
Three things happen on each iteration. First, the thread acquires the condition's lock with with condition:. Second, it checks the state in a while loop and calls condition.wait() if it's not ready to proceed. wait() atomically releases the lock and blocks, then re-acquires the lock when woken. Third, after doing its work, the thread calls notify_all() to wake any threads waiting on the condition so they can re-check their predicate.
The while loop around wait() is not optional. A waiter can be woken without the state actually matching what it needs, either because of "spurious wakeups" or because another waiter raced ahead and grabbed the new item first. Always re-check the predicate after wait() returns.
The choice between notify() and notify_all():
| Method | What it does | When to use |
|---|---|---|
notify() | Wakes one waiter | When any single waiter can handle the change (e.g., one new item, one waiting consumer) |
notify_all() | Wakes all waiters | When the state change affects many waiters, or when waiters are checking different predicates |
notify() is more efficient when you're sure exactly one waiter can proceed. notify_all() is safer when in doubt, because no waiter is left stranded if the predicate matters to several of them. For a beginner, prefer notify_all() and optimize later if profiling shows it matters.
Cost: Each notify_all() wakes every waiter, which means N threads wake up, re-check, and possibly go back to sleep. If only one of them can actually proceed, that's wasted work. For tight inner loops, use notify() when you can prove only one waiter benefits.
In production code, the queue.Queue standard-library class wraps a Condition and gives you a clean producer/consumer API with put and get already implemented. That's the right choice for most real work. Reach for Condition directly when you need predicates more complex than "queue not empty" or "queue not full".
A Barrier is a synchronization point for a fixed number of threads. You initialize it with a count N. Each thread that reaches the barrier calls barrier.wait() and blocks. Once N threads have called wait(), all of them are released together to continue. The barrier then resets and can be reused for the next round.
A practical use: a batch order-processing system that pulls orders in phases. Each phase, workers fetch their slice of orders and process it. The next phase shouldn't start until every worker has finished the current one. A barrier makes that easy.
Each phase, all three workers finish in some order. Whoever finishes first blocks on barrier.wait() until the slowest worker arrives. Then all three are released at once and the next phase starts. The barrier resets automatically between rounds, so the same object handles every phase.
Barrier has a couple of extra knobs:
barrier.wait(timeout=2.0) raises BrokenBarrierError if the timeout expires before all parties arrive. It also breaks the barrier for all other parties, so they get the same exception.barrier.abort() lets you break the barrier intentionally if something goes wrong, freeing any waiters with BrokenBarrierError.barrier.reset() returns a broken barrier to its initial state so it can be reused.action argument: a callable that runs in one thread (the last to arrive) before all waiters are released. Handy for end-of-phase cleanup that should run exactly once.When cleanup runs, the other two threads are already at the barrier waiting. They'll be released only after cleanup returns. That keeps the "end of phase" code single-threaded and the "next phase" code parallel.
A deadlock is when two or more threads are each waiting on a lock the other holds, and none of them can move. The textbook recipe is two locks acquired in opposite orders.
Imagine a shared order ID generator and a shared customer record. Two threads each want to acquire both: one needs the order lock first and the customer lock second; the other does it the other way around.
Thread A holds order_lock and is blocked waiting for customer_lock. Thread B holds customer_lock and is blocked waiting for order_lock. Neither will release until it gets the other lock, which will never happen.
The standard fix is consistent lock ordering: pick a global order for your locks and always acquire them in that order. If every thread acquires order_lock before customer_lock, the deadlock can't happen, because no thread will ever hold customer_lock while waiting for order_lock.
When one thread holds order_lock, the other waits for it instead of acquiring customer_lock. There's no cycle, so no deadlock.
Other defenses are worth knowing:
| Strategy | What it does |
|---|---|
| Consistent lock ordering | Always acquire locks in the same global order. Cheapest to implement. |
Use with everywhere | Guarantees release on exception, prevents one common deadlock cause. |
Use acquire(timeout=...) | If you can't get the lock in time, back off and retry instead of blocking forever. Lets you detect potential deadlocks. |
| Hold one lock at a time | If you don't need both locks at once, don't take both. Split the work. |
| Use higher-level primitives | queue.Queue, concurrent.futures, and asyncio push the locking inside the library, where it's already debugged. |
The trickiest deadlocks involve more than two locks, or locks combined with RLock, Condition, and external resources like file handles. The rule of thumb stays the same: if you take more than one lock at the same time, document the order and stick to it.
Here's a quick reference for picking the right primitive based on the shape of the problem.
| Problem | Primitive |
|---|---|
| Protect a shared mutable value from concurrent updates | Lock |
| Same as above, but the protected code may re-enter on the same thread | RLock |
| Cap the number of threads inside a region (rate limit, connection pool) | Semaphore / BoundedSemaphore |
| Tell waiting threads "go" once a one-time condition is true | Event |
| Producer/consumer pattern with a buffer and predicates | Condition (or queue.Queue for the common case) |
| Synchronize a fixed group of threads at a checkpoint | Barrier |
When in doubt, reach for the higher-level tool. queue.Queue saves you from rolling your own producer/consumer. concurrent.futures.ThreadPoolExecutor handles thread lifecycle and result collection. Hand-rolled locks are powerful, but they're also where most concurrency bugs live.
10 quizzes