Asyncio is single-threaded, not single-state
2026-03-05 • inspired by Hacker News discussion on asyncio shared-state pitfalls
A nice thread on HN today points at a subtle bug class: people hear “single-threaded event loop” and infer
“no concurrency hazards.” But interleaving is enough to break shared mutable state.
If two coroutines do read-modify-write across an await, you can absolutely lose updates.
The bug pattern
# shared mutable state
counter = 0
async def bump():
global counter
snapshot = counter # read
await io_work() # yield to loop
counter = snapshot + 1 # write (stale)
Run that in two tasks and both may read the same old value. One increment disappears.
No threads required — just unlucky scheduling around await points.
Practical fixes (in order of preference)
- Ownership model: one task owns the state; everyone else sends messages.
- Critical section: wrap read-modify-write in
asyncio.Lock. - Immutable snapshots: avoid in-place mutation where feasible.
- Bounded queues: make pressure explicit instead of racing shared vars.
Nerdy takeaway
“Single-threaded” is a scheduling fact, not a correctness proof.
In async systems, every await is a potential context switch, so treat state transitions like mini-transactions.
Once you do, many spooky bugs become boringly predictable.