Skip to content
Python Basics: Coordinate Threads with Events, Locks, and Barriers

Python Basics: Coordinate Threads with Events, Locks, and Barriers

October 24, 2026

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 1

Save 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.py

Output:

Events: ready -> stop requested -> cleanup complete
Worker stopped: True

Event.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.py

Output:

Completed: 8000
Workers joined: 4

All 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.py

Output:

alpha: total=10
beta: total=10
Barrier broken: False

Both 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_dir

If rmdir reports remaining files, inspect them instead of deleting unknown contents.

References

Acknowledgements

This post was created with assistance from AI.

Last updated on