๐Ÿšฆ Pattern 5: Synchronization Primitives

A lock protects shared state. But sometimes you need threads to coordinate โ€” wait for a signal, wait for a condition, cap how many run at once, or all meet at a line. That's what Event, Condition, Semaphore, and Barrier are for.

Beyond Locks

A Lock answers one question: "can I touch this shared state right now?" But threads often need to answer richer questions โ€” "has startup finished yet?", "is there an item in the buffer?", "are all 5 workers ready to begin?". Busy-waiting in a while loop with time.sleep works but burns CPU and adds latency. The threading module ships four primitives built for exactly this.

Key Insight: these primitives don't replace locks โ€” most of them are built on top of a lock. They let a thread sleep until another thread wakes it, instead of spinning. The OS parks the waiter and the CPU does useful work in the meantime.

threading.Event โ€” a One-Bit Signal

An Event is a boolean flag that threads can wait on. One thread flips it with set(); every thread blocked in wait() wakes up at once. It's the simplest way to broadcast "go!" or "we're shutting down" to many workers.

import threading, time

ready = threading.Event()

def worker(n):
    print(f"worker {n} waiting for go signal")
    ready.wait()                      # blocks here until ready.set()
    print(f"worker {n} running!")

threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
    t.start()

time.sleep(1)                         # let all workers reach wait()
print("main: GO")
ready.set()                           # one-to-many wakeup: all 3 fire at once
for t in threads:
    t.join()
sequenceDiagram participant M as Main participant E as Event participant W1 as Worker 1 participant W2 as Worker 2 W1->>E: wait() (parked) W2->>E: wait() (parked) M->>E: set() E-->>W1: wake E-->>W2: wake Note over W1,W2: All waiters
resume together
Real-world: a graceful-shutdown flag. Each worker loops while not stop.is_set(): and does a chunk of work; the main thread calls stop.set() on SIGINT. Also great for "wait until the DB connection pool is warmed up before serving traffic."

threading.Condition โ€” Wait for a Predicate

An Event is a single bit. A Condition lets a thread wait for an arbitrary predicate over shared state โ€” "the queue is non-empty", "the buffer has room" โ€” and bundles a lock so you can inspect that state safely. It's the classic producer/consumer tool.

import threading, time, collections

cond = threading.Condition()          # has its own internal lock
buffer = collections.deque()

def producer():
    for i in range(5):
        time.sleep(0.2)
        with cond:                    # must hold the lock to notify
            buffer.append(i)
            print(f"produced {i}")
            cond.notify()             # wake one waiting consumer

def consumer():
    while True:
        with cond:                    # must hold the lock to wait
            while not buffer:         # WHILE, not if โ€” see below
                cond.wait()
            item = buffer.popleft()
        print(f"consumed {item}")
        if item == 4:
            return

t1 = threading.Thread(target=consumer)
t2 = threading.Thread(target=producer)
t1.start(); t2.start()
t1.join(); t2.join()
Why while, not if: when wait() returns, the predicate is not guaranteed to still be true. Two reasons: (1) another consumer may have been woken first and drained the item before you re-acquired the lock, and (2) spurious wakeups โ€” a waiter can legally return from wait() without any notify(). So you must re-check the condition in a loop. The idiom is always while not predicate: cond.wait().
The mechanics of wait(): you must hold the condition's lock when you call it. wait() then atomically releases the lock and parks the thread โ€” so a producer can grab the lock and notify. On wakeup, wait() re-acquires the lock before returning, so your code inside with cond: is always running under the lock. This atomic release-and-sleep is exactly what a hand-rolled flag + sleep loop can't do safely.
sequenceDiagram participant C as Consumer participant L as Condition lock participant P as Producer C->>L: with cond (acquire) Note over C: buffer empty โ†’
cond.wait() C-->>L: release lock + sleep P->>L: with cond (acquire) P->>P: buffer.append(x) P->>C: notify() P-->>L: release lock C->>L: re-acquire on wakeup Note over C: re-check while loop,
then pop item

Semaphore & BoundedSemaphore โ€” Limit Concurrency to N

A Semaphore is a counter. acquire() decrements it (blocking at zero) and release() increments it. Initialize it to N and only N threads can be inside the guarded section at once โ€” the perfect throttle for "at most 5 concurrent connections to this flaky API."

import threading, time

MAX_CONCURRENT = 3
gate = threading.Semaphore(MAX_CONCURRENT)   # 3 permits

def call_api(n):
    with gate:                    # acquire a permit (blocks if 0 left)
        print(f"request {n} in flight")
        time.sleep(1)             # only 3 of these overlap at any moment
    # permit released on exit

threads = [threading.Thread(target=call_api, args=(i,)) for i in range(10)]
for t in threads: t.start()
for t in threads: t.join()
Prefer BoundedSemaphore. A plain Semaphore lets you release() more times than you acquired, silently raising the ceiling above N โ€” a common bug. BoundedSemaphore raises ValueError if the count ever exceeds its initial value, catching a stray or double release() immediately.
Semaphore vs Lock: a Lock is essentially a Semaphore(1) โ€” but a lock has an owner and enforces acquire/release symmetry, while a semaphore is just a count that any thread can release. Use a semaphore when the resource has capacity N > 1 (connection pool, rate limiter, download slots).

threading.Barrier โ€” Rendezvous of N Threads

A Barrier makes exactly N threads wait for each other. Each calls barrier.wait(); the first N-1 block, and when the N-th arrives, all are released together. Ideal for phased computation where no thread may start phase 2 until every thread finished phase 1.

import threading, time, random

N = 3
barrier = threading.Barrier(N)

def worker(n):
    time.sleep(random.random())       # each finishes phase 1 at a different time
    print(f"worker {n} done phase 1, waiting at barrier")
    barrier.wait()                    # blocks until all N arrive
    print(f"worker {n} starting phase 2")   # all print together

threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)]
for t in threads: t.start()
for t in threads: t.join()
Bonus: Barrier is reusable โ€” after releasing, it resets for the next round, so you can loop through many phases. Pass an action= callable to run once (by one thread) at the moment the barrier trips, e.g. to merge partial results between phases. If a waiting thread times out or is broken, the barrier enters a broken state and all wait() calls raise BrokenBarrierError.

Which One Do I Reach For?

PrimitiveUse whenCore methods
EventBroadcast a one-bit signal (go / stop) to many threadsset, clear, wait, is_set
ConditionWait until a predicate over shared state becomes truewait, notify, notify_all
SemaphoreAllow at most N threads into a sectionacquire, release
BarrierMake N threads meet at a synchronization pointwait
But first, ask if you even need one. Most producer/consumer coordination is cleaner with a queue.Queue, which wraps a Condition for you. Reach for these raw primitives when a queue doesn't fit the shape of the problem โ€” signalling, throttling, or rendezvous.