PyDepth

The Real Cost of an async Call

Awaiting a coroutine that never suspends costs 53 ns against 24 ns for a plain call. Wrapping the same coroutine in a Task and awaiting it costs 28,958 ns.

4 min read

async def is often discussed as though it were a different language with different performance rules. It is a keyword that makes a function return a coroutine object, and the costs are small, specific and measurable — except for one, which is enormous and easy to write by accident.

Four shapes of the same function

The harness times the same trivial addition four ways: called directly, awaited as a coroutine, wrapped in a Task and awaited one at a time, and gathered a thousand at a time. A bare await asyncio.sleep(0) is included as the floor for one loop iteration.

$ python3 experiments/asynccost/calls.py
python 3.13.2 on darwin
200,000 iterations each

shape                 per op   vs plain
plain call             24 ns       1.0x
await coroutine        53 ns       2.1x
await Task          28958 ns    1182.4x
await sleep(0)      14519 ns     592.8x
gather, 1000 wide    1914 ns      78.1x

coroutine object: 192 bytes

The cheap one

Awaiting a coroutine that never suspends costs 53 ns against 24 ns for the plain call. That 29 ns buys the coroutine object, the frame that gets suspended and resumed, and the protocol by which await drives it. It is a constant, it does not depend on what the coroutine does, and against any function body that touches a dictionary or formats a string it disappears.

This is the number to have in mind when someone proposes rewriting a helper as async def so it can be awaited from an async caller. The rewrite is fine. It is not a performance decision in either direction.

The other constant worth knowing is the object: 192 bytes per coroutine on this build. That is cheap next to a thread, which is the comparison that matters, and expensive next to nothing, which is the comparison that catches people. A hundred thousand coroutines held in a list are 19 MB before a single one has been scheduled.

The expensive one

await asyncio.ensure_future(coro) in a loop measured 28,958 ns per iteration — a thousand times the plain call, and five hundred times the direct await. That is the spelling the harness uses; for a coroutine argument asyncio.create_task is the same operation.

Nothing about the coroutine changed. What changed is that the result is no longer produced by a function call; it is produced by the event loop. Creating the Task puts a callback on the ready queue and returns. Awaiting it suspends the caller. The loop finishes the current callback, finds nothing else runnable, polls the selector, wakes, and runs the Task. Then it does the same thing again to deliver the result to the waiting caller.

The await asyncio.sleep(0) row is the same story with the Task removed: 14,519 ns for one deliberate trip around the loop and back. That is the price of a loop iteration on this machine, and awaiting a Task individually costs roughly two of them.

The one that matters

The last row is the same coroutines run a thousand at a time under gather: 1,914 ns each. Same Task machinery, same loop, one round trip amortised across a thousand pieces of work instead of paid per piece.

This is the actual rule, and it is not about async at all. The event loop charges per trip, not per coroutine. Work that goes through it in a batch is cheap; work that goes through it one item at a time pays the full fixed cost every time.

Which is why the shape below is the most expensive way to write the same program:

for row in rows:
    await asyncio.create_task(process(row))   # a loop round trip per row

and this one is not:

await asyncio.gather(*(process(row) for row in rows))

The first version creates concurrency and then immediately destroys it by waiting. It reads like the second version. It performs like a synchronous loop with a syscall bolted onto each iteration.

Seeing it in a running program

The loop will tell you when it is being used this way, if it is asked. Debug mode logs any callback that takes longer than a threshold, and the threshold is settable:

loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.005   # default is 0.1 seconds

What that surfaces is the opposite problem — a single callback hogging the loop — but the two are diagnosed together, because both show up as a loop that is busy while nothing useful is finishing. A cheap sanity check for the batching mistake is to count trips directly: wrap the loop’s _run_once during a request and compare the count against the number of items processed. If they are the same, the work is going through one at a time.

Where the real programs sit

None of these figures describe an application that is actually doing IO. A coroutine awaiting a database round trip spends milliseconds waiting and nanoseconds on the machinery above; the overhead is invisible against the wait, which is the entire point of the design.

They describe the boundary — the code that decides how work reaches the loop. That code is usually a loop over a list, it is usually written once, and it is where the difference between 1,914 ns and 28,958 ns per item is decided.

Where to stop

Everything here is CPython 3.13.2 with the default event loop on macOS. The loop-trip cost in particular is selector-dependent: a different platform, or uvloop, will produce a different number for the sleep(0) row and therefore for the Task row.

The ordering is what transfers. Direct await is nearly free, a loop trip is not, and batching is what turns the second into the first. If a profile shows time inside asyncio.base_events, the answer is almost never a faster loop — it is fewer trips through the one already there.

Frequently asked

Is async slower than sync?
Per call, marginally — 29 ns of extra frame handling. Per unit of concurrency it is dramatically cheaper than a thread. The mistake is using the concurrency machinery for work that is not concurrent.
Why is awaiting a Task so much more expensive than awaiting a coroutine?
Awaiting a coroutine directly is a function call into a generator frame. A Task is scheduled: it goes on the loop's ready queue, the loop finishes the current callback, polls the selector and comes back. That poll is a syscall.
Should I avoid create_task then?
No. Create tasks when you want things to run concurrently, which is the case that pays for the loop trip. Do not create one for work you are about to await immediately — that is a scheduling round trip for a function call.
Share

Related posts

Arrow keys to move, Enter to open.