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:
- Runs ready tasks until they await.
- Polls for I/O completion.
- 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
| Feature | threading | asyncio |
|---|---|---|
| Concurrency | Preemptive (OS-level) | Cooperative (task-level) |
| GIL | Yes (limits CPU parallelism) | Yes (but I/O releases GIL) |
| Best for | CPU-bound (with multiprocessing) | I/O-bound |
| Overhead | ~1MB per thread | ~KB per task |
| Synchronization | Locks, semaphores | Not 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
| Scenario | Use |
|---|---|
| Web server handling 10K connections | Async |
| Parallel matrix multiplication | Threads |
| API gateway proxying requests | Async |
| Image processing pipeline | Threads |
| Database connection pool | Async + pool |
| Real-time game engine | Threads + 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
-
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.
-
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.
-
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.
-
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.
-
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
- Futures — The underlying abstraction
- Coroutines — The implementation mechanism
- Thread Pools — Alternative for CPU-bound work
- Go Channels — Go’s concurrency model
- Concurrency Overview — Fundamental concepts
- LLM Batching
- Cloud Lambda