🚦 Pattern 6: Synchronization

Wait β€” processes have isolated memory, so why would I ever need a lock? Because they still share some things, and those things need coordinating.

"But Processes Don't Share Memory…"

Correct β€” and that's exactly the confusion. If every process has its own private heap (Processes 101), there's nothing to race on, right? Mostly. But multiprocessing gives you deliberate escape hatches that do share state, and the moment you use one, you're back in race-condition territory.

Things processes really do share (and must coordinate on):
  • A shared Value / Array or a shared_memory block (Pattern 4).
  • A single log file, or interleaved writes to stdout.
  • An external resource β€” a database, an API with a rate limit, a physical device.
Key Insight: you need cross-process synchronization exactly when two processes touch the same mutable thing at the same time. The primitives below are the tools for that β€” and they mirror the threading primitives you may know, but they're implemented very differently underneath.

The Primitives

All come from multiprocessing and mirror the threading module one-to-one:

multiprocessingthreading equivalentSame API?
Lock / RLockthreading.Lock / RLockβœ…
Semaphore / BoundedSemaphorethreading.Semaphore / β€¦βœ…
Eventthreading.Eventβœ…
Conditionthreading.Conditionβœ…
Barrierthreading.Barrierβœ…

See the threading synchronization guide for the shared semantics.

Why They're Heavier Than Threading Locks

A threading.Lock is an in-process object β€” acquiring it is a cheap userspace operation. A multiprocessing.Lock can't live in one process's memory, because the whole point is that other processes see it. So it's backed by an OS-level primitive (a named semaphore on the kernel side). Acquiring it is a syscall β€” orders of magnitude costlier than a threading lock.

Key Insight: because every acquire/release is a kernel round-trip, never lock in a hot inner loop across processes. If you find yourself acquiring a cross-process lock millions of times, the design is wrong β€” restructure so each worker computes independently and the parent aggregates (see the real-world note at the bottom).

The Spawn Gotcha: You Must Pass the Lock In

The bug: under the spawn start method (the default on macOS and Windows), each child is a fresh interpreter that re-imports your module. A module-global lock = multiprocessing.Lock() gets re-created in every child β€” so each process ends up with a different lock object guarding nothing in common. Your "lock" protects nothing.

The fix: create the primitive once in the parent and pass it into the workers so they all share the one object. For a raw Process, use args=. For a Pool or ProcessPoolExecutor, use the initializer= / initargs= hook, which runs once per worker at startup and can stash the lock in a per-worker global. (Locks are shareable this way because multiprocessing special-cases them; you generally can't pickle a lock through map β€” see Pattern 8.)

from multiprocessing import Process, Lock

def worker(lock, name):
    with lock:                      # the SAME lock object, passed in via args
        print(f"{name} is in the critical section")

if __name__ == "__main__":
    lock = Lock()
    procs = [Process(target=worker, args=(lock, f"P{i}")) for i in range(4)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

The pool pattern β€” stash the lock in a global inside each worker via initializer:

from multiprocessing import Pool, Lock

_lock = None                        # per-worker global, filled by init()

def init(lock):
    global _lock                    # runs ONCE per worker process at startup
    _lock = lock

def task(item):
    with _lock:                     # every worker shares the one real lock
        # ... touch the shared resource ...
        return item

if __name__ == "__main__":
    shared_lock = Lock()
    with Pool(processes=4, initializer=init, initargs=(shared_lock,)) as pool:
        pool.map(task, range(20))

Full Example: Lock Around a Shared Value

N processes each increment a shared counter 100 000 times. value.value += 1 is not atomic β€” it's read, add, write β€” so without a lock, concurrent increments overwrite each other and updates are lost (a race condition, even across processes, because the memory is genuinely shared).

flowchart TB subgraph OS["OS-level Lock (one named semaphore)"] L["πŸ”’ Lock"] end P1["Process 1"] -->|"acquire (syscall)"| L P2["Process 2"] -->|"acquire (syscall)"| L P3["Process 3"] -->|"acquire (syscall)"| L L -->|"one holder at a time"| V["shared Value('i')
read-add-write"]
from multiprocessing import Process, Value, Lock

def increment(counter, lock, times):
    for _ in range(times):
        with lock:                  # serialize the read-add-write
            counter.value += 1

if __name__ == "__main__":
    counter = Value("i", 0)         # shared signed int, starts at 0
    lock = Lock()
    procs = [Process(target=increment, args=(counter, lock, 100_000)) for _ in range(4)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()
    print(counter.value)            # 400000 β€” correct. Remove the lock β†’ < 400000
Drop the with lock: and you'll routinely see a total well under 400000. Two processes read the same value, both add one, both write back the same number β€” one increment vanishes. The lock makes read-add-write indivisible.

Event and Barrier

Event β€” a broadcast go/stop flag

One process flips a flag; all others waiting on it wake up. Perfect for a coordinated start, or a stop signal that tells workers to wind down (pair with graceful shutdown in Pattern 9).

import time
from multiprocessing import Process, Event

def worker(stop, name):
    while not stop.is_set():         # loop until told to stop
        time.sleep(0.1)              # ... do a unit of work ...
    print(f"{name} stopping cleanly")

if __name__ == "__main__":
    stop = Event()                   # starts cleared
    procs = [Process(target=worker, args=(stop, f"P{i}")) for i in range(3)]
    for p in procs:
        p.start()
    time.sleep(1)
    stop.set()                       # broadcast: all workers see it and exit their loop
    for p in procs:
        p.join()

Barrier β€” everyone reaches the line together

Construct with the number of participants; each call to wait() blocks until that many processes have arrived, then all are released simultaneously. Use it to align workers at a phase boundary (e.g. "all finish loading before anyone starts computing").

import time
from multiprocessing import Process, Barrier

def phase_worker(barrier, name):
    print(f"{name} finished phase 1")
    barrier.wait()                   # block until ALL workers arrive here
    print(f"{name} starting phase 2 together")

if __name__ == "__main__":
    barrier = Barrier(3)             # 3 participants
    procs = [Process(target=phase_worker, args=(barrier, f"P{i}")) for i in range(3)]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

Key Takeaways

PrimitiveUse it to…
Lock / RLockGuard one shared resource; serialize access
SemaphoreCap concurrency to N (connections, rate limits)
EventBroadcast a one-shot go/stop signal to all workers
BarrierRendezvous all workers at a phase boundary
ConditionWait for a predicate and be notified on change
Real-world: prefer designs that don't need cross-process locks at all. OS locks are slow and easy to deadlock. The idiomatic multiprocessing shape is share nothing: each worker computes independently and returns its result, and the parent aggregates (sum the partial counts, merge the dicts). Reach for a Lock/Semaphore only when there's a genuinely shared, external resource you can't avoid contending on.