skip to content

Why do two `await` calls written one after another run sequentially, not concurrently?

level: middleimportance: must knowfreq 70%

answer

  1. Await is a wait, not a fork
  2. It frees the thread, not the order
  3. Durations add up like sync code
  4. Other scheduled work runs meanwhile
  5. Concurrency needs tasks scheduled together

basics

~20 s

Await means wait here until this one awaitable finishes. It suspends the current coroutine so the loop can run other already-scheduled work, but the next line of this coroutine still runs only after the first await completes, so back-to-back awaits add up.

solid answer

~40 s

`await x` is sequential composition: it suspends the coroutine that executes it and resumes at the next statement only once `x` has produced a result. What the suspension buys is that the **event loop** may run *other* scheduled work meanwhile - it does not overlap the two statements inside this coroutine. So `await a()` followed by `await b()` takes roughly the sum of the two durations, exactly like synchronous code, and a `for` loop that awaits once per item is a serial loop. Concurrency needs more than one unit scheduled on the loop at the same time, which is what `asyncio.create_task`, `asyncio.gather` and `asyncio.TaskGroup` exist for; that scheduling is a separate topic. Async I/O by itself buys nothing until something is actually scheduled in parallel.

code

python · 13 lines
python
import asyncio, time

async def load(name):
    await asyncio.sleep(0.5)
    return name

async def main():
    start = time.perf_counter()
    await load("a")
    await load("b")
    print(f"{time.perf_counter() - start:.1f}s")   # ~1.0s

asyncio.run(main())

go deeper

for a junior

Remember that await means wait: two awaits in a row take about as long as both operations combined. Rewriting a loop with async and await does not by itself make it run in parallel.

for a middle

Explain the two effects of await - the current coroutine stops there, while the loop is free to resume other scheduled work - and state plainly that concurrency comes from scheduling several units, not from the keyword.

for a senior

Diagnose the real-world version: an async service no faster than its sync predecessor because every handler awaits serially. Be ready to point at where suspension never happens and where work should have been scheduled together instead.

for a principal

Frame the strategy: async buys concurrency per thread, not latency per request, and colouring the call graph is a lasting architectural cost. Decide where the async boundary sits and what the codebase gains in return.

### What `await` promises `await x` says two things at once, and interviews probe whether a candidate has separated them. 1. **To the coroutine executing it:** stop here. The next statement does not run until `x` has completed and its result is available. In that respect `await` behaves exactly like a blocking call - the code reads top to bottom and means what it looks like. 2. **To the event loop:** this coroutine is parked and its thread is free. While it is suspended, the loop may resume anything *else* it already has scheduled and ready. The second promise is the whole point of asyncio, and it is also what gets over-read. Suspension frees the *thread*, not the *statement order*. Nothing in `await` starts anything early, runs the next line ahead of time, or overlaps two awaits in the same coroutine. ### The arithmetic that follows ```python async def main(): a = await load_a() # 300 ms b = await load_b() # 300 ms return a, b # done after ~600 ms ``` That coroutine takes about 600 ms. It is not slower than the synchronous version, and - crucially - it is not faster either. If nothing else is scheduled on the loop, those 600 ms are 600 ms of an idle thread. The same reasoning applies to the shape people write most often: ```python results = [] for url in urls: results.append(await fetch(url)) # strictly serial ``` One hundred 50 ms fetches take five seconds. Rewriting the loop with `async def` and `await` changed the syntax and not the timeline. ### Why the illusion is so persistent Three things feed it. First, the word *async* suggests "in the background", when here it only means "can suspend". Second, most tutorials show `await asyncio.sleep(...)` inside code that already has several tasks running, so the reader attributes the overlap to `await` instead of to the scheduling. Third, an `await` really can let a whole program go faster - just not through this coroutine's own statements. Under a server handling many connections, each handler's `await` is what lets the other handlers make progress. The concurrency comes from there being many handlers scheduled, not from the `await` keyword. ### What concurrency actually requires To overlap two operations, both must be *scheduled units* known to the loop at the same time. In asyncio that means wrapping coroutines in tasks - `asyncio.create_task`, `asyncio.gather`, or an `asyncio.TaskGroup` block - and then awaiting the aggregate. The details of those constructs, their exception behaviour and how to keep references to running tasks are their own subject; the point that belongs here is the boundary: **`await` composes, scheduling parallelises.** A single `await` on a single awaitable can never be concurrent with anything else in the same coroutine, no matter what it is awaiting. ### The subtler failure: an await that never suspends Suspension is not guaranteed by writing `await`. If the awaited coroutine completes without ever reaching a real suspension point - a cache hit, a value already available, an `asyncio.Future` that is already done - control returns without ever handing back to the loop. A tight loop of such awaits starves every other task even though the code is littered with `await` keywords. Conversely, `await asyncio.sleep(0)` is the idiom for deliberately yielding one turn to the loop without waiting for anything. The lesson is that `await` marks a *possible* suspension point, not a guaranteed one. ### Function colouring, in one paragraph Because `await` is only legal inside `async def`, making one function async forces every caller that wants its result to be async too, all the way up to a top-level `asyncio.run`. That is the "colouring" split: sync and async versions of the same call graph do not compose, and a codebase tends to grow duplicated pairs or a hard boundary where blocking work is pushed off the loop with `asyncio.to_thread`. Recognising that the ripple is a consequence of `await`'s sequential, in-coroutine semantics - not an accident of the library - is what a middle-level answer is expected to reach.

  • Does every `await` actually suspend the coroutine?
    No. `await` marks a *possible* suspension point. If the awaited coroutine reaches its return without hitting a real suspension - a cached value, an already-completed future - control comes straight back and the loop never regains it. A tight loop of such awaits can starve other tasks despite being full of `await` keywords. `await asyncio.sleep(0)` is the explicit way to hand the loop one turn.
  • Why does making one function `async def` ripple through its callers?
    `await` is only legal inside a coroutine function, so any caller that needs the result must itself become `async def`, and so on up to a top-level `asyncio.run`. That is the function-colouring split: synchronous and asynchronous call graphs do not compose. Teams either keep the async boundary shallow, or push blocking work off the loop with `asyncio.to_thread` at the edge rather than colouring everything.
  • If awaiting is sequential, where does asyncio's throughput actually come from?
    From having many coroutines scheduled at once. Each one's `await` releases the single thread while its I/O is outstanding, so the loop can advance the others. One request handler is no faster than its synchronous twin; a thousand of them share one thread instead of a thousand stacks. The win is concurrency across units of work, never speed within one.

Awaiting twice is like phoning one supplier, waiting for the answer, then phoning the next. You free the phone line for colleagues between calls, but your own two calls still happen one after the other.

saying these in an interview costs you the question

  • Thinks await starts the work in the background
  • Says an async for loop is automatically parallel
  • Believes async makes a single request faster
  • Claims await releases the loop in every case
  • Confuses suspending a coroutine with spawning a thread

context