skip to content

Why does a module-level global lose the right value when two asyncio tasks interleave?

level: juniorimportance: should knowfreq 42%

answer

  1. One name, many concurrent readers
  2. Control leaves the coroutine somewhere
  3. The write and the read are not adjacent
  4. Another task runs at the suspension point
  5. Per-task state needs per-task storage

basics

~20 s

One module-level global is shared by every task in the process. A coroutine can be suspended at any await, so a second task overwrites the global before the first resumes and reads it back. A contextvars.ContextVar gives each task its own value instead.

solid answer

~50 s

A module global is a single binding in a single module namespace, and every task on the event loop reaches the same one. Asyncio is cooperative: a coroutine keeps the thread only until its next `await`, and at that point the loop is free to run another task. So the sequence write-global, `await`, read-global is not atomic — another task can write the global in between, and the first task resumes to find someone else's value. The failure is silent and it scales with load: with one task in flight the code looks perfect. A `contextvars.ContextVar` fixes it because asyncio runs each task in its own copy of the context, so each task's write is private and survives arbitrary interleaving. The general rule is that per-request ambient state must be per-task state, not module state.

code

python · 15 lines
python
import asyncio

current_section = "none"

async def build(section):
    global current_section
    current_section = section
    await asyncio.sleep(0.01)
    return f"{section} logged as {current_section}"

async def main():
    print(await asyncio.gather(build("headcount"), build("burn-rate")))

asyncio.run(main())
# ['headcount logged as burn-rate', 'burn-rate logged as burn-rate']

go deeper

for a junior

Recall that all tasks on one event loop share the same module globals, and that an await lets another task run. Know that contextvars.ContextVar is the per-task alternative.

for a middle

Explain the exact window: write, suspension point, read, with another task's write landing in between. Be able to contrast per-task context storage with per-thread storage and say why per-thread is too coarse on a loop.

for a senior

Demonstrate that you would catch this before production: a regression test that runs two units of work concurrently and asserts each sees its own value, plus a review instinct for module-level mutable state in async code paths.

for a principal

Frame the policy: which state is allowed to be ambient, how it is carried across the boundaries where context does not follow, and how the team keeps request-scoped values out of module namespaces as a codebase grows.

### The failure Ambient per-request state — which report section is being built, which tenant is being served — often starts life as a module-level global because it is the shortest thing to write. Under a single request it works flawlessly. Under concurrency it produces wrong values with no exception, no traceback and no log line saying anything went wrong. Consider a nightly report generator that renders a headcount section and a burn-rate section concurrently, one task each. Each task stores its section name in a module global, awaits some I/O, then reads the global back to label its output. If both tasks are in flight, whichever wrote the global most recently wins, and both sections can end up labelled the same. On an 11-person team this shows up as a headcount table that claims to be the burn-rate chart. ### Why interleaving does this Asyncio is **cooperative** concurrency on one thread. A coroutine runs uninterrupted only between suspension points; every `await` that actually yields hands control back to the event loop, which may then step a different task. So this innocent sequence is three separate scheduling opportunities, not one atomic operation: ```python current_section = name # write await fetch_rows() # <-- another task may run here, and write too label = current_section # read: whose value is this? ``` Nothing here is a data race in the machine-word sense — the GIL and the single thread guarantee each statement completes — the bug is purely one of **logical ownership**. The state is per-task by intent but per-module by implementation. Two properties make it especially nasty in review: * **It passes tests.** A test that runs one task at a time never interleaves, so the global is never clobbered. * **It degrades with load.** The more concurrency, the more often the window is hit, so the symptom shows up in production first. ### Why a ContextVar fixes it A `contextvars.ContextVar` is a key that resolves against whichever `contextvars.Context` is currently entered, and asyncio enters a **per-task** context: each task runs in a copy of the context that existed when it was created. A `ContextVar.set` inside a task writes into that private copy, so no other task can overwrite it, no matter where the interleaving falls. The read after the `await` therefore returns the value this task wrote, which is exactly the ownership the code always intended. The same reasoning is why a ContextVar is the async-safe answer where earlier code would have reached for per-thread storage: on an event loop, one thread runs many logical units of work, so per-thread is far too coarse. ### The alternatives, ranked 1. **Pass it explicitly** as a parameter. Boring, always correct, and the right answer whenever the value only needs to travel a couple of frames. 2. **Put it on the object that owns the work** — a per-request object, a small dataclass carried through the call chain. 3. **Use a ContextVar** when the value must be readable deep in a call stack that you do not want to re-plumb — logging correlation being the canonical case. 4. **Module global** — only for genuinely process-wide, effectively-immutable configuration that no request mutates. ### Saying it well in an interview Name the suspension point. The strongest short answer is: *the global is shared, `await` is a scheduling point, so between my write and my read another task can run and write; the state is per-task so it needs per-task storage, which is what a ContextVar gives me.* Candidates who reach for a lock here are answering the wrong question — a `asyncio.Lock` would serialize the tasks rather than give each one its own value, which defeats the concurrency that was the point. ### Proving it in thirty seconds The convincing demonstration is small: run two coroutines concurrently, have each write its own name to the global, await a short sleep, then read the global back and return both the name it wrote and the name it read. The two returned pairs disagree, and the disagreement is deterministic enough to assert on. Keeping that snippet to hand is worth more in a review argument than any amount of describing the window, because the objection you will meet is usually *but it is single-threaded, so how can it interleave?* ### Spotting it in review Three signals are worth training yourself on. A `global` statement inside an `async def` is the loudest: it says a coroutine is writing process-wide state. A module-level name that is reassigned rather than only read is the same smell without the keyword, and a class attribute used as a per-request slot is the object-oriented spelling of it. In every case ask the ownership question — *does this value belong to the process, or to one unit of work?* — because that is the question the storage choice is answering, whether or not anyone noticed. The subtler variant is a value that starts out genuinely process-wide, such as a default tenant, and later acquires a per-request override. The override is what breaks: the same binding now carries two different lifetimes. Splitting it into a module constant for the default and a ContextVar for the override keeps both correct.

  • Would adding an asyncio.Lock around the global fix this?
    No, not usefully. A lock could make the write-await-read sequence exclusive, but only by serializing the tasks — you would lose the concurrency you were paying for, and any await inside the critical section would hold the lock across I/O. The problem is not simultaneous access, it is that the state is logically per-task while the storage is per-module. Give each task its own value with a `contextvars.ContextVar`, or pass the value as an argument.
  • Why does this bug usually survive the test suite and appear only in production?
    Tests typically exercise one logical request at a time, so no second task ever runs at the awaits and the global is never overwritten. The window only opens when two tasks are in flight together, so the defect rate scales with real concurrency. Reproducing it deliberately — running two tasks with `asyncio.gather` and asserting each sees its own value — is what turns it into a caught regression.
  • Is this the same thing as a data race?
    No. Asyncio runs tasks on one thread and switches only at suspension points, so no statement is torn and no memory model subtleties are involved. Each write completes fully before another task runs. The defect is one of logical ownership: shared storage holding state that belongs to one unit of work. The fix is to scope the storage correctly, not to add synchronization.

saying these in an interview costs you the question

  • Thinks each coroutine gets its own copy of module globals
  • Blames a data race or torn write on one thread
  • Reaches for a lock instead of per-task storage
  • Assumes code between two awaits can be preempted anywhere
  • Says the bug is impossible because asyncio is single-threaded

context