programmingconcepts

§ 6.2 · Concurrency

Async and await

A way to write code that waits (for the network, a disk, a timer) without blocking: await pauses the function and lets other work run until the result is ready.

Async and await let one thread wait for slow operations, such as network requests, files and timers, without blocking. When a function reaches await on something unfinished, it pauses and the event loop runs other ready work, resuming the function when the result arrives. Waits overlap, so two one-second requests take about one second. It does not speed up computation.

Good for
Waiting: network, disk, timers
Not for
Heavy computation
Two waits take
The longer one, not the sum
Runs on
One thread (JavaScript and Python)
Requires
Functions
import asyncio


async def fetch_user(name, seconds):
    await asyncio.sleep(seconds)
    return name


async def main(seconds_a, seconds_b):
    log = ["start"]
    a = asyncio.create_task(fetch_user("A", seconds_a))
    b = asyncio.create_task(fetch_user("B", seconds_b))
    results = await asyncio.gather(a, b)
    log.append("done")
    return results, log

main() fetches two users at once. A takes 300 ms and B takes 100 ms, both spent waiting, not computing.

step 0 / 0runningpausedready to resume

Fig. 6.2. The call stack is what's running right now. Paused functions sit in Waiting until their timer fires, then join the Ready queue for the event loop to resume. Change how long each fetch waits with the sliders.

Intuition

A cook with three pans doesn’t stand and stare at the pasta water until it boils. They start the water, start the sauce, chop while both heat up, and go back to whichever is ready first. One cook, many dishes in progress, and nobody standing idle.

An async program is that cook. await means “this will take a while; come back to me when it’s ready”. Meanwhile the one thread is free to run something else.

How it works

An event loop runs one piece of code at a time. When a function reaches await on something that isn’t ready yet, it pauses: its place is saved, it leaves the call stack, and the loop runs other ready code. When the awaited thing finishes (a timer fires, a response arrives), the paused function joins the ready queue and later resumes exactly where it stopped.

In the figure:

  1. main calls fetchUser('A'), which runs until await sleep(...) and pauses.
  2. The same happens for B, so both waits are now running at the same time.
  3. main awaits Promise.all([a, b]) and pauses. The stack is empty, but the program isn’t stuck: the loop is waiting for a timer.
  4. Each timer fires, its function resumes and returns, and finally main resumes with both results.

The total time is the longest wait, not the sum. That’s the whole point.

Async is not parallel

Async lets one thread wait for many things at once. It doesn’t make computation faster: if a function adds up a billion numbers without awaiting, nothing else runs until it finishes. For CPU-heavy work you need threads or processes (Web Workers in the browser, multiprocessing in Python).

Aspect Async and await Threads
Runs code in parallel No: one thing at a time Yes, on multiple cores
Switches tasks Only at await At any moment
Best for Many slow waits (requests, files) Heavy computation, or blocking libraries
Shared data Safe between awaits; can still race across one Needs locks

In each language

Language What it looks like
JavaScript async functions return Promises. The browser and Node.js run one event loop; Promise.all waits for several at once.
Python async def and await with asyncio. asyncio.gather or TaskGroup runs coroutines concurrently. A plain time.sleep inside async code blocks the whole loop.
Go No async keyword: start a goroutine with go f(). The runtime parks a goroutine that waits, so ordinary blocking code is fine.
Rust async fn returns a Future that does nothing until awaited. You need a runtime crate such as Tokio to run futures.
C# Introduced async/await in 2012; Task.WhenAll waits for several tasks.
Java No await; CompletableFuture, or virtual threads (Java 21) that let blocking code scale like async code.

Common mistakes

  1. Awaiting in a loop when the waits are independent. for (const id of ids) await fetch(id) waits for each one before starting the next. Start them all, then await Promise.all(...).
  2. Forgetting await. You get a Promise or coroutine object instead of the result, and errors go unnoticed.
  3. Blocking the loop. A slow synchronous call (heavy computation, time.sleep, a blocking database driver) freezes every other task.
  4. Assuming no races. Between a read and a write separated by an await, other tasks can change the data. See race conditions.

Check yourself

Check yourself

Two independent requests take 300 ms and 200 ms. Started together and awaited with Promise.all, how long until both are done?

Check yourself

An async function adds up a billion numbers without any await. What happens to other tasks?

Sources

  • MDN Web Docs, “Using promises” and “The event loop”.
  • Python documentation, “Coroutines and Tasks” (asyncio).
  • Asynchronous Programming in Rust (the Rust async book), chapter 1.

Newsletter

One new concept each week, by email.

Signup opens soon.