Async/Await and Asynchronous Control Flow

async/await lets you write asynchronous code that *reads* like ordinary step-by-step code.

Learning Objectives

Explain why async/await exists and how async functions suspend and resume (state-machine transformation).

Know async return types per language; await one or many tasks; distinguish sequential awaits from concurrent starts; use whenAll/Promise.all/gather and race patterns.

Propagate errors; cancel with tokens and scopes; time out; clean up with finally and async disposal; use async iterators/generators.

Understand async context propagation, synchronization contexts, re-entrancy; avoid blocking calls, sync-over-async deadlocks, fire-and-forget hazards and unawaited tasks; design async APIs.

Plain-Language Explanation

async/await lets you write asynchronous code that *reads* like ordinary step-by-step code. When a function reaches await on something not finished yet, it pauses, gives its thread back to the runtime to do other work, and resumes later exactly where it left off, with all its local variables intact. Nothing about await creates threads or makes CPU work faster — it just stops your code from wasting a thread while it waits.

Precise Technical Explanation

Why async/await exists Callbacks and promise chains express asynchrony but scatter logic and error handling. async/await (C# 5 in 2012, then JavaScript, Python, Rust, Kotlin's suspend, Swift, C++20 coroutines) restores sequential structure, try/catch, loops and local variables.

An async function returns immediately with a future/task/promise/coroutine representing its eventual result; its body runs (at least partly) later.

Awaiting a promise/future/task await x checks whether x is complete; if so, it continues with the value (or throws the error); otherwise it registers the rest of the function as a continuation and returns control to the caller/event loop.

Suspension and resumption At a suspension point the function's state (locals, position) is saved in a heap object; when the awaited operation completes, a scheduler resumes it — possibly on a different thread (thread-pool runtimes) or the same one (event loops, UI synchronization contexts).

State-machine transformation concept Compilers rewrite an async function into a state machine: each await becomes a state; locals become fields; MoveNext()/poll()/resume() advances from one state to the next. This is why async functions allocate a frame and why stack traces need help.

Async return types by language async function f() async def f() coroutine object (wrap in a Task to run concurrently) async Task<T> F()

Task<T>/ValueTask<T> (async void only for event handlers) async fn f() -> T impl Future<Output = T> (lazy) suspend fun f(): T

T (suspension is implicit in the calling convention) func f() async -> T T (called with await) coroutine returning a user type whatever the promise type defines Awaiting multiple tasks

Start them first, then await all: await Promise.all(ps), await Task.WhenAll(ts), await asyncio.gather(*cs), or an asyncio.TaskGroup.

Sequential awaits versus concurrent starts a = await f(); b = await g() runs f then g (latency = sum). pa = f(); pb = g(); a = await pa; b = await pb (JavaScript, C# tasks) or gather/create_task (Python) runs them concurrently (latency = max). In Python merely *calling* a coroutine function does not start it — it must be awaited or wrapped in a task.

whenAll/Promise.all/gather equivalents See Module 9's combinator table; asyncio.gather returns results in input order.

Race and first-completion patterns Promise.race/any, Task.WhenAny, asyncio.wait(..., return_when=FIRST_COMPLETED), asyncio.as_completed. Always cancel or await the losers to avoid leaks and unobserved errors.

Exceptions thrown in an async function reject its future and are rethrown at the await; try/catch around await works as in synchronous code.

Cancellation tokens and cancellation scopes.NET CancellationToken, JavaScript AbortSignal, Python Task.cancel() (raises CancelledError at the next await), Kotlin Job.cancel, structured scopes (TaskGroup, coroutineScope) that cancel children (Module 13). Cancellation is cooperative everywhere.

asyncio.timeout(s) (Python 3.11+) / asyncio.wait_for, CancellationTokenSource(timeout)/Task.WaitAsync, AbortSignal.timeout(ms), Kotlin withTimeout.

finally and cleanup finally blocks run on success, error and cancellation; in Python a cancelled task sees CancelledError raised at its await, and finally runs — cleanup must not swallow the cancellation.

Async resource disposal await using / IAsyncDisposable (C#), async with (Python), await using (JavaScript explicit resource management, newer engines), Kotlin use within coroutines.

Async iterators and generators Produce a sequence asynchronously: async def … yield + async for (Python), async function* + for await (JavaScript), IAsyncEnumerable<T> + await foreach (C#), Kotlin Flow.

Async context and context propagation Logical "thread-local" values that follow an async flow across suspensions: Python contextvars (each task copies the context),.NET AsyncLocal<T>, Node AsyncLocalStorage, Kotlin CoroutineContext. Used for request IDs, tracing and security contexts — and a source of leaks if mutable objects are shared (Module 25).

In .NET UI apps and old ASP.NET, await captures the SynchronizationContext and resumes on the UI thread; ConfigureAwait(false) resumes on the thread pool instead (library code). Console and ASP.NET Core apps have no such context.

While an async method is suspended, other code — including another call to the same method — can run and change shared state. State checked before an await may be stale after it (an async check-then-act).

Avoiding blocking calls in async code time.sleep, synchronous file/network I/O, CPU-heavy loops and .Result/.Wait() inside async code block the thread the whole runtime depends on. Use async equivalents or offload (asyncio.to_thread, Task.Run, worker threads).

Blocking on an async method (task.Result) from a thread that the method needs in order to resume — a UI thread with a synchronization context, or a single-threaded event loop — deadlocks: the blocked thread waits for the continuation, which waits for the blocked thread.

Starting async work without awaiting it: errors are unobserved, the work may outlive its resources (a request scope, a database connection), and shutdown cannot wait for it. If truly needed, hand it to a supervised background service.

In Python, a coroutine that is never awaited never runs and produces "RuntimeWarning: coroutine … was never awaited" (shown below); tasks created with create_task and not referenced can even be garbage-collected mid-flight (keep references).

Structured error handling Prefer scopes that wait for all children and propagate their errors together (TaskGroup → ExceptionGroup, except*), Module 13.

Async API design principles Async all the way down; accept a cancellation token/signal; never block internally; return Task/Promise (not async void); avoid exposing both sync and async versions unless the sync one is genuinely synchronous; document thread/context of callbacks.

Why It Matters in Real Systems

Async/await is how modern servers handle tens of thousands of concurrent requests per process and how UIs stay responsive. Most async production bugs are blocking calls on the loop, unobserved failures from fire-and-forget, and missing cancellation.

Key Terms

Function returning a future of its eventual result Suspend until a future settles, then resume Where an async function may pause Compiled form of an async function

Cooperative cancellation signal passed to async APIs Blocking on async work — deadlock-prone Starting async work without awaiting it

Values that flow with a logical async operation

Syntax and API Overview

The specification's asyncio examples (CPython 3.13.1):

Both are coroutines scheduled cooperatively on one event-loop thread: asyncio.sleep registers a timer and suspends the coroutine — it does not block the thread — so both waits overlap and the program takes about 0.2 s, not 0.3 s. gather returns results in the order of its *arguments* (['A', 'B']), not completion order (B finished first). This is concurrent I/O-style scheduling, not CPU parallelism.

The specification's C# example (.NET 10):

Task.Delay uses a timer, not a sleeping thread; await suspends Main while the delay is incomplete; nothing here runs in parallel — async alone never makes CPU-heavy calculations parallel.

The specification's JavaScript Promise.all example (Node.js 24):

The result array is in input order (["first", "second"]) even though the second promise fulfilled first; host timers drive completion; nothing here is CPU-parallel.

import asyncio

async def task(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    results = await asyncio.gather(
        task("A", 0.2),
        task("B", 0.1),
    )
    print(results)

asyncio.run(main())
['A done', 'B done']
import asyncio

async def fetch_label(label, delay):
    await asyncio.sleep(delay)
    return label

async def main():
    tasks = [
        asyncio.create_task(fetch_label("A", 0.2)),
        asyncio.create_task(fetch_label("B", 0.1)),
    ]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())
['A', 'B']
using System;
using System.Threading.Tasks;

class Program
{
    static async Task<int> CalculateAsync()
    {
        await Task.Delay(100);
        return 42;
    }

    static async Task Main()
    {
        int value = await CalculateAsync();
        Console.WriteLine(value);
    }
}
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

async function main() {
  const results = await Promise.all([
    delay(100).then(() => "first"),
    delay(50).then(() => "second")
  ]);
  console.log(results);
}

main();
[ 'first', 'second' ]

Complete Code Example

The specification's fetchTwo, run against a local HTTP server started in the same Node.js 24 process (no external network), comparing sequential and concurrent starts and showing status checks and cancellation:

Python 3.13.1: sequential versus concurrent, TaskGroup, asyncio.timeout, cancellation with finally, async generators, context variables, the unawaited-coroutine warning and a blocking call starving the loop:

C# async patterns — WhenAll, WhenAny with cancellation of the loser, CancellationToken, async streams and IAsyncDisposable (.NET 10):

const http = require("node:http");

async function fetchTwo(urlA, urlB) {
  const [responseA, responseB] = await Promise.all([
    fetch(urlA),
    fetch(urlB)
  ]);

  return {
    a: await responseA.text(),
    b: await responseB.text()
  };
}

async function fetchChecked(url, signal) {                 // production-style: status check + cancellation
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error(`${url} -> HTTP ${res.status}`);
  return res.text();
}

const server = http.createServer((req, res) => {
  const ms = Number(new URL(req.url, "http://x").searchParams.get("ms") ?? 0);
  setTimeout(() => {
    if (req.url.startsWith("/missing")) { res.statusCode = 404; return res.end("nope"); }
    res.end(`body of ${req.url}`);
  }, ms);
});

server.listen(0, "127.0.0.1", async () => {
  const base = `http://127.0.0.1:${server.address().port}`;
  const ms = (t0) => Math.round((performance.now() - t0) / 100) * 100;

  let t0 = performance.now();
  const both = await fetchTwo(`${base}/a?ms=300`, `${base}/b?ms=300`);
  console.log("concurrent starts:", both, `~${ms(t0)} ms`);

  t0 = performance.now();
  const a = await (await fetch(`${base}/a?ms=300`)).text();

…
concurrent starts: { a: 'body of /a?ms=300', b: 'body of /b?ms=300' } ~400 ms
sequential awaits: { a: 'body of /a?ms=300', b: 'body of /b?ms=300' } ~600 ms
status check: http://127.0.0.1:61591/missing?ms=10 -> HTTP 404
timeout: TimeoutError
import asyncio
import contextvars
import time
import warnings

warnings.simplefilter("always")
request_id = contextvars.ContextVar("request_id", default="-")


async def io(name, seconds):
    await asyncio.sleep(seconds)
    return f"{name}@{request_id.get()}"


async def handler(rid):
    request_id.set(rid)                                   # each task has its own context copy
    return await io("db", 0.05)


async def ticker(n):
    for i in range(n):
        await asyncio.sleep(0.01)
        yield i                                           # async generator


async def worker(log):
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        log.append("worker: cancelled at its await")
        raise                                             # never swallow cancellation
    finally:
        log.append("worker: finally ran (cleanup)")


async def main():
    t0 = time.perf_counter()
    await io("a", 0.2); await io("b", 0.2)
    seq = time.perf_counter() - t0
    t0 = time.perf_counter()
    await asyncio.gather(io("a", 0.2), io("b", 0.2))
    print(f"sequential awaits ~{round(seq, 1)} s, concurrent ~{round(time.perf_counter() - t0, 1)} s")

    async 
…
sequential awaits ~0.4 s, concurrent ~0.2 s
TaskGroup results: db@req-1 db@req-2 | context outside tasks: -
asyncio.timeout: TimeoutError
cancellation: ['worker: cancelled at its await', 'worker: finally ran (cleanup)', 'main: awaited the cancelled task']
async generator: [0, 1, 2]
heartbeat asked for 10 ms sleeps; the first took ~300 ms because time.sleep blocked the loop
m10_py_patterns.py:67: RuntimeWarning: coroutine 'io' was never awaited
  io("never", 0)                                        # coroutine created but never awaited
RuntimeWarning: Enable tracemalloc to get the object allocation traceback
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

static async Task<string> Work(string name, int ms, CancellationToken ct = default)
{
    await Task.Delay(ms, ct);                    // honours the token
    return name;
}

static async IAsyncEnumerable<int> Numbers([EnumeratorCancellation] CancellationToken ct = default)
{
    for (int i = 1; i <= 3; i++) { await Task.Delay(10, ct); yield return i; }
}

var sw = Stopwatch.StartNew();
var results = await Task.WhenAll(Work("a", 200), Work("b", 200));
Console.WriteLine($"WhenAll: [{string.Join(", ", results)}] in ~{Math.Round(sw.ElapsedMilliseconds / 100.0) * 100} ms");

using (var cts = new CancellationTokenSource())
{
    var fast = Work("fast", 50, cts.Token);
    var slow = Work("slow", 2000, cts.Token);
    var winner = await Task.WhenAny(fast, slow);
    cts.Cancel();                                // cancel the loser
    try { await slow; } catch (OperationCanceledException) { Console.WriteLine($"WhenAny: {await winner} won; slow task cancelled"); }
}

using (var timeout = new CancellationTokenSource(TimeSpan
…
WhenAll: [a, b] in ~200 ms
WhenAny: fast won; slow task cancelled
timeout via CancellationTokenSource(30 ms): TaskCanceledException
async stream: [1, 2, 3]
using the connection
DisposeAsync: connection closed
await WhenAll rethrew the first error: x failed

Line-by-Line Explanation

JS fetch: fetchTwo *initiates* both requests before awaiting Promise.all, so the server's two 300 ms delays overlap (the measured time is a little above 300 ms because the very first requests also open new connections); the sequential version waits for each in turn. The actual degree of concurrency depends on the host (connection limits, server behavior). fetch resolves even for HTTP errors — check res.ok. AbortSignal.timeout aborts the request itself.

Python: gather overlaps waits; TaskGroup waits for all children and cancels the rest if one fails; contextvars values set inside a task stay in that task's context; asyncio.timeout cancels the inner await and raises TimeoutError; a cancelled task sees CancelledError at its await, runs finally, and must re-raise; the never-awaited coroutine triggers a RuntimeWarning (printed to stderr, shown after stdout); time.sleep inside async code freezes the whole loop — the heartbeat's 10 ms sleep took ~300 ms.

C#: WhenAll overlaps; WhenAny returns the first finished task and the loser is cancelled through a shared token; CancellationTokenSource(timeout) implements a timeout; IAsyncEnumerable + await foreach streams values; await using calls DisposeAsync; await Task.WhenAll rethrows the first exception.

Output

Shown under each program. Timings are rounded and come from one real run; everything else is deterministic.

Interleaving or State Trace

Python gather(io("a", .2), io("b", .2)) on one thread:

runs a until await sleep suspended (timer 0.2) runs b until await sleep timers fire; resumes a, then b

Correctness and Safety Analysis

Async code removes preemption *between* awaits on single-threaded loops — code between two awaits runs atomically with respect to other tasks on that loop — but not across awaits (re-entrancy) and not at all on multi-threaded runtimes (.NET, Kotlin Dispatchers.Default, Tokio's multi-thread scheduler), where shared state still needs locks or async-aware primitives.

Common Mistakes and Misconceptions

"async makes code run in parallel/on another thread." Sequential awaits where independent operations could run concurrently.

Calling an async function without await (Python: it never runs; JS/C#: errors unobserved).

Blocking calls (time.sleep, requests.get, .Result) inside async code.

Swallowing CancelledError/OperationCanceledException.

async void methods other than event handlers.

Failure Modes and Debugging Steps

Everything stalls periodically: a blocking call on the loop — enable asyncio debug mode (warns about slow callbacks), measure event-loop lag (Module 11).

Deadlock in a UI app: .Result on the UI thread — make the call chain async or use ConfigureAwait(false) in libraries.

Tasks "disappear": unreferenced create_task results or unawaited coroutines — keep references, use TaskGroup.

Performance and Scalability Implications

Async avoids a thread per waiting operation (memory, context switches), so it scales to very high concurrency for I/O-bound work. Each await that actually suspends costs an allocation and a scheduler round-trip (hundreds of nanoseconds to microseconds); for CPU-bound or very fine-grained work, async adds overhead without benefit.

Security Considerations

Unawaited work can outlive authorization checks or request contexts; mutable objects in async context can leak data across requests; unbounded gather over user input enables resource exhaustion — use semaphores to cap concurrency (Module 14).

Platform and Runtime Caveats

asyncio.TaskGroup and asyncio.timeout need Python 3.11+; asyncio.TimeoutError is an alias of the built-in TimeoutError since 3.11 (separate class in 3.10 and earlier).

fetch and AbortSignal.timeout are built into Node 18+.

IAsyncDisposable/await using need .NET Core 3.0+/C# 8; ASP.NET Core and console apps have no SynchronizationContext; WPF/WinForms/old ASP.NET do.

Rust async functions do nothing until polled by an executor (Tokio, async-std, smol) — the standard library ships no executor.

Real-World Applications

Web servers (ASP.NET Core, FastAPI, Node), API gateways, chat servers, crawlers, mobile/desktop UIs, database drivers.

Best Practices

Go async all the way; never block inside async code.

Start independent operations before awaiting them; use gather/WhenAll/Promise.all or task groups.

Pass cancellation tokens/signals everywhere; put deadlines on external calls.

Re-check state after every await.

Avoid fire-and-forget; supervise background work.

Exercises

E1 · Beginner. Why does await asyncio.gather(io("a", .2), io("b", .2)) take ~0.2 s and not 0.4 s?

Answer: Both coroutines start and suspend on their timers almost immediately; the loop waits for both timers at once, so the waits overlap.

E2 · Intermediate. A C# WinForms button handler calls var s = LoadAsync().Result; and freezes. Explain and fix.

Answer: .Result blocks the UI thread; LoadAsync's await captured the UI SynchronizationContext and needs that same thread to resume — a sync-over-async deadlock. Make the handler async void (event handler) and await LoadAsync(), and use ConfigureAwait(false) inside library code.

E3 · Advanced challenge. Write a Python helper bounded_gather(coros, limit) that runs at most limit coroutines at a time and returns results in input order.

Answer: sem = asyncio.Semaphore(limit); async def run(c): async with sem: return await c; return await asyncio.gather(*(run(c) for c in coros)) — gather preserves input order while the semaphore caps concurrency.

Quiz

? What does asyncio.gather(f(), g()) return if g finishes first?
+ [result of f, result of g] :: gather preserves argument order.
- [result of g, result of f] :: Completion order is not used.
- Only g's result :: It waits for both.
- It depends on the thread scheduler :: asyncio runs on one thread; the order is defined.

? Which line blocks the asyncio event loop?
+ time.sleep(1) inside an async def :: It blocks the thread the loop runs on.
- await asyncio.sleep(1) :: It suspends only this coroutine.
- await asyncio.to_thread(time.sleep, 1) :: It runs the blocking call on a worker thread.
- asyncio.create_task(coro()) :: Creating a task does not block.

? Which statements are true? (select all that apply)
+ await does not by itself create a thread. :: It suspends and resumes on the runtime's scheduler.
+ Code between two awaits on a single-threaded event loop is not interleaved with other tasks on that loop. :: Cooperative scheduling.
- Cancellation stops the awaited operation instantly everywhere. :: It is cooperative.
+ State checked before an await may be stale after it. :: Other tasks ran in between.

Summary

Async/await compiles sequential-looking code into resumable state machines that suspend at await instead of blocking threads. Start independent work before awaiting; combine with gather/WhenAll/Promise.all; propagate errors and cancellation; clean up in finally/async disposal; never block the loop; never forget a task. Async gives concurrency for I/O, not parallelism for CPU.

Further Reading

Python docs: "Coroutines and Tasks", asyncio.TaskGroup, contextvars.

Stephen Toub, "How Async/Await Really Works in C#" (.NET blog, 2023); Stephen Cleary, "Don't Block on Async Code".

MDN: "async function", "Using the Fetch API".

Nathaniel J. Smith, "Notes on structured concurrency" (2018).