Python Basics: Coordinate Threads with Events, Locks, and Barriers
Starting a thread does not decide when another thread may read its result or change shared state. Three small coordination patterns solve different problems: an Event signals shutdown, a Lock protects an update, and a Barrier separates stages. The examples print only after workers have finished, so their output does not depend on scheduling order.
Prepare a scratch directory
These standalone examples were tested on Linux with Python 3.12.14. Use Python 3.6 or newer for the syntax and APIs shown here. The examples use only in-memory sample data and create no external jobs. Use Bash, a text editor, and the standard mktemp, rm, and rmdir utilities. No third-party packages are required. Run without -O so the assertions remain enabled.
Create a fresh directory and keep using the same terminal:
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1Save each named source below as UTF-8. Avoid filenames that shadow standard-library modules.
1. Request a cooperative shutdown
Save as signal_worker_shutdown.py:
from threading import Event, Thread
ready = Event()
stop = Event()
events = []
def worker():
events.append("ready")
ready.set()
try:
if stop.wait(timeout=5):
events.append("stop requested")
else:
events.append("wait expired")
finally:
events.append("cleanup complete")
thread = Thread(target=worker)
thread.start()
try:
assert ready.wait(timeout=5), "Worker did not become ready"
finally:
stop.set()
thread.join(timeout=5)
assert not thread.is_alive(), "Worker did not finish"
assert events == ["ready", "stop requested", "cleanup complete"]
print("Events:", " -> ".join(events))
print("Worker stopped:", not thread.is_alive())Run it:
python3 -B signal_worker_shutdown.pyOutput:
Events: ready -> stop requested -> cleanup complete
Worker stopped: TrueEvent.wait distinguishes a set flag from a timeout. The main thread sets stop even if its readiness check fails, then joins before reading the worker’s list. A real worker must check its stop signal between bounded work steps; setting an event cannot interrupt arbitrary code. See Event objects.
2. Keep a read-modify-write update under one lock
Save as protect_shared_counter.py:
from threading import Lock, Thread
lock = Lock()
counts = {"completed": 0}
worker_count = 4
jobs_per_worker = 2000
def record_completions():
for _ in range(jobs_per_worker):
with lock:
previous = counts["completed"]
counts["completed"] = previous + 1
threads = [Thread(target=record_completions) for _ in range(worker_count)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=5)
assert all(not thread.is_alive() for thread in threads)
assert counts["completed"] == worker_count * jobs_per_worker
print("Completed:", counts["completed"])
print("Workers joined:", len(threads))Run it:
python3 -B protect_shared_counter.pyOutput:
Completed: 8000
Workers joined: 4All writers use the same lock around both reading and replacing the count. The with statement releases it on exit, including exceptions. Keep the protected section short and avoid waiting for other threads while holding it. See locks as context managers.
3. Wait for every partial result before combining them
Save as synchronize_report_stages.py:
from threading import Barrier, BrokenBarrierError, Lock, Thread
barrier = Barrier(2, timeout=5)
lock = Lock()
partials = {}
totals = {}
errors = []
def prepare_report(name, count):
with lock:
partials[name] = count
try:
barrier.wait()
except BrokenBarrierError:
with lock:
errors.append(name)
return
with lock:
totals[name] = sum(partials.values())
threads = [
Thread(target=prepare_report, args=("alpha", 4)),
Thread(target=prepare_report, args=("beta", 6)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
assert all(not thread.is_alive() for thread in threads)
assert not errors, f"Barrier failed for: {errors}"
assert totals == {"alpha": 10, "beta": 10}
for name in sorted(totals):
print(f"{name}: total={totals[name]}")
print("Barrier broken:", barrier.broken)Run it:
python3 -B synchronize_report_stages.pyOutput:
alpha: total=10
beta: total=10
Barrier broken: FalseBoth workers publish a partial before either passes the barrier. The lock is released before waiting, so the other worker can publish. A barrier timeout breaks the barrier and raises BrokenBarrierError. This fixed-participant pattern fits staged work, not an unknown stream of jobs. See Barrier objects.
A join timeout only limits the caller’s wait; it does not kill a thread. Always check is_alive afterward. Choose production timeouts around the actual work and provide a failure path when a participant disappears. These examples demonstrate coordination, not a speed benchmark or a general worker supervisor. For queued independent jobs and result propagation, a thread pool may be simpler.
Clean up
In the same terminal, remove only the named sources, return to the original directory, and remove the empty scratch directory:
rm -- signal_worker_shutdown.py protect_shared_counter.py synchronize_report_stages.py
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirIf rmdir reports remaining files, inspect them instead of deleting unknown contents.
References
Acknowledgements
This post was created with assistance from AI.