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.
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:
- Suspending the function's execution and returning control to the runtime
- Asynchronously monitoring I/O completion
- Resuming the function when complete
- Efficiently scheduling multiple async operations
This article covers the three major execution models behind async/await.
Prerequisites: Sync, Async, Concurrent, Parallel
These commonly confused terms deserve clarification:
| 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 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:
- 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 iteration of the event loop follows this order:
- Execute all code on the call stack
- Once the stack is empty, execute all tasks in the microtask queue (until the queue is empty)
- Once the microtask queue is empty, dequeue and execute one task from the macrotask queue
- Perform rendering if needed
- 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, 4JavaScript Event Loop
Step through: console.log('1'), setTimeout(cb,0), Promise.resolve().then(cb2), console.log('2')
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:
- Evaluate the right-hand side expression (returns a Promise)
- Register the rest of the function as a
.then()callback on the Promise (it enters the microtask queue when the Promise resolves) - 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
awaitis already a Promise, V8 skips creating an extra wrapper Promise and eliminates thethrowawayPromise, reducing microtask ticks from 3 to 1 (viapromiseResolveoptimization)
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 42Calling 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.
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:
- 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 - Coroutine scheduler: Schedules runnable coroutines
- Task queue: Manages
asyncio.Taskobjects
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 resumeIn 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
multiprocessingorconcurrent.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:
- The runtime is not built into the language — You choose a third-party runtime (tokio, async-std)
- Futures are poll-based — Instead of callbacks, the runtime asks Futures if they're done
- 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
awaitcorresponds 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
Push vs Pull
| Model | Languages | Mechanism | Advantage |
|---|---|---|---|
| Push-based | JS, Python | Calls callback/continuation on completion | Simpler implementation |
| Pull-based | Rust | Runtime polls Futures | Zero-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
| Language | Coroutine State Storage | Memory Allocation |
|---|---|---|
| JavaScript | Inside closures (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 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.