Synchronization Primitives in Python
Synchronization primitives coordinate multiple threads (or processes, or coroutines) accessing shared state, preventing race conditions and keeping data consistent. Python ships them in threading, multiprocessing, and asyncio — same names, different concurrency models.
Threading primitives
Lock (mutex)
The most basic primitive: only one thread holds it at a time. Always use it as a context manager so it’s released even on an exception.
import threading
lock = threading.Lock()
def critical_section():
with lock: # acquire; auto-release on exit
... # only one thread in here at a time
RLock (reentrant lock)
Lets the same thread acquire it multiple times (nested calls) without deadlocking. A plain Lock would deadlock on the second acquire.
rlock = threading.RLock()
def outer():
with rlock:
inner() # same thread re-acquires — fine with RLock
def inner():
with rlock:
...
Semaphore / BoundedSemaphore
Allows up to N threads in at once (a Lock is effectively a semaphore of 1). BoundedSemaphore raises if released more times than acquired — a guard against release bugs.
sem = threading.Semaphore(2) # at most 2 concurrent
def worker():
with sem:
... # <= 2 threads run this simultaneously
Event
A one-bit flag threads can wait on. set() wakes all waiters; wait() blocks until set.
event = threading.Event()
def worker():
event.wait() # blocks until another thread calls event.set()
...
Condition
A Lock plus a wait/notify channel — wait for a predicate, get woken when it might be true. Always re-check the predicate in a while loop (spurious wakeups, multiple waiters).
cond = threading.Condition()
ready = False
def consumer():
with cond:
while not ready: # re-check, never a bare `if`
cond.wait() # releases the lock and blocks until notified
...
def producer():
global ready
with cond:
ready = True
cond.notify_all() # wake waiters
Barrier
Blocks a fixed number of threads until all of them arrive, then releases them together.
barrier = threading.Barrier(3)
def task():
barrier.wait() # blocks until 3 threads have called wait()
...
Queue (thread-safe handoff)
queue.Queue isn’t strictly a primitive, but it’s the idiomatic way to pass work between threads — internally locked, so you rarely need an explicit lock. task_done()/join() track completion.
import queue
q = queue.Queue()
q.put(item) # producer
item = q.get(); q.task_done() # consumer
q.join() # block until every queued item is done
Multiprocessing equivalents
multiprocessing mirrors the threading API — Lock, RLock, Semaphore, Event, Condition, Barrier, Queue — but for processes. They’re backed by OS primitives (semaphores, pipes) rather than in-process objects, so they work across the process boundary and sidestep the GIL.
from multiprocessing import Process, Lock, Queue
See 15_when_to_use_processes.md and 20_process_pool_executor.md.
threading vs asyncio primitives
asyncio provides the same names (Lock, Event, Semaphore, Condition, BoundedSemaphore) but for a single-threaded event loop. The core difference is blocking vs yielding:
threading |
asyncio |
|
|---|---|---|
| Model | OS threads, preemptive | one event loop, cooperative |
| Waiting | blocks the thread | yields to the loop (await) |
| Usage | with lock: |
async with lock: / await event.wait() |
| Thread-safe? | yes (cross-thread) | no — same-thread coroutines only |
| GIL | constrained by it | irrelevant (single thread) |
| Barrier | threading.Barrier |
none built-in (compose from Event) |
import asyncio
lock = asyncio.Lock()
async def critical_section():
async with lock: # suspends the coroutine, doesn't block the thread
...
Don’t mix paradigms: never guard asyncio code with a threading.Lock, or vice versa. To bridge threads and the loop, use asyncio.run_coroutine_threadsafe() or loop.run_in_executor(). See 13_asyncio_vs_threads.md and 14_asyncio_queue_vs_threading_queue.md.
Best practices
- Use
with— context managers release locks even on exception; manualacquire()/release()leaks the lock on errors. - Keep critical sections small — hold a lock for the shortest time possible.
- Acquire in a consistent order — out-of-order locking across threads is the classic deadlock.
- Re-check predicates in a loop with
Condition— never a bareifbeforewait(). - Prefer higher-level tools — a
Queueor aconcurrent.futurespool often removes the need for explicit locks entirely.
Common pitfalls
| Pitfall | What happens |
|---|---|
| Deadlock | threads wait on each other in a cycle; nobody proceeds |
| Livelock | threads keep reacting to each other but make no progress |
| Starvation | a thread never gets the resource it needs |
| Priority inversion | a low-priority thread holds a lock a high-priority thread needs |
Interview angle
- “Lock vs RLock?” RLock can be re-acquired by the same thread (reentrant); a Lock deadlocks on the second acquire. Use RLock for recursive/nested locking.
- “Why
whilenotifaroundcondition.wait()?” Spurious wakeups and multiple waiters — the predicate may be false when you wake, so re-check it. - “Semaphore vs Lock?” A Lock allows one holder; a Semaphore allows N. A Lock is a semaphore initialized to 1.
- “Are asyncio and threading primitives interchangeable?” No — asyncio primitives yield to the event loop and aren’t thread-safe; threading primitives block the OS thread. Don’t cross them.