2025, Dec 22 11:00

When Global State Is Safe in Asyncio: Understanding Await Boundaries in Single-Threaded ASGI

Learn why mutating module-level globals is safe in single-threaded asyncio/ASGI, when it fails with threads or multiprocess and how await boundaries stop races

Mutating module-level state in an ASGI app often triggers the same question: is it safe to change singletons or globals in a single-threaded asyncio setup? The short answer is that the risks you know from threads don’t automatically carry over to asyncio. The longer answer is worth understanding, because it clarifies when shared state is fine and when it will bite you.

The setup

Consider a minimal async script that schedules a lot of coroutines, each of which increments a global counter after yielding control. The final value reliably lands where you expect it to.

import asyncio
import random

total = 0

async def bump_total(tag: str):
    global total
    await asyncio.sleep(random.uniform(0, 5))  # hand control back to the loop
    total = total + 1  # read-modify-write
    print(f"{tag}: total -> {total}")

async def orchestrate():
    await asyncio.gather(*[bump_total(f"{i}") for i in range(10000)])
    print(f"Final total (should be 10000): {total}")

if __name__ == "__main__":
    asyncio.run(orchestrate())

What looks dangerous

The suspicious line is the read-modify-write operation. In a threaded program it is not thread-safe, because a thread switch can occur after the current value is read and before the updated value is written. That opens a race where multiple threads compute a new value from the same stale read and step on each other’s writes.

Threading is pre-emptive. The operating system decides when a thread runs and when it yields, so a switch can happen in the middle of such an operation.

Why this asyncio example works

The key difference is that this program is not multi-threaded. Asyncio scheduling is cooperative, and task switches only occur at await points. In this example each coroutine yields during the sleep, but the increment happens after the await and has no await inside it. That means the increment executes to completion without another task interrupting it. The design of asyncio guarantees this behavior, which is consistent with the observed correct result.

Solution

Within a single-threaded asyncio program, a mutation like the increment shown above is safe because control switches only at awaits, and the mutation is outside any await. The code, as written, is fine under that constraint.

import asyncio
import random

total = 0

async def bump_total(tag: str):
    global total
    await asyncio.sleep(random.uniform(0, 5))
    total = total + 1
    print(f"{tag}: total -> {total}")

async def orchestrate():
    await asyncio.gather(*[bump_total(f"{i}") for i in range(10000)])
    print(f"Final total (should be 10000): {total}")

if __name__ == "__main__":
    asyncio.run(orchestrate())

A note on ASGI deployments

In a single-process ASGI server, this approach will not cause problems beyond stylistic concerns. In a multi-process server this will fail.

Why this matters

It’s easy to conflate concurrency models. When you’re reasoning about safety of shared state, you need to know whether you’re in a pre-emptive threading context or a cooperative asyncio context. The same line of code that’s a race in threads can be perfectly fine in a single-threaded event loop, because only awaits define scheduling boundaries.

Takeaways

If your ASGI application runs single-threaded on asyncio, mutating a shared in-memory variable between awaits is safe in the sense described above. If you move to threads, the same pattern is not safe. And if you deploy with multiple processes, this pattern will not work. Understanding the boundary at await—and the difference between pre-emptive and cooperative scheduling—helps you decide when module-level state is acceptable and when it’s time to change the approach.