Inside async/await โ Event Loops, Coroutines, and Runtime Mechanics
Behind async/await, a runtime suspends a function at each await and resumes it when the awaited I/O completes, without blocking the thread. This article compares how JavaScript's event loop, Python's asyncio, and Rust's poll-based Futures do this.
What async/await Hides
Many languages, including JavaScript, Python, Rust, and C#, use async/await as the syntax for writing asynchronous code. The syntax looks similar, but the machinery behind it differs widely between languages and runtimes.
async function fetchData() {
const response = await fetch("/api/data");
const json = await response.json();
return json;
}When this code runs, the runtime does the following:
- At
await, it suspends the function and takes back control - It watches for I/O completion without blocking the thread
- When the I/O completes, it resumes the function where it left off
- Meanwhile, it schedules and runs other async operations
JavaScript does this with an event loop, Python with asyncio, and Rust with poll-based Futures.
Show transcript
async and await make asynchronous code read almost like synchronous code. But behind the keywords, each language runs a very different machine. We'll compare the three models in JavaScript, Python, and Rust.
First, synchronous code waits for an operation to finish. Asynchronous code starts it, moves on, and is notified on completion. Concurrency means execution periods overlap. It can happen even on a single core. Parallelism means physically running at the same time, which requires multiple cores. async and await primarily give you concurrency, not necessarily parallelism.
JavaScript runs on a single thread, so the event loop becomes the center of execution. The call stack holds current work, while Web APIs or Node APIs handle timers, network, and file I/O. Promise continuations go to the microtask queue; setTimeout, I/O callbacks, and UI events go to the task queue.
In one turn, the loop runs one task first. Microtasks do not interrupt that running task. When the stack becomes empty, the microtask checkpoint runs Promise jobs and other microtasks until the queue is empty. That is why the synchronous logs 1 and 2 come first, then Promise logs 3, and setTimeout logs 4 last.
In JavaScript, async and await are syntactic sugar over Promises. At await, the right-hand side is evaluated, and the rest of the function is registered as a then callback on the Promise. When that Promise resolves, the continuation enters the microtask queue. The function suspends, so while I/O is pending, the event loop can keep processing other tasks.
Since V8 version 7.2, awaiting a value that is already a Promise skips an extra wrapper Promise. This cuts microtask ticks from three to one. Python's async and await, by contrast, arrived in Python 3.5 and are built on coroutines.
Calling an async def function does not run its body. It returns a coroutine object. To execute it, you need an event loop, commonly started explicitly with asyncio.run. await uses the __await__ protocol: the loop advances the coroutine with send, and suspension returns control to the loop. In CPython, the coroutine frame, including locals and execution position, is kept on the heap.
The asyncio loop combines I/O multiplexing, a coroutine scheduler, and Task management. On Unix it uses selector backends such as epoll and kqueue; on Windows the default uses IOCP. asyncio runs on one thread, so the GIL matters little; it suits I/O-bound work, not CPU-bound work. Calling a synchronous library like requests from async code blocks the loop, so async alternatives such as aiohttp are needed.
In Rust, the async runtime is not built into the language. You choose one, such as tokio. A Future is polled: Ready means it is complete, while Pending means, ask me again later. JavaScript and Python are push-based, calling continuations on completion; in pull-based Rust, the runtime polls the Future. The compiler turns an async function into a state machine, and each await becomes a state transition.
Before a Future returns Pending, it stores a Waker with the runtime's driver. The driver registers readiness with mechanisms such as epoll, kqueue, or IOCP. When I/O becomes ready, the Waker is called, and the runtime polls that Future again. A Future is a state-machine value, so per-await heap allocation isn't mandatory; boxing, type erasure, or spawning allocates.
State lives in closures on the V8 heap in JavaScript, and in coroutine frames on the heap in Python. Rust polls a concrete state machine, and dropping a Future naturally cancels it. Many languages also keep the function coloring problem: sync functions cannot directly call async ones. A common pitfall is putting blocking work inside async code, which blocks the runtime thread.
The shared principle of async and await is simple: do not leave threads idle while waiting for I/O. The article goes deeper with an interactive event-loop demo, a coroutine state diagram, and a cross-language comparison.
Prerequisites: Sync, Async, Concurrent, Parallel
These four terms are often confused, so here is what each one means.
| Term | Meaning |
|---|---|
| Synchronous | Blocks and waits until the operation completes |
| Asynchronous | Starts the operation and moves on; gets notified upon completion |
| Concurrent | Multiple tasks have overlapping execution periods (possible on a single core) |
| Parallel | Multiple tasks physically execute simultaneously (requires multiple cores) |
async/await provides concurrency; it does not necessarily run anything in parallel. JavaScript's async/await, for example, makes progress on several operations on a single thread.
JavaScript: The Event Loop Model
Event Loop Structure
JavaScript code runs on a single thread. The event loop is what lets that one thread handle many asynchronous operations.
Four components are involved in how the event loop works:
- Call stack โ Stack of currently executing functions
- Web APIs / Node APIs โ Environment for async operations (timers, network, file I/O)
- Microtask queue โ Promise
.then/.catch/.finallycallbacks,queueMicrotask() - Macrotask queue (task queue) โ
setTimeout,setInterval, I/O callbacks, UI events
Event Loop Execution Order
One turn of the browser event loop proceeds in this order:
- Dequeue and execute one task (the initial script, a timer callback, an input event, etc.)
- When that task's call stack becomes empty, run the microtask checkpoint: drain Promise jobs /
queueMicrotask()callbacks until the queue is empty - Update rendering if needed
- Return to step 1 and pick the next task
Because of this order, microtasks do not interrupt the running task, but the microtask queue is drained before the next task starts. In the code below, the Promise callback (a microtask) runs before the setTimeout callback (a task).
console.log("1"); // synchronous
setTimeout(() => {
console.log("4"); // macrotask
}, 0);
Promise.resolve().then(() => {
console.log("3"); // microtask
});
console.log("2"); // synchronous
// Output order: 1, 2, 3, 4JavaScript Event Loop
Step through: console.log('1'), setTimeout(cb,0), Promise.resolve().then(cb2), console.log('2')
async/await Transformation
async/await is often described as syntactic sugar over Promises. In practice the engine saves the function's execution state and suspends and resumes it, but conceptually it behaves like the following Promise chain:
// Code written with async/await
async function example() {
console.log("A");
const result = await fetchData();
console.log("B");
return result;
}
// Conceptual internal transformation
function example() {
console.log("A");
return fetchData().then((result) => {
console.log("B");
return result;
});
}When an await expression is reached, the engine does the following:
- Evaluate the expression after
await; if the value is not a Promise, wrap it in a resolved Promise - Register the rest of the function to run when that Promise resolves (on resolution, the resume step enters the microtask queue)
- Suspend the function and return control to the caller
As a result, the event loop can keep processing other tasks while the I/O is pending.
V8 Engine Optimizations
V8 (the JavaScript engine in Chrome and Node.js) made await faster in v7.2 (V8 blog post). When the value passed to await is already a native Promise, promiseResolve now uses that Promise as is, so V8 no longer creates an extra wrapper Promise. This cut the number of microtask ticks for one await from a minimum of 3 to 1. A separate change to the specification also lets engines skip the internal throwaway Promise in most cases.
Python: asyncio and Coroutines
Python Coroutines
Python's async/await was introduced in Python 3.5 (PEP 492) and is based on coroutines. A coroutine is a function whose execution can be suspended and resumed; Python builds coroutines on an extension of the generator mechanism.
import asyncio
async def fetch_data():
print("fetch start")
await asyncio.sleep(1) # Simulates I/O operation
print("fetch done")
return 42Calling a function defined with async def doesn't run its body; it returns a coroutine object.
coro = fetch_data() # Function body has NOT run yet!
print(type(coro)) # <class 'coroutine'>An event loop (runtime) is needed to actually execute the coroutine.
asyncio Event Loop
Python's asyncio module provides an event loop implementation. In JavaScript the runtime already has an event loop running; in Python you have to create and run one explicitly, for example with asyncio.run():
import asyncio
async def main():
result = await fetch_data()
print(result)
# Create and run the event loop
asyncio.run(main())The asyncio event loop relies on three mechanisms:
- I/O multiplexing: On Unix,
SelectorEventLoopuses selector backends such asepollandkqueue; on Windows, the defaultProactorEventLoopuses IOCP rather than the selector API - A queue of ready callbacks: The event loop puts callbacks that can run immediately into a queue and runs them one at a time
- Tasks: An
asyncio.Taskwraps a coroutine and puts a callback that advances the coroutine by one step into this queue. When the Future the coroutine is waiting on completes, the next step is queued again
Internal Implementation of Python Coroutines
Python's native coroutines (async def) are a distinct type from generators, but in CPython's implementation they share the same suspension machinery (YIELD_VALUE opcode) as generators. await uses the __await__ protocol and conceptually works like this:
# Conceptual internal behavior
async def example():
result = await some_coroutine()
print(result)
# Internally (simplified):
# 1. Call some_coroutine() to get a coroutine object
# 2. Advance execution with .send(None)
# 3. When the coroutine suspends, return control to the event loop
# 4. After I/O completion, pass the result with .send(result) to resumeIn CPython's implementation, the coroutine frame (local variables, execution position, etc.) is kept on the heap, so suspending and resuming does not require unwinding or restoring the stack.
asyncio Constraints and the GIL
Python's asyncio has the following constraints:
- GIL (Global Interpreter Lock): In CPython's default build, only one thread executes Python bytecode at a time. asyncio runs on a single thread, so the GIL barely affects it
- Not suited for CPU-bound work: async/await helps with work that spends much of its time waiting on I/O. For CPU-bound work, use
multiprocessingorconcurrent.futures.ProcessPoolExecutor - Library support required: Calling a synchronous library (e.g.,
requests) directly from async code blocks the event loop, so you need an async alternative (e.g.,aiohttp)
Rust: Zero-Cost Futures and Polling
Rust's Async Model
Rust's async/await differs from JavaScript and Python in three ways:
- The runtime is not built into the language โ you choose a third-party runtime such as tokio or smol
- Futures are poll-based โ instead of a callback announcing completion, the runtime asks the Future whether it has finished
- Zero-cost abstraction โ async/await adds almost no overhead compared with writing the state machine by hand
The Future Trait
Rust's Future is defined by this trait:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // Complete: return the result
Pending, // Not yet complete: poll me again later
}JavaScript's Promises and Python's coroutines are push-based: the side that completes calls a callback to announce it. Rust is pull-based (polling): the runtime asks the Future whether it has finished.
State Machine Transformation
Rust's compiler transforms async fn into a state machine.
async fn example() -> i32 {
let a = fetch_a().await; // Suspension point 1
let b = fetch_b().await; // Suspension point 2
a + b
}
// State machine generated by the compiler (conceptual):
enum ExampleFuture {
Start,
WaitingA { future_a: FetchAFuture },
WaitingB { a: i32, future_b: FetchBFuture },
Done,
}
impl Future for ExampleFuture {
type Output = i32;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<i32> {
loop {
match self.state {
Start => {
self.future_a = fetch_a();
self.state = WaitingA;
}
WaitingA => {
match self.future_a.poll(cx) {
Poll::Ready(a) => {
self.a = a;
self.future_b = fetch_b();
self.state = WaitingB;
}
Poll::Pending => return Poll::Pending,
}
}
WaitingB => {
match self.future_b.poll(cx) {
Poll::Ready(b) => {
return Poll::Ready(self.a + b);
}
Poll::Pending => return Poll::Pending,
}
}
Done => panic!("polled after completion"),
}
}
}
}In this transformation, each await corresponds to one state transition. The resulting Future has the following properties:
- No mandatory per-
awaitheap allocation: The Future is a concrete state-machine value that can stay on the stack when awaited directly; it is heap-allocated when you box it, erase its type, or spawn it into a runtime task - No dynamic dispatch: Concrete types are determined at compile time
Waker and Runtime Cooperation
In the poll-based model, the runtime needs some way to learn when to poll a Future again. That mechanism is the Waker:
// Context contains a Waker
pub struct Context<'a> {
waker: &'a Waker,
}Before a Future returns Poll::Pending, it stores a clone of the Waker with the runtime's reactor/driver while that driver registers OS readiness interest (for example with epoll/kqueue/IOCP). When the OS reports readiness, the driver calls the Waker, telling the runtime to poll this Future again.
1. Runtime calls Future.poll()
2. Future initiates I/O internally; the runtime driver registers OS readiness and stores the Waker
3. Returns Poll::Pending
4. Runtime polls other Futures
5. I/O becomes ready -> the runtime driver wakes the task via the Waker
6. Runtime polls that Future again
7. Returns Poll::Ready(result)The tokio Runtime
tokio is the most widely used async runtime in Rust.
#[tokio::main]
async fn main() {
let result = fetch_data().await;
println!("{result}");
}tokio's main components are:
- Multi-threaded scheduler: A work-stealing algorithm distributes tasks across CPU cores
- I/O driver: Async I/O wrapping epoll/kqueue/IOCP
- Timer: Hierarchical timer wheel
- Single-threaded mode: With
#[tokio::main(flavor = "current_thread")], tokio runs as a single-threaded event loop
Cross-Language Comparison
Push vs Pull
| Model | Languages | Mechanism | Advantage |
|---|---|---|---|
| Push-based | JS, Python | Calls callback/continuation on completion | Simpler mechanism (no polling or Waker needed) |
| Pull-based | Rust | Runtime polls Futures | Zero-cost, natural cancellation |
One advantage of the pull-based model is how easy cancellation is. In Rust, dropping a Future means it is never polled again, so its work stops (a task spawned onto a runtime, however, keeps running even if you drop its JoinHandle). Push-based models often need an explicit way to signal cancellation, such as JavaScript's AbortController.
Coroutine State and Memory Allocation
| Language | Coroutine State Storage | Memory Allocation |
|---|---|---|
| JavaScript | Suspended function state (V8 heap) | GC-managed |
| Python | Coroutine frame (heap) | GC-managed |
| Rust | State machine (stack or Box) | Manually controllable |
| Go | Goroutine stack (contiguous, growable) | GC-managed + stack growth |
The Function Coloring Problem
Many languages draw a clear line between sync and async functions, which leads to the "function coloring problem":
- Inside a sync function you cannot
awaitan async function's result (to wait for it, you must block the thread or start an event loop, asasyncio.run()does) - An async function can call sync functions, but calling one that blocks for a long time stalls the event loop
# NG: Can't directly call async from sync
def sync_function():
result = await async_function() # SyntaxError!
# OK: Bridge with asyncio.run()
def sync_function():
result = asyncio.run(async_function())Go has no async/await and so does not have this problem. Any function can be started as a new goroutine with the go keyword, and while a goroutine waits on I/O, the runtime runs other goroutines.
Practical Pitfalls
JavaScript Pitfalls
// NG: Forgetting await returns a Promise object
async function bad() {
const data = fetch("/api"); // Missing await!
console.log(data); // Prints Promise object
}
// NG: Sequential when it could be concurrent
async function sequential() {
const a = await fetchA(); // Wait 1 second
const b = await fetchB(); // Wait 1 more second โ 2 seconds total
}
// OK: Use Promise.all to run them concurrently
async function parallel() {
const [a, b] = await Promise.all([fetchA(), fetchB()]); // 1 second total
}Python Pitfalls
# NG: Blocking I/O inside async code
async def bad():
import requests
response = requests.get("https://example.com") # Blocks the event loop!
# OK: Use async libraries
async def good():
import aiohttp
async with aiohttp.ClientSession() as session:
response = await session.get("https://example.com")
# Running independent tasks concurrently
async def gather_example():
results = await asyncio.gather(
fetch_a(),
fetch_b(),
fetch_c(),
)Rust Pitfalls
// NG: Blocking operation inside async fn
async fn bad() {
std::thread::sleep(Duration::from_secs(1)); // Blocks the runtime thread!
}
// OK: Use tokio's async version
async fn good() {
tokio::time::sleep(Duration::from_secs(1)).await;
}
// OK: Use spawn_blocking for CPU-bound work
async fn cpu_work() {
let result = tokio::task::spawn_blocking(|| {
heavy_computation()
}).await.unwrap();
}Summary
The async/await syntax looks similar across languages, but the implementations behind it differ widely:
- JavaScript: Suspends and resumes functions using Promises on a single-threaded event loop. The priority of microtasks over macrotasks determines execution order
- Python: The asyncio event loop schedules coroutines (an extension of generators). Under the GIL, it makes progress on I/O waits concurrently
- Rust: Compiles async fn into Future state machines. The runtime drives execution by polling, and no heap allocation is required per
await
All three share one design goal: don't leave a thread idle while waiting on I/O. async/await is syntax that lets you write this kind of asynchronous code in a form close to synchronous code.