Skip to content

Signalling with asyncio.Event set() and clear()

asyncio.Event is the simplest synchronisation primitive: a boolean flag that tasks can wait on. It is used in three different ways that look identical in code and behave very differently: as a latch that is set once and stays set ("startup finished", "shutdown requested"); as a gate that opens and closes ("connected", "not paused"); and as a pulse — set() immediately followed by clear() to say "something happened, check again". The first two are what Event is for. The third works by accident for tasks that happen to be waiting at that instant and silently misses everyone else. In a test, set() followed immediately by clear() still woke all 5 waiting tasks — and would have woken none that arrived one line later. This guide covers each use and the tool to reach for when an Event is the wrong one.

Prerequisites

1. Use Event as a one-shot latch

The cleanest use: a fact that becomes true once and stays true. Every task that waits before or after the fact proceeds as soon as it holds:

import asyncio


class Service:
    def __init__(self) -> None:
        self.ready = asyncio.Event()
        self.stopping = asyncio.Event()

    async def start(self) -> None:
        await self._connect_everything()
        self.ready.set()                       # never cleared

    async def handle(self, request):
        async with asyncio.timeout(10):
            await self.ready.wait()            # returns immediately once set
        return await self._process(request)

    async def run_background(self) -> None:
        while not self.stopping.is_set():
            await self._tick()
            try:
                async with asyncio.timeout(5):
                    await self.stopping.wait() # sleep 5 s, or wake early on shutdown
            except TimeoutError:
                pass

set() is idempotent and wait() on a set event returns without suspending, so there is no ordering problem: a task that starts waiting after set() still proceeds. The stopping.wait() with a timeout is a useful idiom on its own — an interruptible sleep for periodic loops, so shutdown does not wait for the full interval, as in running periodic tasks without drift.

Verify: start a request before ready is set and one after; both proceed, the first as soon as set() is called.

Three ways people use asyncio.Event A grid of 3 rows by 4 columns. Three ways people use asyncio.Event use calls late waiter sees it verdict latch set() once yes what Event is for gate set() and clear() as state changes yes, while open fine pulse set(); clear() back to back no lost signals An Event models state; it cannot remember that something happened while nobody was looking.

2. Use Event as a gate for a state that flips

When the state goes back and forth — a connection that drops and reconnects, a consumer that pauses — set() and clear() track it, and wait() means "wait until the state is currently true":

class Connection:
    def __init__(self) -> None:
        self.connected = asyncio.Event()

    async def maintain(self) -> None:
        while True:
            try:
                await self._connect()
                self.connected.set()
                await self._run_until_disconnect()
            finally:
                self.connected.clear()
            await asyncio.sleep(self._backoff())

    async def send(self, msg: bytes) -> None:
        await self.connected.wait()
        await self._write(msg)                # can still fail: the gate may close meanwhile

The gate tells a task the state was true when it woke up; it cannot guarantee it is still true by the time the task acts. Code after wait() must handle the state having changed — here, _write can fail if the connection dropped between waking and writing. That is the same caveat as any check-then-act sequence in concurrent code, and the reason the gate is an optimisation to avoid futile work, not a guarantee.

Verify: drop the connection while send() callers are waiting; they block until reconnection, and a send that races a drop fails with a connection error rather than hanging.

3. Understand what set() then clear() really does

Here is the pulse pattern, and the measurement:

async def main() -> None:
    ev = asyncio.Event()
    woke: list[int] = []

    async def waiter(i: int) -> None:
        await ev.wait()
        woke.append(i)

    tasks = [asyncio.create_task(waiter(i)) for i in range(5)]
    await asyncio.sleep(0)                    # let them start waiting
    ev.set()
    ev.clear()
    await asyncio.sleep(0.01)
    print(len(woke))                          # 5

All five woke, even though the event was already clear by the time any of them ran. set() resolves the future each current waiter is blocked on; clear() only resets the flag and does not un-resolve those futures. So the pulse reaches exactly the tasks that were inside wait() at the instant of set().

Everyone else misses it: a consumer that was busy processing the previous item when the pulse fired, then calls wait() afterwards, waits for the next pulse — and the item that triggered this one sits unprocessed. Under load, consumers are busy most of the time, so most pulses are missed by most consumers.

Verify: add a waiter that starts waiting one sleep(0) after the pulse; it never wakes.

A pulse reaches only the tasks already waiting 3 lanes over time. A pulse reaches only the tasks already waiting event flag clear set clear again waiters 1-5 waiting woken busy consumer processing previous item waits for a pulse already gone time → The pulse is not remembered, so whoever was not waiting at that instant misses it.

4. Replace pulses with something that remembers

What a pulse is usually trying to say is "there is new work; go and look". That needs memory, and asyncio offers two good shapes:

A Condition with a predicate. The consumer re-checks real state under the lock, so a notification it missed does not matter — the state still says there is work:

class Inbox:
    def __init__(self) -> None:
        self._items: list = []
        self._cond = asyncio.Condition()

    async def put(self, item) -> None:
        async with self._cond:
            self._items.append(item)
            self._cond.notify()

    async def take_all(self) -> list:
        async with self._cond:
            await self._cond.wait_for(lambda: self._items)   # checks state first
            items, self._items = self._items, []
            return items

A version counter. When consumers only need "has anything changed since I last looked", keep a counter and an event per generation:

class Version:
    def __init__(self) -> None:
        self.value = 0
        self._changed = asyncio.Event()

    def bump(self) -> None:
        self.value += 1
        self._changed.set()
        self._changed = asyncio.Event()       # new event for the next generation

    async def wait_newer_than(self, seen: int) -> int:
        while self.value <= seen:
            await self._changed.wait()
        return self.value

A consumer that was busy during several bumps sees value > seen and returns immediately. Replacing the event rather than clearing it means waiters of the old generation are woken and stay woken. For plain work items, an asyncio.Queue is simpler than either.

Verify: with a deliberately slow consumer, every bump or item is eventually observed; none waits for a signal that already happened.

5. Bound every wait

An Event.wait() with no timeout waits for ever if the setter crashes. For latches that is acceptable only when something else will tear the process down; for request paths it never is:

async def wait_ready(service: Service, timeout: float = 10.0) -> None:
    try:
        async with asyncio.timeout(timeout):
            await service.ready.wait()
    except TimeoutError:
        raise ServiceUnavailable("startup did not finish in time") from None

The readiness-probe version of this — reporting "not ready" to an orchestrator rather than making requests wait — is in implementing health and readiness probes for asyncio.

Verify: make startup hang; requests fail after the timeout with a clear error instead of accumulating.

Event, Condition, Queue or version counter? A decision on What are you signalling with 3 outcomes. Event, Condition, Queue or version counter? What are you signalling? a state that is true or false asyncio.Event latch or gate discrete work items asyncio.Queue nothing is lost something changed Condition or version missed notifications are safe If the message is "this happened", the receiver needs memory; an Event only holds the current state.

Verification

Event usage is sound when:

  • Every Event models a state — latch or gate — never a momentary pulse.
  • Code after wait() tolerates the state having changed before it acts.
  • Change notifications use a Condition, Queue or version counter, and slow consumers miss nothing.
  • Request-path waits have timeouts.

Diagnostic Hook: for gates, export the time spent closed and the number of waiters blocked on them; a gate that is closed a lot is an availability signal in its own right. In tests, add a slow consumer to every notification path and assert it processes everything — the single most effective check for lost-pulse bugs.

Pitfalls & edge cases

  • Pulsing with set(); clear(). Only current waiters wake; busy consumers miss the signal.
  • Clearing a latch. Someone will eventually call clear() on a "ready" event during a reload, and every new request will hang; make latches one-way.
  • Creating Events at import time. Like other primitives they bind to a loop on first contended use; create them inside the running application.
  • Waiting on an event with no setter. If the only code path that sets it can fail, add a timeout or set it in a finally.

Frequently Asked Questions

What happens if I call set() and then clear() on an asyncio Event?

Every task already blocked in wait() is woken and proceeds, even though the flag is cleared again. Tasks that call wait() afterwards block until the next set(). The signal is not remembered, so consumers that were busy miss it.

Is asyncio.Event reusable?

Yes: set() and clear() can be called any number of times, which makes it suitable as a gate for a state that flips. It is not suitable for counting or remembering events that happened while nobody was waiting.

How do I notify waiting tasks that new data is available?

Use an asyncio.Queue for discrete items, or an asyncio.Condition where consumers wait_for a predicate on shared state. Both work even when a consumer was busy at the moment of notification, unlike an Event pulse.

Can I wait on an asyncio Event with a timeout?

Yes. Wrap event.wait() in async with asyncio.timeout(seconds), and handle TimeoutError.