← Back to list

The Physics of Async Systems

Understanding the runtime mechanics of event loops, I/O completion, and future orchestration.

Scaibu in Stackademic · 2026-05-06 07:19 · 0 claps · 34.5 min read
#concurrency #system-design-concepts #performance-engineering #coroutine-internals #scaibu
Open on Medium ↗
Wiki topics: ⚛️ · Physics

The Physics of Async Systems

Understanding the runtime mechanics of event loops, I/O completion, and future orchestration.

Introduction

Async/await presents a linear, almost deceptive model of execution. Code appears to pause and resume seamlessly, as if the runtime simply “waits” and continues. In reality, nothing is waiting. There is no paused thread holding your stack. What exists instead is a coordinated system of state machines, queues, schedulers, and kernel interfaces that collectively simulate this behavior.

Every await splits your function into segments. The compiler lifts its state into a heap-allocated frame, converts control flow into an explicit state machine, and hands execution over to an executor. Progress is no longer implicit—it depends on external signals: I/O readiness, timer expiry, or explicit wakeups. These signals propagate through wakers into run queues, are scheduled by executors, and eventually drive the coroutine forward through repeated polling.

This article builds a complete mental model of that system. It connects the layers that are usually explained in isolation — event loops, I/O backends, promises, coroutines, schedulers — into a single execution pipeline. The focus is not on how to use async, but on how it actually works: the data structures, invariants, and ordering guarantees that determine correctness and performance.

Topics

Async runtimes = tightly coupled subsystems

Designed to optimize latency, throughput, memory efficiency, and correctness under concurrency.

Event Loop (core orchestrator)

  • Manages execution using ring buffers (ready queue)
  • Uses heaps or timing wheels for timers
  • Defines execution order via phases and microtask draining

I/O Layer (kernel interaction)

  • Uses models like epoll or io_uring
  • Converts blocking operations → completion events
  • Optimizes via shared-memory rings and batched syscalls

Promise / Future System

  • Shared state with atomic transitions
  • Stores continuations for chaining
  • Guarantees single resolution + controlled scheduling

Stackless Coroutines (compiler transformation)

  • async/await → state machine
  • Heap-allocated frame stores execution state
  • Progress driven via poll() and suspension points

Scheduler / Executor (execution engine)

  • Work-stealing deques + global queues
  • Optimizes locality and load balancing
  • Uses cooperative scheduling (task budgeting)

Wakers (rescheduling mechanism)

  • Decoupled notification system
  • Allows tasks to be resumed without executor awareness
  • Requires strict ordering to avoid lost wakeups

Channels (task communication)

  • Lock-free, sequence-based queues
  • High-throughput message passing
  • Integrated with async park/wake logic

Backpressure (flow control)

  • Token bucket, GCRA, adaptive concurrency
  • Regulates load based on rate or latency
  • Prevents overload without blocking

Cancellation (termination semantics)

  • Two-phase protocol (signal + cleanup)
  • Requires safe interruption guarantees
  • Distinguishes safe vs unsafe cancellation points

Overall Model A non-blocking, event-driven system where:

  • progress is explicit
  • state is externalized
  • correctness depends on precise coordination across subsystems

1. Event Loop Internals

The event loop is the beating heart of every async runtime. Understanding its internal data structures is not optional — every performance characteristic, every ordering guarantee, every subtle bug you will ever encounter in async code traces back to how the event loop is built.

Ring Buffer — The Ready Event Queue

The event loop needs a queue to hold tasks that are ready to run. The naive choice is a dynamic array or linked list. Both are wrong for this use case.

A dynamic array reallocates on growth — you cannot predict when, and the reallocation causes an O(n) copy at the worst possible moment. A separately-allocated linked list requires a heap allocation per node — under high event throughput this means thousands of allocations per second, destroying cache locality and creating GC pressure.

The ring buffer solves both. It is a fixed-size circular array with two indices — head and tail. Push advances tail, pop advances head. Both wrap around using modulo (or bitmask if the size is a power of two, which is always preferred because bitmask is a single AND instruction versus a division).

push(item):
    data[tail % N] = item
    tail++

pop():
    if head == tail: return EMPTY
    item = data[head % N]
    head++
    return item

The critical property: zero allocation after initialization. The buffer is allocated once. Every push and pop is O(1) with no heap involvement. The entire buffer fits in cache after warmup. This matters because the event loop runs this queue thousands of times per second — any per-operation allocation cost compounds catastrophically.

Participant A      Participant B      Participant C
     |                   |                   |
     |---- Request ----->|                   |
     |                   |---- Process ----->|
     |                   |<--- Response -----|
     |<--- Reply --------|                   |
     |                   |                   |

The second critical property: predictable memory usage. A dynamic queue can grow without bound if producers outrun consumers. A ring buffer enforces a hard capacity — if the ready queue is full, you have a backpressure problem that must be surfaced, not silently absorbed by allocating more memory.

Min-Heap — The Timer Queue

The event loop must fire timers at the correct time. The data structure holding pending timers must answer one question efficiently: what is the next timer to expire?

A min-heap answers this in O(1) — the minimum is always at index 0. Insertion and removal are O(log n). For typical timer counts (hundreds to low thousands), log n is effectively constant.

struct Timer:
    expiry_time
    callback

sift_up(i):
    while i > 0:
        parent = (i - 1) / 2
        if data[parent].expiry > data[i].expiry:
            swap(data[parent], data[i])
            i = parent
        else: break

sift_down(i):
    loop:
        left    = 2*i + 1
        right   = 2*i + 2
        smallest = i
        if left  < len and data[left].expiry  < data[smallest].expiry: smallest = left
        if right < len and data[right].expiry < data[smallest].expiry: smallest = right
        if smallest == i: break
        swap(data[i], data[smallest])
        i = smallest

Why a heap and not a sorted array? Insertion into a sorted array is O(n) — you must shift elements. Under high timer churn (network timeouts being set and cancelled constantly), O(n) insertion kills throughput. The heap gives O(log n) for both insert and delete.

Why not a balanced BST (red-black tree)? A BST gives the same asymptotic complexity but has worse constants — each node is separately allocated, pointer-chasing kills cache locality. The heap is a contiguous array — sift operations walk a predictable path through contiguous memory.

The event loop uses the heap’s minimum to set the epoll_wait timeout. This is the integration point between the timer system and the I/O notification system:

timeout = timer_heap.peek().expiry - now
events  = epoll_wait(timeout)

If no I/O events arrive, epoll_wait returns after exactly timeout nanoseconds — and the loop fires the expired timer. This means the loop never busy-waits and never sleeps longer than the next timer requires. Getting this right is what makes a runtime both CPU-efficient and timer-accurate simultaneously.

Hierarchical Timing Wheel — High-Scale Timers

The min-heap breaks down at scale. With tens of thousands of concurrent timers (one per TCP connection timeout, one per request deadline, one per keepalive), O(log n) insert becomes measurable. More critically, timer cancellation in a heap requires finding the node first — O(n) scan unless you maintain a separate index.

The hierarchical timing wheel solves this. It trades the heap’s generality for O(1) insert, O(1) cancel, and O(1) amortized expire — at the cost of quantized resolution.

The structure: multiple arrays (wheels), each representing a different time granularity. Level 0 has slots of 1ms each, 256 slots covering 256ms. Level 1 has slots of 256ms each, covering 65 seconds. Level 2 covers hours. Each slot holds a linked list of timers expiring in that slot’s window.

insert(timer, delay):
    for level in levels:
        if delay < level.tick_duration * SLOTS:
            slot = (level.cursor + delay / level.tick_duration) % SLOTS
            level.buckets[slot].append(timer)
            return
        delay -= remaining time at this level

tick():
    levels[0].cursor++
    fire all timers in levels[0].buckets[cursor]
    if levels[0].cursor wrapped:
        cascade from levels[1] into levels[0]
        levels[1].cursor++
        if levels[1].cursor wrapped:
            cascade from levels[2] ...

The cascade is the key operation. When level 0’s cursor wraps around (every 256ms), it pulls all timers from the current slot of level 1, re-inserts them into level 0 at their precise sub-slot. This amortizes the work across all 256 ticks of level 0 — a single cascade is O(k) where k is the number of timers in that level-1 slot, but averaged across 256 ticks it approaches O(1).

Cancellation is O(1): each timer node is in a linked list. If you hold a pointer to the node (which the setTimeout return value gives you), removal is pointer manipulation — no searching required.

This is why Linux’s kernel timer implementation, Netty’s HashedWheelTimer, Kafka's Timer, and Tokio's timer all use wheels rather than heaps. The constant factors matter at the scale these systems operate at.

Intrusive Linked List — The Run Queue

The ready-to-run queue inside the event loop has a constraint the ring buffer does not satisfy: tasks must be able to remove themselves from the middle of the queue (for cancellation), and the queue must handle arbitrary growth without reallocation.

The intrusive linked list solves this by embedding the list node directly inside the task struct:

struct Task:
    next: *Task          // the list pointer lives inside the task
    prev: *Task
    callback
    state

Non-intrusive lists allocate a separate list node that holds a pointer to the element. This means two heap allocations per enqueue: one for the node, one for the element. The intrusive version allocates the task once — the list node is part of the task’s memory. Enqueue and dequeue are pure pointer manipulation, zero allocation.

The second advantage: given a pointer to a task, you can remove it from any list in O(1) without knowing which list it is in — just manipulate its own prev/next pointers. This is essential for cancellation: when a timeout fires and cancels a pending I/O operation, the operation’s task must be removed from whatever queue it currently occupies, immediately, without searching.

This is why the Linux kernel uses intrusive lists (list_head embedded in every kernel data structure) for virtually everything. The pattern appears in every high-performance runtime for the same reasons.

Phase Ordering and Microtask Draining

The event loop is not a simple while loop. It has phases, and the ordering of those phases determines the observable execution order of all async code. Getting this wrong produces bugs that are nearly impossible to reason about from the outside.

The canonical phase structure:

loop:
    phase: timers         — fire all expired setTimeout / setInterval
    drain microtask queue
    phase: pending I/O    — fire I/O callbacks deferred from previous iteration
    drain microtask queue
    phase: poll           — block on epoll_wait, fire ready I/O callbacks
    drain microtask queue
    phase: check          — fire setImmediate callbacks
    drain microtask queue
    phase: close          — fire close event callbacks
    drain microtask queue

The microtask drain between every phase is the critical invariant. Microtasks (Promise continuations, queueMicrotask) must run to completion before the event loop advances to the next phase. This is why promise chains always complete before the next timer fires, even a timer with delay 0.

drain_microtask_queue():
    while not microtask_queue.empty():
        fn = microtask_queue.pop_front()
        fn()  // fn may enqueue more microtasks — keep draining

Note that draining is recursive in the sense that each callback may enqueue more microtasks, which are also drained before returning. This means an infinite microtask chain will starve the I/O phase entirely — the loop never advances. This is not a theoretical concern: it is the mechanism behind one of the most common async performance bugs.

The phase ordering also explains why setImmediate (check phase) fires before a setTimeout(fn, 0) (timers phase) when both are queued from within an I/O callback (poll phase) — after poll, check comes before the next timers phase.

2. I/O Submission and Completion — io_uring

io_uring represents a fundamental rethinking of how userspace and the kernel communicate about I/O. The traditional model (epoll + read/write) requires at minimum two syscalls per I/O operation: one to learn the fd is ready, one to perform the actual transfer. Each syscall is a privilege level transition — roughly 100–1000ns of overhead on modern CPUs with Spectre/Meltdown mitigations. At 100k operations per second, syscall overhead alone consumes 10–100ms of CPU time per second.

io_uring eliminates this entirely for the steady-state fast path.

The Ring Pair — Shared Memory Between Userspace and Kernel

The architecture is two lock-free ring buffers in shared memory — mapped into both userspace and kernel address space via mmap. No data is copied between them. Both sides read and write to the same physical memory pages.

struct SubmissionQueue:
    entries: SQE[]          // array of submission queue entries
    head:    atomic_u32     // kernel reads from head — consumer
    tail:    atomic_u32     // userspace writes to tail — producer
    mask:    u32            // capacity - 1, for fast modulo

struct CompletionQueue:
    entries: CQE[]
    head:    atomic_u32     // userspace reads from head — consumer
    tail:    atomic_u32     // kernel writes to tail — producer
    mask:    u32

Userspace submits work by writing SQEs (submission queue entries) into the SQ array at the current tail position, then advancing the tail with a release store. The kernel sees the new tail via its own view of the same memory and processes the entries.

The kernel writes CQEs (completion queue entries) into the CQ array and advances the CQ tail. Userspace polls the CQ head to harvest completions.

The release/acquire memory ordering is critical. The producer’s store of data must be visible to the consumer before the store of the tail index — otherwise the consumer reads the index, sees new work, but reads stale data. A release store on the tail, paired with an acquire load on the consumer side, provides exactly this guarantee without a full memory fence.

SQE and CQE — The Communication Protocol

Every I/O operation is described by a 64-byte SQE:

struct SQE:
    opcode:      u8     // READ, WRITE, ACCEPT, CONNECT, SEND, RECV, FSYNC ...
    flags:       u8     // IOSQE_IO_LINK, IOSQE_BUFFER_SELECT, IOSQE_FIXED_FILE ...
    fd:          i32    // file descriptor (or registered file index)
    offset:      u64    // file offset for read/write; 0 for sockets
    buffer_ptr:  u64    // userspace buffer address
    buffer_len:  u32    // buffer length
    user_data:   u64    // opaque — echoed back in CQE; use to correlate completion to submission

The user_data field is the correlation mechanism. When a CQE arrives, you look up the pending operation by user_data to know what completed and with what result. In practice this is an index into a slab of in-flight operation structs.

struct CQE:
    user_data:  u64    // matches SQE.user_data exactly
    result:     i32    // bytes transferred, or negative errno on error
    flags:      u32    // IORING_CQE_F_BUFFER (buffer index in high bits), IORING_CQE_F_MORE

The 64-byte SQE size is deliberate — it fits exactly in one cache line. Writing an SQE is a single cache line write with no false sharing against adjacent SQEs.

Batched Submission — Eliminating Syscall Per Operation

The naive use of io_uring still calls io_uring_enter once per operation. The correct use accumulates all SQEs for a tick of the event loop, then submits them all with a single io_uring_enter:

pending = []

enqueue_read(fd, buf, len, token):
    pending.append(SQE(opcode=READ, fd=fd, buffer_ptr=buf, buffer_len=len, user_data=token))

flush_submissions():
    for sqe in pending:
        sq.entries[sq.tail & sq.mask] = sqe
        sq.tail.store(sq.tail + 1, release)
    io_uring_enter(fd, submit=len(pending), min_complete=0, flags=0)
    pending.clear()

One syscall for N operations. At 32 operations per flush, you reduce syscall overhead by 32x. This is the primary source of io_uring’s throughput advantage over epoll.

Linked SQE Chains — Sequential Async Pipelines in Kernel Space

Setting IOSQE_IO_LINK on an SQE creates a dependency: the next SQE in the submission only executes if the current one succeeds. This allows you to express multi-step I/O sequences as a chain submitted in one batch:

submit_chain([
    SQE(RECV,  fd, buf1, flags=IOSQE_IO_LINK),    // receive request header
    SQE(READ,  file_fd, buf2, flags=IOSQE_IO_LINK), // read file from disk
    SQE(SEND,  fd, buf2, flags=0)                  // send file contents
])

The kernel executes these sequentially without returning to userspace between steps. No wakeup, no scheduling, no syscall between the receive and the send. For the common pattern of “receive request, read data, send response” this eliminates two userspace round-trips per request.

If any SQE in the chain fails, the kernel marks subsequent SQEs with ECANCELED in their CQEs — you still get a CQE for each, you just handle the error path.

Provided Buffers — Eliminating TOCTOU in Buffer Management

The traditional model has a time-of-check to time-of-use (TOCTOU) problem: you learn a fd is readable (check), then you call read with a buffer you must have ready (use). You must pre-allocate buffers before knowing how many fds will become readable simultaneously — either over-allocating or risking buffer unavailability.

Provided buffers invert this. You register a pool of buffers with the kernel upfront. When submitting a RECV, you specify the buffer group ID but no specific buffer. The kernel selects a free buffer from the pool at the moment data arrives, places the data there, and reports which buffer ID was used in the CQE flags.

register_buffers(pool_id, bufs[N]):
    io_uring_register(IORING_REGISTER_PBUF_RING, pool_id, bufs, N)

submit_recv(fd, pool_id, token):
    SQE(opcode=RECV, fd=fd, buf_group=pool_id, flags=IOSQE_BUFFER_SELECT, user_data=token)

on_cqe(cqe):
    buf_id  = cqe.flags >> IORING_CQE_BUFFER_SHIFT
    buf     = pool.get(buf_id)
    process(buf[0 .. cqe.result])
    pool.return(buf_id)

The kernel only consumes a buffer when data actually arrives — never wastes a buffer on a connection with no data. For 10,000 concurrent connections, you need buffers proportional to active connections, not total connections.

3. Promise and Future Machinery

Promises are often described as “a value that will be available in the future.” This description is true but useless for understanding what actually happens. The correct mental model: a promise is a state machine with a linked list of continuations, shared between two handles via a reference-counted heap allocation.

The Shared State — What a Promise Actually Is

Every promise/future pair shares a single heap-allocated struct:

struct SharedState<T, E>:
    state:         atomic enum { PENDING, FULFILLED, REJECTED }
    value:         Option<T>
    error:         Option<E>
    refcount:      atomic_u32
    continuations: IntrusiveList<Continuation>
    mutex:         Mutex                  // guards continuations list

The Promise handle and the Future handle are both thin wrappers around a pointer to this struct. The refcount starts at 2 — one for each handle. When either handle is dropped, the refcount decrements. When it reaches 0, the shared state is freed.

This is why you can pass a future to another thread and resolve the promise from yet another thread — they share the same heap allocation, and the atomic state field ensures only one resolution wins.

One-Shot Resolution — The CAS Guarantee

The most critical property of a promise is that it can only be resolved once. Multiple resolves must be silently dropped — not panicked, not queued, silently dropped. This enables patterns like “resolve from whichever I/O callback fires first” without coordination.

resolve(value):
    expected = PENDING
    if not state.compare_exchange(expected, FULFILLED, acq_rel, acquire):
        return                                    // already settled — drop
    state.value = Some(value)
    flush_continuations()

reject(error):
    expected = PENDING
    if not state.compare_exchange(expected, REJECTED, acq_rel, acquire):
        return
    state.error = Some(error)
    flush_continuations()

The compare-exchange atomically checks that state is still PENDING and transitions to FULFILLED in one uninterruptible operation. The second resolver to call this finds state is no longer PENDING — the CAS fails, and it returns immediately.

The acq_rel ordering on success ensures that the value write before the CAS is visible to any thread that observes the new state via an acquire load. Without this, a consumer could observe FULFILLED but read a stale (uninitialized) value.

Continuation Chain — How .then() Is Stored

When you call .then(on_fulfill, on_reject) on a future, you are not executing anything. You are attaching a continuation to the shared state's continuation list. The continuation will be executed later, when the promise is resolved.

then(on_fulfill, on_reject):
    next_promise, next_future = create_pair()

    cont = Continuation(fn():
        if state.state == FULFILLED:
            result = on_fulfill(state.value)
            assimilate(next_promise, result)
        else:
            result = on_reject(state.error)
            assimilate(next_promise, result)
    )

    lock(state.mutex)
    if state.state == PENDING:
        state.continuations.push(cont)
        unlock(state.mutex)
    else:
        unlock(state.mutex)
        microtask_queue.push(cont)         // already settled — schedule immediately

    return next_future

Two paths: if the promise is still pending, the continuation is stored in the list — it will be enqueued when the promise resolves. If the promise is already settled (another thread resolved it between creation and this .then() call), the continuation is immediately scheduled as a microtask.

The mutex around the continuations list guards against a race: the promise resolves on thread A while .then() is attaching a continuation on thread B. Without the mutex, the resolution flush could walk the list and miss the continuation that is mid-insertion. The continuation would then never run — a silent bug.

Flush — Resolution to Microtask Queue

When a promise resolves, it walks the continuation list and enqueues each continuation as a microtask:

flush_continuations():
    lock(state.mutex)
    list = take(state.continuations)       // atomically steal the entire list
    unlock(state.mutex)
    for cont in list:
        microtask_queue.push(cont)         // schedule, do not execute yet

Critical: flush does not execute continuations directly. It enqueues them as microtasks. Execution happens in the microtask drain phase of the event loop. This ensures two properties:

First, promise callbacks are always asynchronous — .then() never calls your callback synchronously even if the promise is already resolved. This eliminates Zalgo — the pathological behavior where a callback fires synchronously sometimes and asynchronously other times depending on internal state.

Second, the resolution code (which may be deep inside a library) does not directly execute user code. It only enqueues. This prevents re-entrancy: user code cannot call back into the resolver while the resolver is still on the stack.

Thenable Assimilation — Flattening Promise Chains

If .then() returns a value, the next promise is resolved with that value. But if .then() returns another promise, you do not want a promise-of-a-promise — you want the outer promise to wait for the inner one. Thenable assimilation handles this:

assimilate(promise, value):
    if value is PromiseLike:              // has a .then method
        value.then(
            fn(v): promise.resolve(v),
            fn(e): promise.reject(e)
        )
    else:
        promise.resolve(value)

This is recursive in the sense that if value.then also returns a promise, assimilation runs again. The chain flattens to arbitrary depth. Without this, fetch().then(r => r.json()) would return a Promise<Promise<JSON>> instead of a Promise<JSON> — every async operation returning a promise would require explicit unwrapping.

This is why the Promises/A+ specification dedicates more text to the resolution procedure than to any other aspect — thenable assimilation is subtle and the source of many interoperability bugs between promise implementations.

Unhandled Rejection Tracking

A rejected promise with no .catch() is a silent failure. The runtime must detect this and surface it. The mechanism:

reject(error):
    ...settle...
    if state.continuations.empty():
        unhandled_set.add(state)          // no handlers yet — potentially unhandled

then(on_fulfill, on_reject):
    unhandled_set.remove(state)          // handler attached — no longer unhandled
    ...

end_of_microtask_checkpoint():
    for state in unhandled_set:
        emit('unhandledRejection', state.error)
    unhandled_set.clear()

The check happens at the end of the microtask checkpoint — not immediately on rejection. This is essential: it allows code like promise.reject(err); promise.catch(handler) to attach the handler synchronously after rejection without triggering a false unhandled rejection warning. The checkpoint boundary is the point at which the runtime can be certain no more synchronous handler attachment is possible.

The unhandled_set is a weak set — it holds references that do not prevent garbage collection. If the promise itself is collected (nobody holds a reference) before the checkpoint, it is removed from the set automatically. This prevents false positives for promises that are created, rejected, and immediately discarded with no possible handler.

4. Stackless Coroutine State Machine

The stackless coroutine is the single most important transformation in modern async programming. Every async/await in every language compiles down to this. Understanding the transformation at the machine level is what separates engineers who debug async correctly from engineers who guess.

The Core Problem Stackless Coroutines Solve

A regular function has one entry point and one exit point. Its local variables live on the call stack — when the function returns, the stack frame is destroyed. This model is incompatible with suspension: if you suspend in the middle of a function and return control to the caller, the stack frame is gone. When you try to resume, all local variables have been overwritten by subsequent calls.

The stackless coroutine solves this by moving all state that must survive a suspension point off the stack and into a heap-allocated struct — the coroutine frame. The function itself is transformed into a state machine that can be entered at any previously suspended point.

The Compiler Transformation

Consider this async function:

async fn fetch_and_process(url, timeout):
    conn   = await connect(url)
    data   = await read(conn, timeout)
    result = process(data)
    return result

There are two suspension points: await connect and await read. The compiler identifies every local variable that is alive across any suspension point. Here conn is alive across await read (it is used after the first await). data is alive across nothing (process runs before any suspension after the second await, but process itself is synchronous). url and timeout are parameters — alive from the start.

The compiler generates:

struct FetchAndProcessFrame:
    state:   enum { START, AFTER_CONNECT, AFTER_READ, DONE }
    url:     String          // alive from start — must survive first suspension
    timeout: Duration
    conn:    Connection      // alive across second suspension — must be stored
    connect_future: ConnectFuture     // the sub-future being awaited at state 0
    read_future:    ReadFuture        // the sub-future being awaited at state 1

Note: data and result do not appear in the frame — they are only live within a single phase and can be stack-allocated within that phase's execution.

The poll function:

poll(frame, waker):
    switch frame.state:

        case START:
            frame.connect_future = connect(frame.url)
            frame.state = AFTER_CONNECT
            // fall through to poll the sub-future immediately

        case AFTER_CONNECT:
            match frame.connect_future.poll(waker):
                Ready(conn):
                    frame.conn = conn
                    frame.read_future = read(frame.conn, frame.timeout)
                    frame.state = AFTER_READ
                    // fall through to poll read_future immediately
                Pending:
                    return Pending

        case AFTER_READ:
            match frame.read_future.poll(waker):
                Ready(data):
                    result = process(data)
                    frame.state = DONE
                    return Ready(result)
                Pending:
                    return Pending

        case DONE:
            panic("polled after completion")

This is the complete transformation. The switch-on-state is the resume mechanism. Each case resumes at the exact point the coroutine last suspended. The fall-through from START to AFTER_CONNECT to AFTER_READ handles the case where sub-futures complete synchronously — no unnecessary yield to the executor.

Frame Layout and Memory

The frame struct is heap-allocated when the coroutine is spawned as a task. Its size is computed at compile time — the compiler knows exactly which variables are alive at each suspension point and packs them into the struct.

A critical optimization: variables that are never alive simultaneously across a suspension point can share the same memory location. This is a union-like optimization — the frame uses max(size_of_branch_A, size_of_branch_B) bytes for variables that are mutually exclusive.

// if conn is only used between suspension 1 and 2,
// and result is only used after suspension 2,
// they can overlap in the frame:

union:
    conn:   Connection      // alive between state 1 and 2
    result: ProcessResult   // alive after state 2 (before return)

This makes coroutine frames significantly smaller than naive analysis suggests. In Rust, the compiler performs this optimization automatically — the future size is the minimum necessary to represent all live variable sets simultaneously.

The frame also contains the nested future slots (connect_future, read_future). These are stored inline in the parent frame — not separately heap-allocated. This means a chain of 10 awaits produces one heap allocation for the outermost frame, which contains all nested frames inline. The entire async call chain is a single contiguous allocation.

The Waker — Scheduling Without an Executor Reference

When a sub-future cannot complete immediately, it must somehow notify the executor to re-poll the parent future when it can make progress. The sub-future cannot hold a direct reference to the executor — that would create a circular dependency and require knowing which executor is being used at future construction time.

The Waker abstracts this:

struct Waker:
    data:   *void              // opaque pointer — executor-defined
    vtable: *WakerVTable

struct WakerVTable:
    wake:         fn(*void)    // consume the waker and schedule the task
    wake_by_ref:  fn(*void)    // schedule the task without consuming
    clone:        fn(*void) -> Waker
    drop:         fn(*void)

The executor creates a Waker for each task before polling it. The Waker’s data pointer points to the task struct. The wake function, when called, pushes the task back onto the executor's run queue.

The sub-future stores this waker when it returns Pending:

// inside ConnectFuture.poll:
poll(frame, cx):
    if socket_ready:
        return Ready(conn)
    else:
        io_source.register_waker(cx.waker.clone())  // store waker in the I/O interest table
        return Pending

When the socket becomes readable, the I/O driver retrieves the stored waker and calls wake(). This enqueues the parent task without the sub-future needing to know anything about which executor is running or how scheduling works.

The vtable indirection is what makes this work across executor implementations. The same future code runs identically whether the executor is single-threaded, multi-threaded, or a custom embedded runtime — the waker’s vtable contains all executor-specific behavior.

Waker Registration — The Critical Ordering

The order of operations inside a future’s poll is not arbitrary. The correct order is:

poll(frame, cx):
    result = attempt_operation()        // 1. try the operation first
    if result == WOULD_BLOCK:
        register_waker(cx.waker)        // 2. only register if it would block
        result = attempt_operation()    // 3. try again after registering
        if result == WOULD_BLOCK:
            return Pending              // 4. now safe to return Pending
    return Ready(result)

Why the second attempt after registration (step 3)? Because the event that would have triggered the waker may have occurred between step 1 and step 2. If you register and then check, you close this window — if the event happened between steps 1 and 2, step 3 will succeed. If you only check before registering, you return Pending with a waker that will never be called because the event already fired.

This is one of the most subtle correctness requirements in async runtime implementation. A future that gets this wrong produces a task that suspends permanently — it returned Pending, the waker was never called, the task is never re-polled, the operation never completes. From the outside it looks like a hang with no error.

HALO — Heap Allocation Elision

The heap allocation for coroutine frames is the main cost of async in languages like Rust. HALO (Heap Allocation Elision Optimization) eliminates this cost when the compiler can prove the future is immediately awaited without being moved or stored.

The conditions for elision:

  • The future is created and awaited in the same expression: await connect(url)
  • The future is never stored in a struct or passed to a function that might store it
  • The future’s size is known at compile time (always true for stackless)

When these hold, the compiler allocates the nested future’s frame directly within the parent frame, on the parent’s stack or within the parent’s heap allocation. Zero additional heap operations.

// This:
let conn = await connect(url)

// Compiles to frame inline storage, no heap alloc for connect's frame:
frame.connect_future = connect(url)   // stored inside parent frame directly
poll(frame.connect_future, cx)

In practice, well-written async code achieves near-zero allocation per await point after the initial task spawn. The entire async call tree is one allocation.

The Colored Functions Problem — Why It Matters

Stackless coroutines have a fundamental limitation: suspension can only happen at explicit await points, and await can only appear inside an async function. A synchronous function cannot contain an await. This means if any function in your call chain needs to await something, every function above it must also be async.

async fn a(): await b()
async fn b(): await c()
async fn c(): await some_io()

// sync_fn cannot call a() and wait for it without blocking a thread:
fn sync_fn():
    result = block_on(a())   // forces blocking — defeats async

This “coloring” of functions — async vs sync — propagates upward through the entire call stack. It means you cannot add async I/O to a deeply nested function without making the entire call chain async. It is a viral property.

This is not a solvable problem within the stackless model — it is an inherent consequence of the design. Stackful coroutines (goroutines, virtual threads) avoid this entirely because they carry a real stack and can suspend from any call depth. The tradeoff is that stackful coroutines are heavier — each requires its own stack allocation regardless of whether it ever suspends.

The existence of this problem is why Go chose stackful goroutines, why Java Loom chose virtual threads with real stacks, and why some Rust users reach for tokio::task::spawn_blocking when integrating with sync libraries — to run sync code on a thread pool where it can block without harming the async executor.

5. Scheduler and Executor Internals

The executor is the runtime that drives futures to completion. Its internal design determines throughput, latency, fairness, and CPU utilization. The data structures and algorithms here are directly responsible for the performance characteristics you observe in production.

Work-Stealing Deque — The Chase-Lev Algorithm

Every high-performance async executor uses work stealing. The data structure that makes it efficient is the Chase-Lev deque — a double-ended queue with asymmetric access: the owner accesses one end cheaply, stealers access the other end with atomic operations.

struct Deque<T>:
    buffer:  AtomicPtr<Buffer<T>>   // the backing circular array
    top:     AtomicI64              // stealers take from top — atomic
    bottom:  I64                    // owner pushes/pops from bottom — non-atomic locally

struct Buffer<T>:
    data:    T[]
    mask:    u64                    // capacity - 1

Owner push (called only by the owning thread):

push(task):
    b      = bottom
    t      = top.load(acquire)
    buf    = buffer.load(relaxed)

    if b - t >= buf.capacity - 1:
        grow()                      // resize — rare path
        buf = buffer.load(relaxed)

    buf.data[b & buf.mask] = task
    bottom = b + 1                  // non-atomic write — only owner writes bottom

Owner pop (also only called by the owning thread):

pop():
    b   = bottom - 1
    buf = buffer.load(relaxed)
    bottom = b                      // tentatively decrement

    t = top.load(seq_cst)           // seq_cst to synchronize with stealers
    if t <= b:
        task = buf.data[b & buf.mask]
        if t == b:                  // only one element — might race with a stealer
            if not top.compare_exchange(t, t+1, seq_cst, relaxed):
                bottom = b + 1     // stealer won — restore bottom, return empty
                return EMPTY
        return task
    else:
        bottom = b + 1             // queue was empty
        return EMPTY

Stealer (called by any other thread):

steal():
    loop:
        t   = top.load(acquire)
        b   = bottom.load(acquire)
        if t >= b: return EMPTY     // empty

        buf  = buffer.load(acquire)
        task = buf.data[t & buf.mask]

        if top.compare_exchange(t, t+1, seq_cst, relaxed):
            return task             // CAS succeeded — we got the task
        // CAS failed — another stealer got it, retry

The asymmetry is the key insight. The owner operates on bottom with non-atomic writes — no cache coherence traffic. Only the single-element edge case requires a CAS from the owner side. Stealers use CAS on top — they contend with each other but never with the owner except in the single-element case.

This means an executor thread running its own tasks pays essentially zero synchronization cost. The stealing path is more expensive, but stealing is rare — it only happens when a thread runs out of work.

Global Injection Queue — Task Entry Point

Newly spawned tasks and tasks woken from external threads (e.g., a waker called from an I/O driver thread) cannot go directly into a specific thread’s local deque — they do not know which thread is least loaded. They enter via a global injection queue.

The global queue is an MPSC (multiple producer, single consumer) or MPMC queue depending on whether multiple threads drain it:

struct GlobalQueue<T>:
    head: AtomicPtr<Node<T>>
    tail: AtomicPtr<Node<T>>

push(task):                          // called by any thread
    node = alloc Node(task, next=null)
    old_tail = tail.swap(node, acq_rel)
    old_tail.next.store(node, release)

steal_batch(local_deque, max_n):     // called by worker thread
    taken = 0
    while taken < max_n:
        node = pop_front()
        if node == null: break
        local_deque.push(node.task)
        taken++
    return taken

Workers check the global queue periodically — typically every N local pops (Tokio uses 61, a prime number to avoid alignment with other periodic operations). Taking a batch rather than a single task amortizes the cost of the global queue’s lock or CAS across multiple tasks.

LIFO Slot — Cache Locality Optimization

When a task spawns a child task and immediately awaits it, the child should run next — before anything else. The child will access data that the parent just touched, which is still hot in cache. Running the child immediately maximizes cache reuse.

Every worker thread has a single LIFO slot — a one-task buffer that bypasses the deque:

struct Worker:
    lifo_slot: AtomicOption<Task>
    local_deque: Deque<Task>

schedule(task):
    prev = lifo_slot.swap(Some(task), acq_rel)
    if prev is Some(old_task):
        local_deque.push(old_task)   // displaced task goes to normal queue

next_task():
    if let Some(task) = lifo_slot.take():
        return task                  // take LIFO slot first
    return local_deque.pop()
        or global_queue.steal()
        or steal_from_random_victim()

The spawning task puts the new task in the LIFO slot. On the next poll iteration, the worker picks it up immediately. The spawning task, now suspended waiting for the child, goes into the regular deque for later resumption.

This single optimization produces measurable throughput improvements for workloads with parent-child task relationships — which is most async workloads. Tokio calls this the “coop” optimization and measures 10–30% throughput improvement on typical HTTP server workloads.

Task Budget — Preventing Monopolization

A cooperative executor relies on tasks yielding control regularly. A task that never suspends (CPU-bound loop, very long synchronous operation) monopolizes the executor thread and starves all other tasks on that thread.

The budget mechanism gives each task a fixed number of “operations” per scheduling quantum:

struct TaskContext:
    budget: Cell<u8>             // decrements per operation, e.g. starts at 128

consume_budget(cx):
    b = cx.budget.get()
    if b == 0:
        cx.waker.wake_by_ref()   // re-schedule self
        return Pending           // yield to executor
    cx.budget.set(b - 1)
    return Continue

Each async I/O operation consumes one budget unit. When the budget reaches zero, the task schedules itself for resumption and yields — giving other tasks a chance to run. The task resumes on the next scheduler tick with a fresh budget.

This is why Tokio’s I/O operations are budget-aware — TcpStream::read internally calls consume_budget and may return Poll::Pending even when data is available, if the budget is exhausted. From the user's perspective the read "didn't complete yet" — from the scheduler's perspective, the task voluntarily yielded to prevent starvation.

Worker Parking — Efficient Idle

When all queues are empty, worker threads must sleep without burning CPU. The naive approach — thread::sleep(1ms) in a loop — burns CPU and introduces unnecessary latency. The correct approach is OS-level parking via futex (Linux) or equivalent.

struct Parker:
    state: AtomicU32             // EMPTY=0, PARKED=1, NOTIFIED=2

park():
    state.store(PARKED, release)
    loop:
        futex_wait(state, PARKED)   // blocks until state != PARKED
        if state.load(acquire) == NOTIFIED:
            state.store(EMPTY, release)
            return

unpark():
    old = state.swap(NOTIFIED, acq_rel)
    if old == PARKED:
        futex_wake(state, 1)        // wake one thread

The state machine prevents the lost wakeup race. If unpark is called between the worker's decision to park and the actual futex_wait call, the state is NOTIFIED — futex_wait returns immediately because the condition is already false.

Worker threads attempt to steal from all other threads before parking. The steal attempt itself serves as a final check for work before sleeping — prevents a scenario where a task is enqueued, the worker decides to park, the unpark is missed, and the task sits unexecuted.

6. Waker Internals and Notification

Atomic Waker — The Single-Slot Concurrent Waker

A future often needs to store a waker that will be replaced when the future is polled again. The naive approach — store the waker in a Mutex-protected Option — is correct but slow. The AtomicWaker uses a CAS-based state machine to avoid the mutex on the common path.

struct AtomicWaker:
    state: AtomicU8              // EMPTY=0, REGISTERING=1, WAKING=2, REGISTERED=3
    waker: UnsafeCell<Option<Waker>>

register(waker):
    loop:
        s = state.load(acquire)
        match s:
            EMPTY | REGISTERED:
                if state.compare_exchange(s, REGISTERING, acq_rel, acquire) succeeds:
                    waker_slot = waker
                    state.store(REGISTERED, release)
                    return

            WAKING:
                // concurrent wake — just call our waker directly
                waker.wake()
                return

wake():
    s = state.swap(WAKING, acq_rel)
    match s:
        REGISTERED:
            waker = take(waker_slot)
            state.store(EMPTY, release)
            waker.wake()

        REGISTERING:
            // register() is mid-flight — it will see WAKING state and self-wake
            // nothing to do here

        EMPTY | WAKING:
            // nothing registered, or already waking

The REGISTERING state prevents a race where register is mid-write when wake fires. The WAKING state prevents a race where wake fires mid-register. The state machine ensures exactly one of: the waker is called by wake(), or the registering thread sees WAKING and calls it itself. The invariant is: no wakeup is ever lost.

Poll-then-Register — The Correctness Protocol

The interaction between a future returning Pending and the waker being registered has a specific required ordering that must not be violated:

// WRONG — can miss events:
register_waker(cx.waker)          // register first
result = attempt_io()             // then try
if result == WOULD_BLOCK:
    return Pending
// Problem: event fired between register and attempt — attempt succeeded,
// but now we return Pending anyway. Task parks forever.

// ALSO WRONG — can miss wakeup:
result = attempt_io()             // try first
if result == WOULD_BLOCK:
    return Pending                // return Pending before registering waker
// Problem: event fires after attempt returns WOULD_BLOCK but before
// the caller registers the waker — wakeup is lost.

// CORRECT:
result = attempt_io()             // 1. try first
if result == WOULD_BLOCK:
    register_waker(cx.waker)      // 2. register
    result = attempt_io()         // 3. try again — closes the window
    if result == WOULD_BLOCK:
        return Pending            // 4. now safe — waker is registered
return Ready(result)

Step 3 is the critical addition. It closes the window between step 1 and step 2 where an event could fire and be missed. After step 2’s registration, any new event will call the waker and re-poll the future. If the event fired between steps 1 and 2, step 3 will succeed — the future returns Ready without parking.

7. Channel Internals

Slot Sequence Protocol — Dmitry Vyukov’s MPMC Queue

The highest-performance bounded MPMC (multiple producer multiple consumer) channel uses sequence numbers on each slot rather than locking the entire queue. This is the algorithm behind LMAX Disruptor, Rust’s crossbeam::ArrayQueue, and dozens of high-performance message queues.

struct Slot<T>:
    sequence: AtomicU64
    value:    UnsafeCell<T>

struct Queue<T>:
    slots:    Box<[Slot<T>]>     // fixed-size, cache-line padded
    head:     AtomicU64          // enqueue position
    tail:     AtomicU64          // dequeue position
    mask:     u64

// Initialization: slot[i].sequence = i for all i

Enqueue:

push(value):
    loop:
        pos  = head.load(relaxed)
        slot = slots[pos & mask]
        seq  = slot.sequence.load(acquire)
        diff = seq as i64 - pos as i64

        if diff == 0:
            // slot is ready for writing at this position
            if head.compare_exchange(pos, pos+1, relaxed, relaxed) succeeds:
                slot.value = value
                slot.sequence.store(pos + 1, release)  // signal slot is readable
                return Ok

        elif diff < 0:
            return Err(FULL)       // queue full

        // diff > 0 — another producer advanced head, retry with new pos

Dequeue:

pop():
    loop:
        pos  = tail.load(relaxed)
        slot = slots[pos & mask]
        seq  = slot.sequence.load(acquire)
        diff = seq as i64 - (pos + 1) as i64

        if diff == 0:
            // slot has data written for this position
            if tail.compare_exchange(pos, pos+1, relaxed, relaxed) succeeds:
                value = slot.value
                slot.sequence.store(pos + mask + 1, release)  // mark slot reusable
                return Ok(value)

        elif diff < 0:
            return Err(EMPTY)

        // another consumer advanced tail, retry

The sequence number eliminates the ABA problem entirely — it is a monotonically increasing counter, not a pointer, so it can never “wrap back” to a previous value within the useful lifetime of the queue. It also makes the algorithm wait-free on the fast path — no retry loop when there is no contention.

The release store after writing the value, paired with the acquire load of the sequence number by the consumer, ensures the value write is visible before the consumer reads it — without a full memory fence.

Async Park / Wake — The Channel Blocking Protocol

When a consumer finds the channel empty, it must park until a producer adds an item. The protocol must handle the race between “checking empty” and “going to sleep” — a producer may add an item between these two steps.

async fn recv(channel):
    loop:
        if let Some(value) = channel.try_pop():
            return value                    // fast path — no suspension

        waiter = Waiter(waker: cx.waker.clone(), value: None)
        channel.waiters.push(waiter_ptr)    // register intent to wait

        // critical: check again after registering
        if let Some(value) = channel.try_pop():
            channel.waiters.remove(waiter_ptr)
            return value                    // item arrived before we parked

        return Pending                      // safe to park — waker is registered

The second try_pop after registering the waiter closes the window. If a producer pushed between the first try_pop and the waiters.push, the item is in the queue and the second try_pop finds it. If the producer pushes after waiters.push, it will see the waiter and call its waker. Either way, no item is missed and no wakeup is lost.

On the producer side:

fn send(channel, value):
    channel.push(value)
    if let Some(waiter) = channel.waiters.pop():
        waiter.waker.wake()                 // wake one waiting consumer

The ordering is: push value first, then check waiters. This matches the consumer’s ordering: register waiter first, then check for value. This opposing ordering is what prevents the race — you cannot simultaneously miss both the value (consumer checks before push) and the waker (producer checks before registration).

8. Backpressure Algorithms

Token Bucket — Rate Limiting with Burst

The token bucket allows bursts up to a capacity while enforcing a long-term average rate. It is the correct algorithm for rate limiting async producers.

struct TokenBucket:
    tokens:       f64            // current token count
    capacity:     f64            // maximum tokens (burst size)
    refill_rate:  f64            // tokens per nanosecond
    last_refill:  Instant

acquire(cost):
    now = Instant::now()
    elapsed = now - last_refill
    tokens = min(capacity, tokens + elapsed * refill_rate)
    last_refill = now

    if tokens >= cost:
        tokens -= cost
        return Allow
    else:
        wait_time = (cost - tokens) / refill_rate
        return Deny(wait_time)    // caller may sleep wait_time and retry

The refill is lazy — tokens accumulate based on elapsed time, computed only when acquire is called. No background thread needed. This makes it efficient for async contexts where a background timer would require task spawning.

The burst capacity is what makes token bucket correct for network traffic: a connection that was idle for 1 second at a 1MB/s rate has banked 1MB of burst capacity. It can send 1MB immediately rather than being artificially throttled to 1KB/ms.

GCRA — Generic Cell Rate Algorithm

GCRA is mathematically equivalent to a token bucket but implemented with a single variable — the “theoretical arrival time” (TAT) of the next allowed request:

struct GCRA:
    tat:        AtomicU64        // theoretical arrival time, nanoseconds
    emission:   u64              // nanoseconds between emissions = 1/rate
    burst:      u64              // burst tolerance in nanoseconds = capacity/rate

check(cost_cells):
    increment = cost_cells * emission
    now       = current_time_ns()

    loop:
        old_tat = tat.load(acquire)
        new_tat = max(old_tat, now) + increment

        if new_tat - now > burst:
            wait = new_tat - increment - now + burst
            return Deny(wait)

        if tat.compare_exchange(old_tat, new_tat, acq_rel, acquire) succeeds:
            return Allow
        // CAS failed — another request updated tat concurrently, retry

GCRA’s advantage over token bucket in async contexts: the entire state is one 64-bit integer. The CAS makes it lock-free — multiple async tasks can check the rate limiter concurrently without a mutex. A token bucket with two fields (tokens + timestamp) requires two atomic updates, which cannot be done atomically together without a mutex or CAS on a 128-bit value.

Adaptive Concurrency Limiting — Little’s Law

Static concurrency limits (semaphores with fixed N) fail under variable load — too low means wasted capacity, too high means overload. Adaptive limiters use queuing theory to self-tune.

Little’s Law: L = λW — the average number of requests in the system equals the arrival rate times the average latency. Rearranging: λ = L / W — the maximum throughput equals concurrency divided by latency.

struct AdaptiveLimiter:
    limit:         AtomicU64       // current concurrency limit
    in_flight:     AtomicU64       // current in-flight requests
    min_rtt:       f64             // minimum observed round-trip time (no-load baseline)
    ewma_rtt:      f64             // smoothed current RTT

on_request_start():
    if in_flight.load() >= limit.load():
        return Reject

    in_flight.fetch_add(1)
    start_time = now()
    return Token(start_time)

on_request_end(token):
    rtt = now() - token.start_time
    in_flight.fetch_sub(1)

    ewma_rtt = alpha * rtt + (1-alpha) * ewma_rtt
    min_rtt  = min(min_rtt, rtt)   // decays slowly over time

    // Vegas algorithm: compute gradient
    gradient = 1.0 - (min_rtt / ewma_rtt)

    new_limit = limit * (1 + beta * gradient)
    new_limit = clamp(new_limit, min_limit, max_limit)
    limit.store(new_limit)

When ewma_rtt ≈ min_rtt: gradient ≈ 0 → limit holds steady — system is not overloaded. When ewma_rtt >> min_rtt: gradient → 1 → limit decreases — queuing is occurring, back off. When ewma_rtt improves: gradient becomes negative → limit increases — capacity available.

This self-tunes without any configuration. Netflix’s Concurrency Limiter, Google’s gRPC adaptive throttling, and Envoy’s adaptive concurrency filter all use variations of this approach.

9. Cancellation Internals

Two-Phase Cancel — The Correct Protocol

Cancellation has two distinct phases that must not be collapsed into one. Collapsing them creates races.

struct CancellationToken:
    state:     AtomicU8            // ALIVE=0, CANCELLING=1, CANCELLED=2
    callbacks: Mutex<List<Fn()>>   // registered cancel callbacks

// Phase 1: signal
cancel():
    if not state.compare_exchange(ALIVE, CANCELLING, acq_rel, acquire):
        return                     // already cancelled

    lock(callbacks.mutex)
    cbs = take(callbacks.list)
    unlock(callbacks.mutex)

    for cb in cbs:
        cb()                       // wake all registered waiters

    state.store(CANCELLED, release)

// Registration — must handle concurrent cancel
register(callback):
    lock(callbacks.mutex)
    if state.load(relaxed) == ALIVE:
        callbacks.list.push(callback)
        unlock(callbacks.mutex)
        return Registration(token_ptr, callback_ptr)
    else:
        unlock(callbacks.mutex)
        callback()                 // already cancelled — fire immediately
        return AlreadyCancelled

The lock on the callbacks list during both registration and the cancel flush is essential. Without it, a cancel that fires between the state check and the push in register would walk an empty list (missing the new callback) while register completes the push into a list nobody will ever walk again.

The two-phase state transition (ALIVE → CANCELLING → CANCELLED) allows in-flight operations to distinguish “cancel in progress” from “cancel complete” — useful for operations that must check whether cleanup is still necessary.

Cancellation Safety Analysis

Not every async operation is safe to cancel at every suspension point. The classification:

Cancellation-safe: operation can be dropped at any point without inconsistent state.

  • Reading from a socket: data stays in kernel buffer, re-readable later
  • A sleep/timer: timer simply never fires
  • A pure computation with no side effects

Not cancellation-safe: dropping mid-operation leaves inconsistent state.

  • Writing a framed message: may have written the header but not the body — peer receives corrupt stream
  • A database transaction: may have executed some statements but not committed or rolled back
  • A file write with fsync: may have written without syncing — data loss on crash
// Unsafe pattern:
async fn send_message(conn, msg):
    await conn.write(header(msg))    // SUSPEND POINT — if cancelled here,
    await conn.write(body(msg))      // body never sends — peer gets truncated message

// Safe pattern:
async fn send_message(conn, msg):
    buf = concat(header(msg), body(msg))
    await conn.write_all(buf)        // atomic from cancel perspective — one operation

The rule: an async operation is cancellation-safe if and only if the only observable effect of cancellation is “the operation did not complete” — not “the operation partially completed.” Partial completion is always worse than no completion because it leaves the system in an inconsistent state that is harder to recover from than a clean failure.

Structured concurrency enforces this discipline by making cancellation the default behavior when a scope exits — you are forced to reason about every operation’s cancellation safety upfront, not when debugging a production incident.

Conclusion

Modern async runtimes are not built on a single mechanism; they are the result of several carefully engineered layers working in concert. The event loop decides what runs next, the I/O backend determines when external work is ready, the promise and future machinery preserve compositional structure, coroutines preserve execution state across suspension points, and the scheduler ensures that all of it moves forward without wasting threads or violating fairness. Around these core pieces sit wakers, channels, backpressure controls, and cancellation protocols, each solving a specific failure mode that emerges under real load.

The important lesson is that async is not “magic parallelism.” It is a discipline of deferred execution. Every suspension point is a contractual boundary: state must be preserved, wakeups must not be lost, ordering must remain stable, and progress must be driven explicitly. When any one of those guarantees is violated, the result is not just slower code — it is incorrect code, often in subtle ways that only appear under contention, latency spikes, or cancellation races.

That is why understanding internals matters. A surface-level grasp of async/await is enough to write code that compiles, but not enough to write code that scales, remains responsive, and fails predictably. The real value of studying these internals is that it gives you a systems-level model: you can see where latency comes from, why tasks stall, how memory moves through the runtime, and which invariants must hold for the whole machine to stay correct.

In practice, this model changes how you debug and design. You stop treating hangs as mysteries and start tracing them back to missed wakeups, blocking work, queue pressure, or cancellation races. You stop treating performance issues as vague “async overhead” and start identifying whether the bottleneck is scheduling, I/O submission, timer churn, or contention in shared state. That shift — from abstraction to mechanism — is the difference between using async and understanding it.

If there is one overarching idea to keep, it is this: async runtimes do not eliminate complexity; they relocate it into explicit structures and rules. The abstraction is elegant because the machinery beneath it is strict. Once you understand that machinery, async code becomes far less magical and far more controllable.


메타데이터
post_id
0341fa5e2b7a
slug
the-physics-of-async-systems-0341fa5e2b7a
url
https://blog.stackademic.com/the-physics-of-async-systems-0341fa5e2b7a
canonical_url
https://blog.stackademic.com/the-physics-of-async-systems-0341fa5e2b7a
author_url
https://medium.com/@scaibu
status
ok
fetched_at
2026-07-15 12:09:26