How can shared state change across an `await` when asyncio runs on one thread?
answer
- One thread does not mean uninterrupted
- Interleaving happens only at known places
- Between two awaits is atomic
- A value read before await may be stale
- Check-then-act split by a suspension
basics
~20 sAwait points are the only places another coroutine can run, so anything between two awaits is atomic but anything read before an await may be stale after it. A check-then-act sequence split by an await is a race even without threads.
solid answer
~50 sA single-threaded event loop still interleaves work - just at known places. Code between two `await` expressions runs without interruption, so no other coroutine can observe it half-done; but the moment a coroutine suspends, others resume and may mutate anything shared. A value read before an `await` can therefore be stale when the coroutine resumes, and a check-then-act or read-modify-write sequence that straddles a suspension is a genuine race. Three fixes cover most cases: read once into a local and use that local, so intent is explicit; publish updates as a whole immutable snapshot rebound to one name, since rebinding happens between awaits and readers see the old or new object but never a half-built one; or hold an `asyncio.Lock` when the critical section genuinely must span a suspension. A `threading.Lock` is the wrong tool - it would block the loop.
code
python · 16 linesimport asyncio
flags = {"beta": False}
async def read_then_use():
enabled = flags["beta"]
await asyncio.sleep(0) # anything may run here
return enabled, flags["beta"] # (False, True): the read went stale
async def main():
task = asyncio.create_task(read_then_use())
await asyncio.sleep(0)
flags["beta"] = True
print(await task)
asyncio.run(main())go deeper
Take away the core rule: other coroutines can only run while yours is suspended at an await, so a value read before an await might not still be true after it.
Explain why code between two awaits is effectively atomic and what that implies: the audit is per suspension point, and the fix is usually to read once into a local rather than to add a lock.
Show the production instinct: identify the check-then-act or read-modify-write that straddles a suspension, choose between snapshot rebinding and an asyncio.Lock, and describe how you would force the interleaving in a test.
Own the boundary you are selling to the team: asyncio removes data races but not logical ones, and its advantage is that every interleaving point is visible in the source. Decide the conventions - immutable snapshots, lock scope, review rules - that make that advantage real.
### One thread, but not one continuous story The reassuring half of asyncio is that a coroutine runs to its next `await` without interruption. There is no pre-emption, no arbitrary bytecode boundary at which another coroutine can slip in. Everything between two suspension points is effectively atomic with respect to the rest of the loop, which is why a great deal of asyncio code is safe without any locking at all. The dangerous half is the mirror image: **every `await` is a place where the world can change underneath you**. The loop resumes other ready tasks while you are parked, and they may mutate the very dict, list, counter or cached snapshot you read a line earlier. Nothing tells you this happened; your local variables are intact, so the code looks consistent while its assumptions have expired. ### The shape of the bug Consider a feature-flag service whose handlers consult an in-process flag map while a background refresher updates it, at a peak of about 1,200 requests per minute: ```python async def handle(request): if flags["new_pricing"]: # check quote = await price_new(request) # suspension point else: quote = await price_old(request) return render(quote, flags["new_pricing"]) # act on a re-read ``` The check and the final read are separated by a suspension. If the refresher flips the flag while the handler is awaiting, the request is priced one way and rendered the other. It happens rarely, only when a refresh lands inside the pricing window, and it is invisible in tests that never overlap the two - the classic profile of a production-only race. Worse variants mutate a shared structure in place: a refresher that clears a dict and then repopulates it key by key, with an `await` in the middle, exposes an empty map to every handler that resumes in that gap. Read-modify-write is the other standard shape: ```python count = counters[key] await persist(count + 1) counters[key] = count + 1 # lost update if anyone else did the same ``` This is a lost update in the textbook sense, in a program with exactly one thread. ### Three fixes, in order of preference **Read once, then use the local.** If a decision must be consistent for the whole request, read the shared value into a local before the first `await` and use only that local afterwards. The request then acts on a coherent view, even if it is a moment out of date - usually the correct semantics for configuration and flags anyway. This is the cheapest fix and it is a readability win, because the snapshot point becomes explicit. **Publish whole snapshots by rebinding, never by mutating.** Have the writer build a completely new dict and rebind the shared name in one statement. Name rebinding cannot be interrupted by another coroutine, because rebinding contains no `await`. Readers hold either the old object or the new one, never a half-built one. Combined with the rule above - grab the object once into a local - readers need no locking at all, and there is no window in which the map is empty. **Use `asyncio.Lock` when the critical section really must span a suspension.** Some sequences genuinely cannot be restructured: an update that must persist and then reflect the persisted value, for instance. `async with lock:` serialises those sections against other coroutines. Two cautions: it must be an `asyncio.Lock` and not a `threading.Lock`, because a threading lock would block the entire loop rather than suspending the coroutine; and the lock must be created on the loop that uses it. Holding a lock across a long await also serialises throughput, so keep the guarded region as small as the invariant allows. ### How to reason about it in review The practical discipline is a scan rather than a theory: **find every `await` inside a function that touches shared mutable state, and ask what would break if an arbitrary other coroutine ran at that exact point.** Because the suspension points are visible in the source - unlike thread pre-emption, which can occur anywhere - asyncio races are far easier to audit than threaded ones. That visibility is one of the model's real advantages, and it is wasted by teams who conclude that single-threaded means race-free. The distinction worth stating explicitly in an interview: single-threaded execution removes *data* races at the memory level - you will never see a torn value or need a memory barrier - but it does not remove *logical* races over application invariants that span a suspension point.
- If it is single-threaded, is it fair to call this a race condition at all?Yes, though it is a logical race rather than a data race. Single-threaded execution rules out torn reads and memory-ordering problems, so no barriers or atomics are needed. What remains is interleaving over application invariants: two coroutines whose steps are correct individually produce a wrong combined outcome when one suspends mid-sequence. The label matters less than the fix, which is the same as under threads - do not split an invariant across a suspension.
- Why is `threading.Lock` the wrong choice inside a coroutine?Acquiring a contended `threading.Lock` blocks the calling thread, and in asyncio that thread is running the event loop - so every other coroutine, including the one that would release the lock, stops making progress. That is a self-inflicted deadlock in the common case. `asyncio.Lock` instead suspends the coroutine and returns control to the loop, which is the only correct behaviour on the loop's thread.
- How would you make this class of bug visible before production?Force interleaving in tests rather than hoping for it: schedule the reader and the mutator together and insert `await asyncio.sleep(0)` at the suspension points to guarantee the loop switches. In review, scan each `await` inside functions touching shared state and ask what an arbitrary other coroutine running there would break. The suspension points are visible in the source, which makes this auditable in a way thread pre-emption is not.
It is like leaving your desk mid-task: nobody touches your work while you are sitting there, but the notes you memorised before stepping out may have been rewritten by the time you sit back down.
saying these in an interview costs you the question
- Says single-threaded asyncio cannot have race conditions
- Thinks a whole coroutine runs atomically start to finish
- Reaches for threading.Lock inside a coroutine
- Believes await only pauses, never lets others mutate
- Guards a read but leaves the later re-read unguarded
- Mutates a shared dict in place across a suspension