Locks, semaphores and events

asyncio · Practical · 10 min

Shared state and rate limits still need coordination, even on a single thread.

Coroutines run on one thread, so a single statement like x += 1 can't be interrupted. But any sequence that awaits in the middle can: read a value, await something, write it back, and another coroutine may have changed it in between. asyncio provides async versions of the classic primitives (not thread-safe; for threads use threading):

  • asyncio.Lock: async with lock: ensures only one coroutine is inside the block at a time.
  • asyncio.Semaphore(n): at most n holders at once, the standard way to cap concurrent requests to a service. BoundedSemaphore raises if released too often.
  • asyncio.Event: one coroutine calls event.set(), any number blocked on await event.wait() wake up. Good for "ready" or "shutdown" signals.
  • asyncio.Condition and asyncio.Barrier (3.11) cover rarer cases.

These primitives no longer take a loop argument and bind to the running loop when first used, so create them inside async code or at least use them in only one loop.

A Semaphore around a fan-out is the simplest rate limiter: gather all tasks, and each acquires the semaphore before its request.

Example

import asyncio

sem, active, peak = asyncio.Semaphore(2), [], 0

async def call_api(i):
    global peak
    async with sem:                      # at most 2 inside at once
        active.append(i); peak = max(peak, len(active))
        await asyncio.sleep(0.05)
        active.remove(i)
    return i

async def main():
    ready = asyncio.Event()
    async def waiter():
        await ready.wait()
        print("waiter: go!")
    w = asyncio.create_task(waiter())
    print(await asyncio.gather(*(call_api(i) for i in range(6))))
    ready.set(); await w
    print("peak concurrency:", peak)

asyncio.run(main())
# [0, 1, 2, 3, 4, 5] / waiter: go! / peak concurrency: 2

Remember

  • Races can happen across await points, even on one thread
  • Semaphore(n) caps concurrent operations
  • Event broadcasts a one-time signal to many waiters

Watch out for

  • Using threading.Lock in async code: acquiring it blocks the whole event loop.

Check yourself

  1. Which primitive is the usual way to allow at most 10 concurrent HTTP requests?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/asyncio-sync.html