Wait β processes have isolated memory, so why would I ever need a lock? Because they still share some things, and those things need coordinating.
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.
Value / Array or a shared_memory block (Pattern 4).stdout.All come from multiprocessing and mirror the threading module one-to-one:
Lock β mutual exclusion; one holder at a time.RLock β reentrant lock; the same process can re-acquire it without deadlocking.Semaphore β allow up to N concurrent holders (e.g. cap concurrent DB connections).BoundedSemaphore β a Semaphore that raises if released more times than acquired (catches bugs).Event β a one-bit broadcast flag; workers wait() until it's set().Condition β wait for a predicate and be notified when state changes.Barrier β a rendezvous: N processes block until all N arrive, then all proceed.multiprocessing | threading equivalent | Same API? |
|---|---|---|
Lock / RLock | threading.Lock / RLock | β |
Semaphore / BoundedSemaphore | threading.Semaphore / β¦ | β |
Event | threading.Event | β |
Condition | threading.Condition | β |
Barrier | threading.Barrier | β |
See the threading synchronization guide for the shared semantics.
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.
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))
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).
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
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 BarrierEvent β a broadcast go/stop flagOne 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()
| Primitive | Use it to⦠|
|---|---|
Lock / RLock | Guard one shared resource; serialize access |
Semaphore | Cap concurrency to N (connections, rate limits) |
Event | Broadcast a one-shot go/stop signal to all workers |
Barrier | Rendezvous all workers at a phase boundary |
Condition | Wait for a predicate and be notified on change |
Lock/Semaphore only when
there's a genuinely shared, external resource you can't avoid contending on.