backend / async concurrency / 10_synchronization_primitives.md

Synchronization Primitives in Python

4 min read source

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; manual acquire()/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 bare if before wait().
  • Prefer higher-level tools — a Queue or a concurrent.futures pool 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 while not if around condition.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.