All posts

Inside async/await — Event Loops, Coroutines, and Runtime Mechanics

What happens behind the async/await keywords. From JavaScript's event loop to Python's asyncio to Rust's polling-based Future model, a deep dive into the internals of asynchronous execution.

Asyncasync/awaitEvent LoopCoroutineJavaScriptPythonRust

What async/await Hides

In modern programming languages, async/await has become the standard syntax for writing asynchronous code. But behind these two keywords, vastly different mechanisms run depending on the language and runtime.

async function fetchData() {
  const response = await fetch("/api/data");
  const json = await response.json();
  return json;
}

Behind this seemingly simple code, the runtime is doing the following:

  1. Suspending the function's execution and returning control to the runtime
  2. Asynchronously monitoring I/O completion
  3. Resuming the function when complete
  4. Efficiently scheduling multiple async operations

This article covers the three major execution models behind async/await.

Concurrency ModelsMulti-threadingOS threads, preemptiveJava, C++, C#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 commonly confused terms deserve clarification:

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 achieves concurrency, not necessarily parallelism. JavaScript's async/await achieves concurrent processing on a single thread.

JavaScript: The Event Loop Model

Event Loop Structure

JavaScript runs on a single thread. To handle multiple asynchronous operations on one thread, it uses the event loop mechanism.

The event loop consists of:

  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. One macrotask44. Render?Microtasks run to exhaustion before any macrotask

Event Loop Execution Order

One iteration of the event loop follows this order:

  1. Execute all code on the call stack
  2. Once the stack is empty, execute all tasks in the microtask queue (until the queue is empty)
  3. Once the microtask queue is empty, dequeue and execute one task from the macrotask queue
  4. Perform rendering if needed
  5. Return to step 1

The critical point is that microtasks always take priority over macrotasks. Let's verify with this code:

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 Q
(empty)
Macrotask Q
(empty)
Console:
// waiting...
Script execution begins. main() is pushed onto the call stack.
RunningMicrotaskMacrotask
1 / 10

async/await Transformation

async/await is syntactic sugar over Promises. The compiler/engine internally transforms async functions into Promise chains:

// 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:

  1. Evaluate the right-hand side expression (returns a Promise)
  2. Register the rest of the function as a .then() callback on the Promise (it enters the microtask queue when the Promise resolves)
  3. Suspend execution and return control to the event loop

This allows the event loop to continue processing other tasks while waiting for I/O.

V8 Engine Optimizations

V8 (the JavaScript engine in Chrome and Node.js) includes several optimizations for async/await:

  • Faster async/await (V8 v7.2+): When the value passed to await is already a Promise, V8 skips creating an extra wrapper Promise and eliminates the throwaway Promise, reducing microtask ticks from 3 to 1 (via promiseResolve optimization)

Python: asyncio and Coroutines

Python Coroutines

Python's async/await was introduced in Python 3.5 and is based on coroutines. Coroutines are functions that can be suspended and resumed, internally extending 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 execute the 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. Unlike JavaScript, Python's event loop must be explicitly created and 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 internally works through:

  1. Selector-based I/O multiplexing: Uses OS system calls (Linux: epoll, macOS: kqueue, Windows: IOCP) to monitor multiple I/O sources on a single thread
  2. Coroutine scheduler: Schedules runnable coroutines
  3. Task queue: Manages asyncio.Task objects

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 internally uses the __await__ protocol, transforming control flow as follows:

# 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. This eliminates the need for stack unwinding or restoration when suspending and resuming.

asyncio Constraints and the GIL

Python's asyncio has several important constraints:

  • GIL (Global Interpreter Lock): In CPython, only one thread executes Python bytecode at a time. Since asyncio runs on a single thread, the GIL's impact is minimal
  • Not suited for CPU-bound work: async/await is optimized for I/O-bound operations. For CPU-bound work, use multiprocessing or concurrent.futures.ProcessPoolExecutor
  • Library support required: Calling synchronous libraries (e.g., requests) from async code blocks the event loop. Use async alternatives (e.g., aiohttp) instead

Rust: Zero-Cost Futures and Polling

Rust's Async Model

Rust's async/await takes a fundamentally different approach from other languages:

  1. The runtime is not built into the language — You choose a third-party runtime (tokio, async-std)
  2. Futures are poll-based — Instead of callbacks, the runtime asks Futures if they're done
  3. Zero-cost abstraction — async/await overhead is minimal

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
}

While JavaScript's Promises and Python's coroutines are push-based ("call the callback when done"), Rust is pull-based (polling) ("the runtime asks the Future if it's done").

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"),
            }
        }
    }
}

This transformation provides:

  • No heap allocation needed: The state machine can be placed on the stack as a single enum
  • No dynamic dispatch: Concrete types are determined at compile time
  • Each await corresponds to one state transition

Waker and Runtime Cooperation

In the poll-based model, there must be a mechanism to tell the runtime "when to poll the Future again." This is the Waker:

// Context contains a Waker
pub struct Context<'a> {
    waker: &'a Waker,
}

Before a Future returns Poll::Pending, it registers a clone of the Waker with epoll or a socket callback. When I/O completes, the Waker fires, notifying the runtime that "this Future should be polled again."

1. Runtime calls Future.poll()
2. Future initiates I/O internally, registers Waker with the kernel
3. Returns Poll::Pending
4. Runtime polls other Futures
5. I/O completes -> kernel wakes the Waker
6. Runtime polls that Future again
7. Returns Poll::Ready(result)

The tokio Runtime

tokio is Rust's most widely used async runtime:

#[tokio::main]
async fn main() {
    let result = fetch_data().await;
    println!("{result}");
}

tokio internally provides:

  • Multi-threaded scheduler: 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: #[tokio::main(flavor = "current_thread")] for event-loop style operation

Cross-Language Comparison

async/await Across LanguagesLanguageRuntimeSyntaxModelJavaScriptEvent Loop (V8)async/await, PromiseSingle-threadedPythonasyncioasync/await, coroutineSingle-threadedRusttokio / async-stdasync/await, FutureMulti-threaded runtimeC#Task / ThreadPoolasync/await, TaskMulti-threadedKotlinDispatcherssuspend, coroutineStructured concurrency

Push vs Pull

ModelLanguagesMechanismAdvantage
Push-basedJS, PythonCalls callback/continuation on completionSimpler implementation
Pull-basedRustRuntime polls FuturesZero-cost, natural cancellation

A distinguishing advantage of Rust's pull-based model is cancellation. Dropping a Future automatically cancels it. Push-based models often require explicit cancellation tokens (CancellationToken).

Memory Model

LanguageCoroutine State StorageMemory Allocation
JavaScriptInside closures (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 have a "function coloring problem" where sync and async functions are clearly distinguished:

  • You can't directly call an async function from a sync function (blocking is needed)
  • You can call sync functions from async ones, but long blocking should be avoided
# 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 avoids this problem with goroutines. All functions are implicitly capable of concurrent execution, and the go keyword launches a new goroutine.

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 parallel
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 for parallel execution
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

async/await provides similar surface syntax, but the underlying implementations differ dramatically across languages:

  • JavaScript: Transforms to Promise chains on a single-threaded event loop. Microtask vs macrotask priority determines behavior
  • Python: asyncio event loop schedules coroutines (extended generators). Achieves I/O concurrency under GIL constraints
  • Rust: Compiles to zero-cost Future state machines. Poll-based model where the runtime drives execution, achieving async without heap allocation

The common design principle is never letting threads sit idle during I/O waits. async/await is the syntactic innovation that achieves this while maintaining the readability of synchronous code.