Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Async/Await

Overview

Async/await is a programming model for writing asynchronous code that looks synchronous. Instead of callbacks or manual thread management, async functions can suspend execution and resume later, allowing other tasks to run during I/O waits. It’s the dominant model in JavaScript/TypeScript, Python, Rust, and C#, and is increasingly used in systems programming.

The Problem Async Solves

Synchronous Blocking

sequenceDiagram
    participant T as Thread
    participant DB as Database
    participant API as External API

    T->>DB: Query (blocks 50ms)
    Note over T: Thread IDLE, wasting CPU
    DB-->>T: Result
    T->>API: HTTP request (blocks 200ms)
    Note over T: Thread IDLE, wasting CPU
    API-->>T: Response
    T->>T: Process (5ms)
    Note over T: Total: 255ms, CPU used: 5ms

Async Non-Blocking

sequenceDiagram
    participant E as Event Loop (1 thread)
    participant T1 as Task 1
    participant T2 as Task 2
    participant DB as Database

    E->>T1: Start Task 1
    T1->>DB: Query (async, suspend)
    E->>T2: Start Task 2 (while T1 waits)
    T2->>DB: Query (async, suspend)
    DB-->>T1: Result (resume)
    E->>T1: Continue Task 1
    DB-->>T2: Result (resume)
    E->>T2: Continue Task 2
    Note over E: Both tasks done in ~200ms total

How It Works

The Event Loop

graph TD
    EL[Event Loop] --> CHECK{Ready queue empty?}
    CHECK -->|No| RUN[Run next ready task]
    RUN --> AWAIT{Task awaits?}
    AWAIT -->|Yes| REGISTER[Register I/O interest]
    REGISTER --> CHECK
    AWAIT -->|No| CHECK
    CHECK -->|Yes| POLL[Poll for I/O events]
    POLL --> WAKE[Wake completed tasks]
    WAKE --> CHECK

The event loop repeatedly:

  1. Runs ready tasks until they await.
  2. Polls for I/O completion.
  3. Wakes tasks whose I/O is complete.

Coroutine State Machine

graph TD
    START[async fn starts] --> EXEC1[Execute until first await]
    EXEC1 --> SUSPEND[Suspend: save state to stack/frame]
    SUSPEND --> REGISTER[Register I/O with reactor]
    REGISTER --> OTHER[Other tasks run]
    OTHER --> READY[I/O ready]
    READY --> RESUME[Resume: restore state]
    RESUME --> EXEC2[Continue execution]
    EXEC2 --> AWAIT2{Another await?}
    AWAIT2 -->|Yes| SUSPEND
    AWAIT2 -->|No| RETURN[Return result]

Each await point is a potential suspension. The compiler generates a state machine that saves/restores local variables.

JavaScript/TypeScript

// Async function returns a Promise
async function fetchUserData(userId) {
    const response = await fetch(`/api/users/${userId}`);  // Suspend here
    const user = await response.json();                     // Suspend here
    return user;
}

// Concurrent execution with Promise.all
async function fetchAllUsers(userIds) {
    const promises = userIds.map(id => fetchUserData(id));  // All start
    const users = await Promise.all(promises);              // Wait for all
    return users;
}

// Error handling
async function safeFetch(url) {
    try {
        const response = await fetch(url);
        return await response.json();
    } catch (error) {
        console.error('Fetch failed:', error);
        return null;
    }
}

Event Loop in JavaScript

graph TD
    subgraph EventLoop[Node.js Event Loop]
        TIMERS[Timers: setTimeout, setInterval]
        POLL[Poll: I/O callbacks]
        CHECK[Check: setImmediate]
        CLOSE[Close callbacks]
    end

    CALL[Call Stack] -->|execute| SYNC[Synchronous code]
    SYNC -->|await/timeout| QUEUE[Callback Queue]
    QUEUE -->|event loop| CALL

JavaScript has a single-threaded event loop. Async operations (I/O, timers) are handled by the runtime (libuv), and callbacks are queued for execution.

Python

import asyncio
import aiohttp

# Async function
async def fetch_url(session, url):
    async with session.get(url) as response:  # Suspend on I/O
        return await response.text()            # Suspend on read

# Run multiple concurrently
async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        return await asyncio.gather(*tasks)  # Run all concurrently

# Entry point
async def main():
    urls = ["https://api.example.com/1", "https://api.example.com/2"]
    results = await fetch_all(urls)
    for url, result in zip(urls, results):
        print(f"{url}: {len(result)} bytes")

asyncio.run(main())

Python Event Loop

graph TD
    EL[asyncio Event Loop] --> TASKS[Ready Tasks]
    TASKS --> T1[Task 1: await I/O]
    TASKS --> T2[Task 2: await I/O]
    T1 --> SELECTOR[Selector: epoll/kqueue]
    T2 --> SELECTOR
    SELECTOR -->|I/O ready| TASKS

Python’s asyncio uses a single-threaded event loop with select/epoll/kqueue for I/O multiplexing. Tasks are coroutines scheduled on the event loop.

Python Threading vs Asyncio

Featurethreadingasyncio
ConcurrencyPreemptive (OS-level)Cooperative (task-level)
GILYes (limits CPU parallelism)Yes (but I/O releases GIL)
Best forCPU-bound (with multiprocessing)I/O-bound
Overhead~1MB per thread~KB per task
SynchronizationLocks, semaphoresNot needed (single thread)

Rust

use tokio;

// Async function
async fn fetch_url(url: &str) -> Result<String, reqwest::Error> {
    let response = reqwest::get(url).await?;  // Suspend
    let body = response.text().await?;         // Suspend
    Ok(body)
}

// Concurrent execution
#[tokio::main]
async fn main() {
    let urls = vec!["https://api.example.com/1", "https://api.example.com/2"];

    // Spawn concurrent tasks
    let handles: Vec<_> = urls.into_iter()
        .map(|url| tokio::spawn(fetch_url(url)))
        .collect();

    // Await all results
    for handle in handles {
        match handle.await {
            Ok(Ok(body)) => println!("Got {} bytes", body.len()),
            Ok(Err(e)) => eprintln!("Error: {}", e),
            Err(e) => eprintln!("Task panicked: {}", e),
        }
    }
}

Rust Async Model

graph TD
    subgraph Tokio[Tokio Runtime]
        W1[Worker Thread 1] --> EXEC1[Execute Future 1]
        W2[Worker Thread 2] --> EXEC2[Execute Future 2]
        EXEC1 -->|Poll| READY{Ready?}
        EXEC2 -->|Poll| READY
        READY -->|No| WAKER[Register waker]
        READY -->|Yes| CONTINUE[Continue execution]
        WAKER -->|I/O ready| EXEC1
    end

Rust async uses a poll-based model. Futures are lazy — they only execute when polled. The runtime (tokio, async-std) polls futures on worker threads. When a future returns Poll::Pending, it registers a waker to be notified when ready.

Async vs Threads

graph TD
    CHOICE{Task type?} -->|I/O-bound| ASYNC[Async/Await]
    CHOICE -->|CPU-bound| THREADS[Threads]
    CHOICE -->|Mixed| BOTH[Async + thread pool for CPU work]

    ASYNC --> A1[Lightweight, many concurrent tasks]
    ASYNC --> A2[Low overhead per task]
    ASYNC --> A3[Good for network, file I/O]

    THREADS --> T1[OS-level parallelism]
    THREADS --> T2[No cooperative scheduling issues]
    THREADS --> T3[Good for computation]

When to Use What

ScenarioUse
Web server handling 10K connectionsAsync
Parallel matrix multiplicationThreads
API gateway proxying requestsAsync
Image processing pipelineThreads
Database connection poolAsync + pool
Real-time game engineThreads + async I/O

Common Pitfalls

Blocking the Event Loop

# BAD: Blocks the event loop
async def bad():
    time.sleep(1)  # Blocks entire event loop!
    data = requests.get(url)  # Blocking HTTP call!

# GOOD: Use async alternatives
async def good():
    await asyncio.sleep(1)  # Yields to event loop
    async with aiohttp.ClientSession() as session:
        data = await session.get(url)  # Non-blocking

Async All the Way

graph TD
    SYNC[Sync function] -->|Can't call| ASYNC[Async function directly]
    ASYNC -->|Solution| RUN[asyncio.run() or runtime.block_on()]
    ASYNC -->|Solution| RUN2[await from async context]

Once you go async, it “colours” your entire call stack. Sync code can’t directly call async code without a runtime.

Interview Questions

  1. Q: What is async/await and how does it differ from threading? A: Async/await uses cooperative multitasking on a single thread. Tasks voluntarily yield (await) at I/O points, allowing other tasks to run. Threading uses preemptive multitasking where the OS switches between threads. Async is lighter (KB per task vs MB per thread) but requires non-blocking I/O.

  2. Q: What is an event loop? A: An event loop is the core of async runtimes. It maintains a queue of ready tasks, runs them until they await, then polls for I/O completion. When I/O is ready, it resumes the corresponding tasks. Single-threaded event loops avoid synchronization overhead.

  3. Q: How does Rust’s async differ from JavaScript’s? A: Rust async is poll-based — futures are lazy and only execute when polled by the runtime. JavaScript async is promise-based — promises start executing immediately. Rust gives more control (choose your runtime) but is more complex. JavaScript’s runtime (V8/libuv) is built-in.

  4. Q: Why can’t you call blocking functions in an async context? A: Blocking functions (like synchronous I/O) prevent the event loop from running, stalling ALL concurrent tasks. The event loop is single-threaded, so a blocked thread means no other task can make progress. Use async alternatives or offload blocking work to a thread pool.

  5. Q: What is structured concurrency? A: Structured concurrency ensures that async tasks have well-defined lifetimes tied to their parent scope. When the parent scope ends, all child tasks are cancelled or joined. This prevents resource leaks and makes reasoning about async code easier. Python’s TaskGroups and Rust’s tokio::JoinSet implement this.

Common Mistakes

  • Blocking the event loop with synchronous I/O.
  • Not handling cancellation (tasks cancelled on scope exit).
  • Creating too many concurrent tasks (memory exhaustion).
  • Mixing async and sync code without proper bridging.
  • Assuming async is always faster — for CPU-bound work, threads are better.

Summary

Async/await provides a way to write non-blocking code that looks synchronous. The event loop manages task scheduling, suspending tasks at await points and resuming them when I/O is ready. It’s ideal for I/O-bound workloads with many concurrent connections. Key differences from threading: cooperative vs preemptive, single-threaded vs multi-threaded, lightweight tasks vs heavyweight threads.

Cross-References