Using asyncio.Barrier to Start Tasks Together¶
A load test is meant to hit an endpoint with 500 simultaneous requests, but each client task spends a different amount of time building its session, so the "burst" is really a ramp spread over two seconds and the rate limiter under test never sees a spike. A race-condition test tries to make two tasks check a balance at the same instant and passes on one run in fifty. A multi-stage job wants every worker to finish its warm-up before any of them begins the next phase. Each of these needs the same thing: a rendezvous where tasks wait until a fixed number of them have arrived, and then all proceed together. Python 3.11 added asyncio.Barrier for exactly this. This guide uses it for synchronised starts, phase gates and deterministic race tests, and covers the parts that differ from threading.Barrier: how aborting breaks it, and why a cancelled or timed-out waiter does not.
Prerequisites¶
- Python 3.11+, standard library only.
- Primitive overview from Synchronization Primitives and choosing asyncio Lock vs Semaphore vs Event.
- Task groups from structured concurrency with asyncio.TaskGroup.
1. Release tasks together once all have arrived¶
Barrier(parties) makes each await barrier.wait() suspend until parties tasks are waiting, then releases them all and returns each one a distinct index between 0 and parties - 1. Tasks that arrive early simply wait for the late ones.
import asyncio
async def party(barrier: asyncio.Barrier, name: str, prep: float, log: list) -> None:
loop = asyncio.get_running_loop()
await asyncio.sleep(prep) # uneven setup time
index = await barrier.wait()
log.append((name, index, round(loop.time(), 3)))
async def main() -> None:
barrier = asyncio.Barrier(3)
log: list = []
async with asyncio.TaskGroup() as tg:
for name, prep in (("fast", 0.0), ("medium", 0.02), ("slow", 0.05)):
tg.create_task(party(barrier, name, prep, log))
times = {t for _, _, t in log}
print(sorted(log, key=lambda row: row[1]))
print("distinct release times:", len(times)) # 1: all released in the same instant
asyncio.run(main())
All three entries carry the same loop time although their preparation took 0, 20 and 50 ms: the fast tasks waited at the barrier for the slow one. The index is useful for assigning roles — "index 0 is the leader" — without a separate coordination step.
Verify: the three log entries share one timestamp and have indexes 0, 1 and 2.
2. Fire a true burst in a load test¶
The load-test problem from the introduction: create every client, let each finish its per-request setup, and only then send all requests at once. The barrier removes setup time from the measurement.
import asyncio
import time
async def fake_endpoint(arrivals: list[float]) -> None:
arrivals.append(time.perf_counter())
await asyncio.sleep(0.001)
async def client(i: int, barrier: asyncio.Barrier, arrivals: list[float]) -> None:
await asyncio.sleep((i % 10) * 0.005) # per-client setup: 0 to 45 ms
await barrier.wait() # everyone ready?
await fake_endpoint(arrivals)
async def burst(n: int, synchronised: bool) -> float:
arrivals: list[float] = []
barrier = asyncio.Barrier(n)
async def no_gate(i: int) -> None:
await asyncio.sleep((i % 10) * 0.005)
await fake_endpoint(arrivals)
async with asyncio.TaskGroup() as tg:
for i in range(n):
tg.create_task(client(i, barrier, arrivals) if synchronised else no_gate(i))
return (max(arrivals) - min(arrivals)) * 1000
async def main() -> None:
print(f"without barrier: requests spread over {await burst(500, False):.1f} ms")
print(f"with barrier: requests spread over {await burst(500, True):.1f} ms")
asyncio.run(main())
Without the barrier the 500 requests arrive spread over the full setup range of about 45 ms; with it they arrive within a millisecond or so — as close to simultaneous as one event loop can dispatch them. That is the difference between testing a token bucket against a real burst and testing it against a gentle ramp.
Verify: the synchronised spread is a small fraction of the unsynchronised one.
3. Gate phases of a multi-stage job¶
A barrier can be passed repeatedly: after releasing one group it resets automatically for the next round. Workers that must all finish phase one before any starts phase two can wait at the same barrier at the end of each phase.
import asyncio
async def worker(name: str, barrier: asyncio.Barrier, work: dict[str, float], log: list) -> None:
loop = asyncio.get_running_loop()
for phase in ("warm-up", "load", "verify"):
await asyncio.sleep(work[phase])
await barrier.wait() # nobody starts the next phase early
log.append((phase, name, round(loop.time(), 3)))
async def main() -> None:
barrier = asyncio.Barrier(2)
log: list = []
await asyncio.gather(
worker("a", barrier, {"warm-up": 0.01, "load": 0.05, "verify": 0.0}, log),
worker("b", barrier, {"warm-up": 0.03, "load": 0.01, "verify": 0.02}, log),
)
for phase in ("warm-up", "load", "verify"):
times = {t for p, _, t in log if p == phase}
print(phase, "released together:", len(times) == 1)
asyncio.run(main())
Each phase ends when the slower worker finishes it, and both start the next phase together. For fan-out work where "finish when everyone is done" is enough, a TaskGroup per phase is simpler; a barrier fits when long-lived tasks keep their state across phases.
Verify: each phase prints released together: True.
4. Break the barrier to stop everyone¶
If one participant fails, the others would wait forever for a party that will never arrive. await barrier.abort() puts the barrier into the broken state: every current and future wait() raises BrokenBarrierError until await barrier.reset() is called.
import asyncio
async def participant(i: int, barrier: asyncio.Barrier) -> str:
try:
if i == 2:
raise ConnectionError("participant 2 could not connect")
await barrier.wait()
return f"{i}: started"
except ConnectionError:
await barrier.abort() # release everyone else with an error
raise
except asyncio.BrokenBarrierError:
return f"{i}: aborted because the group cannot start"
async def main() -> None:
barrier = asyncio.Barrier(3)
results = await asyncio.gather(*(participant(i, barrier) for i in range(3)),
return_exceptions=True)
print([r if isinstance(r, str) else type(r).__name__ for r in results])
print("broken:", barrier.broken)
await barrier.reset()
print("after reset, broken:", barrier.broken)
asyncio.run(main())
The two healthy participants receive BrokenBarrierError instead of hanging, and the failing one propagates its real error. Structured code can get the same effect by running participants in a TaskGroup, whose failure cancels the waiting siblings; abort() is the tool when participants are not in one group.
Verify: the result list shows two aborted messages and a ConnectionError, and broken is True until reset().
5. Know how cancellation and timeouts behave¶
Here asyncio.Barrier differs from threading.Barrier, where a timed-out wait breaks the barrier for everyone. In asyncio, cancelling a waiting task — including through asyncio.timeout() — just removes that waiter; the barrier stays intact and needs the full number of parties again.
import asyncio
async def main() -> None:
barrier = asyncio.Barrier(2)
try:
async with asyncio.timeout(0.02):
await barrier.wait() # the second party never arrives
except TimeoutError:
print("timed out; broken:", barrier.broken, "| waiting:", barrier.n_waiting)
barrier = asyncio.Barrier(3)
first = asyncio.create_task(barrier.wait())
second = asyncio.create_task(barrier.wait())
await asyncio.sleep(0)
first.cancel() # one waiter gives up
await asyncio.sleep(0)
print("after a cancel: broken:", barrier.broken, "| waiting:", barrier.n_waiting)
third = asyncio.create_task(barrier.wait())
fourth = asyncio.create_task(barrier.wait())
outcomes = await asyncio.gather(first, second, third, fourth, return_exceptions=True)
print([type(o).__name__ if isinstance(o, BaseException) else "released" for o in outcomes])
asyncio.run(main())
A timed-out waiter leaves the barrier unbroken with nobody waiting, and a cancelled waiter's place is filled by the next arrival: the second, third and fourth tasks are released together. If the group must not proceed once any member gives up, call abort() from the timeout handler. Do not rely on the returned indexes being distinct after a cancellation; in the run above, two released tasks received the same index, so assign roles by other means when waiters can be cancelled.
Verify: the timeout reports broken: False and zero waiting, and the cancellation run prints CancelledError followed by three released.
Verification¶
Barrier-based coordination is correct when:
- Synchronised starts are measured: arrival spread with the barrier is a small fraction of setup-time spread.
- Phases are gated: no participant begins a phase before every participant finished the previous one.
- Failures release the group: a failing participant aborts the barrier, and the others exit with
BrokenBarrierErrorrather than hanging. - Timeouts are explicit about the group: a participant that times out either aborts the barrier or is replaced, by design.
- Every wait has a deadline in tests, so a missing party fails fast.
Pitfalls & edge cases¶
- Wrong party count. A barrier created for more parties than will ever arrive waits forever. Derive
partiesfrom the same list that creates the tasks. - Porting from
threading.Barrier. Code that relies on a timeout breaking the barrier behaves differently in asyncio; addabort()where that semantics is needed. - Using a barrier as a lock. Barriers release groups, not one task at a time; mutual exclusion needs a
Lock. - Measuring "simultaneous" on one loop. Released tasks still run one after another on the loop thread; the barrier removes setup skew, not scheduling order. For true parallel load, use several processes, each with its own barrier and a shared start time.
- Race tests that assume index order. Release order and indexes are implementation details. Assert on outcomes — for example that a double-spend was prevented — not on which index ran first.
Frequently Asked Questions¶
What does asyncio.Barrier do?
It is a synchronization primitive added in Python 3.11 that makes tasks calling wait suspend until a fixed number of tasks, the parties, are waiting, then releases them all together. Each released task receives an index from zero to parties minus one, and the barrier resets for the next round.
How is asyncio.Barrier different from asyncio.Event?
An Event releases every waiter whenever some code sets it, regardless of how many tasks are waiting. A Barrier releases waiters only when the configured number of parties has arrived, so it synchronises a group without any separate controller deciding when to set a flag.
What happens when a task waiting on asyncio.Barrier is cancelled?
The cancelled task leaves the waiting group and the barrier is not broken, so the remaining waiters keep waiting for the full number of parties. This differs from threading.Barrier, where a timeout breaks the barrier. Call abort explicitly if the whole group should fail when one member gives up.
How do I stop tasks waiting on a Barrier if one participant fails?
Call await barrier.abort() from the failing participant. The barrier becomes broken, every waiting and future wait raises BrokenBarrierError, and tasks can handle that error and exit. Call await barrier.reset() to make the barrier usable again.
Related¶
- Synchronization Primitives — up to the topic overview for asyncio's primitives.
- Coordinating producers and consumers with asyncio.Condition — state-based waiting when a fixed group size is not the condition.
- Asyncio Fundamentals & Event Loop Architecture — the section overview for tasks and coordination.