โ†All posts

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.

Asyncasync/awaitEvent LoopCoroutineJavaScriptPythonRust

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:

  1. At await, it suspends the function and takes back control
  2. It watches for I/O completion without blocking the thread
  3. When the I/O completes, it resumes the function where it left off
  4. Meanwhile, it schedules and runs other async operations

JavaScript does this with an event loop, Python with asyncio, and Rust with poll-based Futures.

Watch the video explainer4:36Synthesized narration ยท burned-in captions
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.

Concurrency ModelsMulti-threadingOS threads, preemptiveC#async/await โœ“Event LoopSingle-threaded, non-blockingJavaScript, Dartasync/await โœ“CoroutinesCooperative schedulingPython, Kotlin, Rustasync/await โœ“async/await syntax works across all three models

Prerequisites: Sync, Async, Concurrent, Parallel

These four terms are often confused, so here is what each one means.

TermMeaning
SynchronousBlocks and waits until the operation completes
AsynchronousStarts the operation and moves on; gets notified upon completion
ConcurrentMultiple tasks have overlapping execution periods (possible on a single core)
ParallelMultiple 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:

  1. Call stack โ€” Stack of currently executing functions
  2. Web APIs / Node APIs โ€” Environment for async operations (timers, network, file I/O)
  3. Microtask queue โ€” Promise .then / .catch / .finally callbacks, queueMicrotask()
  4. Macrotask queue (task queue) โ€” setTimeout, setInterval, I/O callbacks, UI events
Event Loop Cycle11. Run stack22. Microtasks33. Render if needed44. One macrotaskAll microtasks run before the next macrotask

Event Loop Execution Order

One turn of the browser event loop proceeds in this order:

  1. Dequeue and execute one task (the initial script, a timer callback, an input event, etc.)
  2. When that task's call stack becomes empty, run the microtask checkpoint: drain Promise jobs / queueMicrotask() callbacks until the queue is empty
  3. Update rendering if needed
  4. 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, 4

JavaScript Event Loop

Step through: console.log('1'), setTimeout(cb,0), Promise.resolve().then(cb2), console.log('2')

Call Stack
main()
Web APIs
(empty)
Microtask Queue
(empty)
Macrotask Queue
(empty)
Console:
// waiting...
Script execution begins. main(), standing for the whole script, is pushed onto the call stack.
RunningMicrotaskMacrotask
1 / 10

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:

  1. Evaluate the expression after await; if the value is not a Promise, wrap it in a resolved Promise
  2. Register the rest of the function to run when that Promise resolves (on resolution, the resume step enters the microtask queue)
  3. 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 42

Calling 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.

Coroutine State MachineCreatedRunningSuspendedCompletedstartawaitresumereturn

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:

  1. I/O multiplexing: On Unix, SelectorEventLoop uses selector backends such as epoll and kqueue; on Windows, the default ProactorEventLoop uses IOCP rather than the selector API
  2. A queue of ready callbacks: The event loop puts callbacks that can run immediately into a queue and runs them one at a time
  3. Tasks: An asyncio.Task wraps 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 resume

In 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 multiprocessing or concurrent.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:

  1. The runtime is not built into the language โ€” you choose a third-party runtime such as tokio or smol
  2. Futures are poll-based โ€” instead of a callback announcing completion, the runtime asks the Future whether it has finished
  3. 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-await heap 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

async/await Across LanguagesLanguageRuntimeSyntaxModelJavaScriptBuilt-in event loopasync/await, PromiseSingle-threadedPythonasyncioasync/await, coroutineSingle-threadedRusttokio / smolasync/await, FutureMulti-threaded runtimeC#Task / ThreadPoolasync/await, TaskMulti-threadedKotlinDispatcherssuspend, coroutineDepends on dispatcher

Push vs Pull

ModelLanguagesMechanismAdvantage
Push-basedJS, PythonCalls callback/continuation on completionSimpler mechanism (no polling or Waker needed)
Pull-basedRustRuntime polls FuturesZero-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

LanguageCoroutine State StorageMemory Allocation
JavaScriptSuspended function state (V8 heap)GC-managed
PythonCoroutine frame (heap)GC-managed
RustState machine (stack or Box)Manually controllable
GoGoroutine 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 await an async function's result (to wait for it, you must block the thread or start an event loop, as asyncio.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.