Skip to content

Core Concepts

github-actions[bot] edited this page Jul 19, 2026 · 34 revisions

Core Concepts

This page explains the fundamental concepts behind Elio's design.

Coroutines and Tasks

Elio uses C++20 coroutines as the foundation for async programming. A coroutine is a function that can suspend and resume execution.

The task<T> Type

coro::task<T> is the primary coroutine type in Elio:

#include <elio/coro/task.hpp>

// A task that returns an int
coro::task<int> compute() {
    co_return 42;
}

// A task that returns nothing
coro::task<void> do_work() {
    int result = co_await compute();
    ELIO_LOG_INFO("Result: {}", result);
    co_return;
}

task<T> is a move-only, single-shot lazy owner. Moving it transfers the unstarted coroutine frame and leaves the source empty, which allows lazy tasks to be stored in containers or passed through move-only callables. The task object does not represent running work: once ownership is transferred to the scheduler, use the join_handle<T> returned by spawn() when the work must be observed. Moving a task never migrates a running coroutine or pending I/O.

Runtime policy is deliberately separate from this lazy owner. Every promise_base holds a shared_ptr<task_execution_context>, which stores task-chain cancellation authority, caller-requested affinity, the internal worker-local flag, and operation-local I/O pin diagnostics. A scheduler-created join state shares that context with its wrapper promise, so the policy state can remain valid after frame destruction without a raw promise pointer. The context neither owns nor keeps the coroutine frame alive. Awaitables continue to own each pending operation's completion and cleanup state; those state machines are not moved into the task-wide context. While a worker-local I/O operation is pending, its operation state holds an io_operation_guard. The guard records the owning worker and I/O context generation in the shared task context without transferring operation ownership to the task object.

Awaiting Tasks

Use co_await to wait for a task to complete:

coro::task<void> example() {
    // Sequential execution
    int a = co_await compute();
    int b = co_await compute();
    
    ELIO_LOG_INFO("Sum: {}", a + b);
    co_return;
}

Scheduler

The scheduler manages coroutine execution across multiple threads.

Using async_main (Recommended)

The simplest way to run async code is with ELIO_ASYNC_MAIN:

#include <elio/elio.hpp>

coro::task<int> async_main(int argc, char* argv[]) {
    // Access command line arguments
    if (argc > 1) {
        std::cout << "Argument: " << argv[1] << std::endl;
    }

    // Your async code here
    co_return 0;
}

ELIO_ASYNC_MAIN(async_main)

For async_main functions returning void:

coro::task<void> async_main(int argc, char* argv[]) {
    co_await do_work();
    co_return;
}

ELIO_ASYNC_MAIN(async_main)  // Same macro handles void return type

For simple programs without command line arguments:

coro::task<int> async_main() {
    co_return 0;
}

ELIO_ASYNC_MAIN(async_main)  // Same macro handles no-argument signature

Note: ELIO_ASYNC_MAIN uses compile-time dispatch to detect the callable's arity and return type, supporting all four combinations: task<int>(argc,argv), task<void>(argc,argv), task<int>(), task<void>().

Using elio::run()

For more control, use elio::run() directly:

int main(int argc, char* argv[]) {
    // Run with default thread count (hardware concurrency)
    return elio::run(async_main, argc, argv);
}

// Or use run_config for full control
int main(int argc, char* argv[]) {
    elio::run_config config;
    config.num_threads = 4;  // 4 worker threads
    
    return elio::run(config, async_main, argc, argv);
}

Manual Scheduler Control

For advanced use cases, you can manage the scheduler manually:

#include <elio/runtime/scheduler.hpp>

// Create with N worker threads
runtime::scheduler sched(4);

// Start the scheduler
sched.start();

// Spawn tasks (pass callable, not invoked task)
sched.go(my_coroutine);

// Shutdown when done
sched.shutdown();

Task Spawning

Elio provides several ways to spawn concurrent tasks:

Fire-and-Forget with elio::go()

Use elio::go() to spawn a task that runs independently:

coro::task<void> background_work() {
    // This runs in the background
    co_return;
}

coro::task<void> main_task() {
    // Spawn and continue immediately (don't wait)
    elio::go(background_work);
    
    // Lambda with captures is also safe
    int value = 42;
    elio::go([value]() -> coro::task<void> {
        // 'value' is safely copied into the coroutine frame
        co_return;
    });
    
    // Continue with other work...
    co_return;
}

Joinable Tasks with elio::spawn()

Use elio::spawn() to get a join_handle that lets you await the result later:

coro::task<int> compute(int x) {
    co_return x * 2;
}

coro::task<void> parallel_example() {
    // Spawn multiple tasks concurrently
    auto h1 = elio::spawn(compute, 10);
    auto h2 = elio::spawn(compute, 20);
    auto h3 = elio::spawn(compute, 30);
    
    // All three run in parallel
    // Now wait for results
    int a = co_await h1;  // 20
    int b = co_await h2;  // 40
    int c = co_await h3;  // 60
    
    ELIO_LOG_INFO("Sum: {}", a + b + c);  // 120
    co_return;
}

Each spawn() call has an independent cancellation context. A join handle can request cancellation without retaining or addressing the coroutine frame:

coro::task<void> background_request() {
    auto token = coro::this_coro::cancel_token();
    auto result = co_await time::sleep_for(30s, token);
    if (result == coro::cancel_result::cancelled) {
        co_return;
    }
}

coro::task<void> controller() {
    auto handle = elio::spawn(background_request);
    handle.request_cancel();
    co_await handle;  // Still join: cancellation is not forced destruction.
}

request_cancel() is safe when task completion or frame destruction races with the request. It does not guarantee prompt completion, overwrite a result that already completed, or cancel other independently spawned tasks.

Affinity Spawn with elio::go_to()

Use elio::go_to() to spawn a task with affinity to a specific worker thread. The task is placed on the target worker's queue with affinity set before it first resumes. If a steal attempt later observes the task on another queue, the scheduler requeues it to the affinity worker instead of executing it on the wrong worker.

For deterministic placement, pass a worker id in [0, scheduler.num_threads()). Out-of-range ids are not rejected, but they are treated as scheduler fallback behavior rather than a precise pinning contract.

coro::task<void> connection_handler(int fd) {
    // This entire task runs on the designated worker
    co_return;
}

coro::task<void> accept_loop() {
    int worker = 0;
    while (auto fd = co_await accept(...)) {
        // Round-robin connections across workers
        elio::go_to(worker % num_workers, connection_handler, fd);
        ++worker;
    }
}

This is useful when you need cache locality or want to partition work across workers at spawn time, without the brief migration window that go() + set_affinity() would have.

Checking Completion

You can check if a spawned task is done without blocking:

coro::task<void> poll_example() {
    auto handle = elio::spawn(slow_operation);
    
    while (!handle.is_ready()) {
        // Do other work while waiting
        co_await do_something_else();
    }
    
    auto result = co_await handle;
    co_return;
}

Work Stealing

Elio uses a work-stealing scheduler for load balancing. Each worker thread has a local queue, and idle workers steal tasks from busy workers.

Efficient Idle Waiting

When workers have no tasks to execute, they enter an efficient sleep state instead of busy-waiting:

  • Unified wake mechanism: Each worker's I/O backend contains an eventfd that external threads can write to, waking the worker from its I/O poll
  • Configurable wait strategy: Workers can spin before blocking (for low-latency) or block immediately (for CPU efficiency)
  • Automatic wake-up: When tasks are scheduled to a worker, it is automatically woken via eventfd write to the I/O backend
  • IO integration: Workers poll the IO backend (io_uring/epoll) before sleeping

This design ensures:

  • Near-zero CPU usage when idle with default blocking strategy (< 1%)
  • Configurable spin-before-block for low-latency workloads
  • Fast wake-up latency when new work arrives
  • Efficient coordination between task scheduling and IO polling

Thread Affinity

Tasks can request a specific worker thread using the affinity API. The scheduler respects affinity during stealing: a stolen affinity task is requeued to its bound worker instead of being executed by the thief.

#include <elio/runtime/affinity.hpp>

coro::task<void> affinity_example() {
    // Bind to worker 2 and migrate there immediately
    co_await elio::runtime::set_affinity(2);

    // Bind to the current worker (prevent migration)
    co_await elio::runtime::bind_to_current_worker();

    // Remove caller affinity (migration is allowed when no I/O pin is active)
    co_await elio::runtime::clear_affinity();

    co_return;
}

All three functions are also available in the elio namespace as convenience aliases (elio::set_affinity, elio::bind_to_current_worker, elio::clear_affinity).

set_affinity accepts an optional second parameter migrate (default true). When true, the coroutine is immediately rescheduled on the target worker. When false, the affinity is recorded but the coroutine continues on its current worker until its next suspension point.

Caller affinity and I/O ownership are independent. An active I/O pin is the effective scheduler affinity and takes precedence over a caller request. clear_affinity() clears only caller affinity; it cannot move a pending operation or make its continuation runnable on a different backend owner. Affinity changes made during pending I/O become effective after that operation reaches a terminal backend completion.

Design Rationale

Why work-stealing. A centralized task queue becomes a contention bottleneck under high concurrency. Work-stealing distributes scheduling decisions: each worker thread operates on its own local deque, and idle workers steal from busy ones. This provides automatic load balancing without requiring a central coordinator. The owner-side LIFO order also has a cache-locality benefit -- the most recently pushed task is the one most likely to have hot data in the current core's cache.

Why Chase-Lev deque. The Chase-Lev algorithm gives the owner thread lock-free push and pop operations with no atomic read-modify-write on the fast path. Contention only occurs when the deque has a single element and both the owner and a thief attempt to take it simultaneously. The deque also supports dynamic resizing by replacing the underlying circular buffer, which avoids the need to pre-allocate a fixed upper bound.

Why MPSC inbox for external submissions. Cross-thread task submissions (e.g., spawning a task onto a specific worker from another thread) go through a bounded MPSC ring buffer rather than directly into the Chase-Lev deque. This separation keeps the deque's invariants simple -- only the owner ever pushes -- and the bounded capacity with cache-line aligned slots (alignas(64)) eliminates false sharing between producers and the consumer. If a sustained burst fills the ring after bounded retries, submissions spill into a locked per-worker overflow queue. This rare slow path preserves worker affinity and borrowed coroutine ownership instead of resuming on the submitting thread.

Vthreads And The Virtual Stack

An Elio vthread is the logical asynchronous execution represented by a root task and its chain of awaited coroutine tasks. It is not an operating system thread and it is not a coroutine-frame allocation arena. A vthread may suspend and resume on scheduler workers, and may migrate between workers when its affinity and I/O ownership permit that movement.

C++20 stackless coroutines do not maintain a call stack in the traditional sense. When a coroutine suspends, the compiler-generated frame is stored on the heap, but the chain of callers that led to that suspension point is lost. This makes debugging difficult -- tools like gdb bt show the scheduler's dispatch loop rather than the logical call chain of coroutines.

Elio reconstructs this information through a virtual stack: an intrusive linked list of promise_base objects connected by parent_ pointers. The current_frame_ thread-local identifies the frame executing on a thread. A lazy task<T> does not remain installed merely because its frame is owned; when it is awaited, its parent_ is rebound to the actual awaiting coroutine for that execution chain. Scheduler, synchronization, I/O, and affinity-migration resume paths preserve that parent and install the resumed frame for the duration of the resume call. Generator iteration similarly binds the producer to its active consumer and restores the consumer context before transferring a yielded value or completion. Initial scheduler ownership handoff, including targeted spawn, is separate and detaches construction-time ancestry.

Virtual-stack ancestry itself costs one parent pointer per coroutine frame. Separately, promise_base holds a shared execution-context reference; the context has its own allocation and may outlive the frame when an external runtime owner retains it.

What it enables

  • elio-pstack: A CLI tool that attaches to a running process (or reads a coredump) and walks the virtual stack chains to print coroutine backtraces, similar to pstack for threads.
  • Debugger extensions: elio-gdb.py and elio-lldb.py use the same frame linkage to implement elio bt (backtrace) and elio list (list active coroutines).
  • Exception propagation: When a coroutine throws, unhandled_exception() captures it in the promise. The parent coroutine can then rethrow the exception when it co_awaits the child's result, propagating errors up the logical call chain.

Frame metadata

Each promise_base also carries debug metadata:

  • frame_magic_: A constant (0x454C494F46524D45, ASCII "ELIOFRME") that debuggers use to validate that a memory region is a live coroutine frame.
  • debug_id_: A lazily-allocated unique ID (batch-allocated per thread to avoid global contention).
  • debug_location_: Source file, function name, and line number.
  • debug_state_: Current state (created, running, suspended, completed, failed).

Runtime policy is not debugger metadata. It is referenced through execution_context_; debugger scripts discover that field from symbols but do not decode implementation-specific std::shared_ptr internals.

Coroutine Frame Allocation

coro::task frames use the implementation's standard coroutine heap allocation path in every build. Elio does not pool task frames or require nested frames to be destroyed in LIFO order. A frame may be destroyed on a different worker from the one that created or last resumed it, provided ownership is transferred and the frame is destroyed exactly once.

Frame allocation is independent of the vthread abstraction described above. Vthreads still maintain a logical virtual stack through the promise_base parent chain; that chain tracks coroutine ancestry but does not own or allocate the coroutine frames.

Sanitizer compatibility

AddressSanitizer and ThreadSanitizer observe the same task-frame allocation and deallocation path as normal builds. There is no sanitizer-specific allocator mode, so sanitizer coverage matches production frame lifetime behavior.

I/O Context

The I/O context manages async I/O operations. Each scheduler worker owns one context with a stable worker ID and context generation. Backend submission, cancellation, and polling stay on that worker, so normal I/O does not require a hop through a central reactor.

An awaitable acquires an io_operation_guard in await_suspend() and stores it in backend-visible operation state. Until normal completion, cancellation completion, prepare failure, or orphan cleanup releases that guard, the coroutine cannot be scheduled or stolen onto another worker. Completion releases the pin before resuming the coroutine, so a previously requested user affinity can then take effect. Destroying a suspended coroutine does not release the pin early: the orphaned operation retains it until the backend observes the terminal completion and deletes the operation state.

Pool shrink does not migrate pending I/O. A retiring worker keeps polling its own context until both backend pending work and active operation pins reach zero. A later pool grow reuses the same worker context only after that drain has finished.

Standalone io_context objects have no scheduler owner. Their caller must serialize backend access, drive polling, and keep the context alive. Standard Elio awaitables reject a standalone context from scheduler execution and reject another worker's context. The context's mutating low-level entries also reject cross-owner access; only notify() is a cross-thread wakeup entry. A directly constructed backend that is not owned by an io_context remains a standalone integration surface that its caller must serialize and drive.

When a standard awaitable is used by a coroutine promise that does not derive from promise_base, the backend still resumes completion on its owner worker and context-level accounting still keeps retirement drain alive. Such a promise has no Elio task execution context, so a custom runtime that independently re-enqueues the suspended handle must preserve worker ownership itself.

These ownership rules do not serialize object-level operations. The runtime does not prevent overlapping reads, overlapping writes, or close-versus-I/O on the same stream or fd. Callers must provide that synchronization unless a higher-level type explicitly documents a stronger concurrent-use contract.

Accessing the Current Context

#include <elio/io/io_awaitables.hpp>

// Get the current thread's I/O context (from within a coroutine)
auto& ctx = io::current_io_context();

I/O Backends

Elio supports two I/O backends:

Backend Kernel Version Performance
io_uring Linux 5.1+ Best
epoll Any Linux Good

The backend is selected automatically at compile time based on availability.

Awaitables

Awaitables are types that can be used with co_await. Elio provides several built-in awaitables:

I/O Awaitables

// Read from a socket
auto result = co_await stream.read(buffer, size);

// Write to a socket
auto result = co_await stream.write(data, len);

// Accept a connection
auto stream = co_await listener.accept();

Timer Awaitables

#include <elio/time/timer.hpp>

// Sleep for a duration
co_await time::sleep_for(std::chrono::seconds(1));

// Sleep until a time point
co_await time::sleep_until(deadline);

Synchronization Primitives

Elio provides coroutine-aware synchronization primitives.

Mutex

#include <elio/sync/primitives.hpp>

sync::mutex mtx;

coro::task<void> critical_section() {
    co_await mtx.lock();
    // Protected code here
    mtx.unlock();
    co_return;
}

Shared Mutex (Read-Write Lock)

shared_mutex allows multiple concurrent readers or a single exclusive writer:

sync::shared_mutex rwlock;

// Multiple readers can run concurrently
coro::task<void> reader() {
    co_await rwlock.lock_shared();
    sync::shared_lock_guard guard(rwlock);  // RAII unlock
    // Read shared data
    co_return;
}

// Writers get exclusive access
coro::task<void> writer() {
    co_await rwlock.lock();
    sync::unique_lock_guard guard(rwlock);  // RAII unlock
    // Modify shared data
    co_return;
}

Event

event is a one-shot signaling primitive. One or more coroutines wait for the event to be set:

sync::event evt;

coro::task<void> waiter() {
    co_await evt.wait();
    // Event was set
    co_return;
}

coro::task<void> signaler() {
    // Wake all waiters
    evt.set();
    co_return;
}

Channel

channel<T> provides a multi-producer multi-consumer queue for passing values between coroutines. By default, channel<T> creates a rendezvous (synchronous) channel where send blocks until a matching recv is ready:

// Rendezvous channel (default): sender waits for receiver
sync::channel<int> ch;

// Bounded channel: buffers up to 16 items before back-pressure
sync::channel<int> bch(16);

// Unbounded channel: no back-pressure, may grow indefinitely
auto uch = sync::channel<int>::unbounded();

coro::task<void> producer() {
    co_await ch.send(42);
    co_return;
}

coro::task<void> consumer() {
    auto value = co_await ch.recv();
    // value == 42
    co_return;
}

Semaphore

sync::semaphore sem(10);  // Max 10 concurrent

coro::task<void> limited_work() {
    co_await sem.acquire();
    // Do work
    sem.release();
    co_return;
}

Spinlock

spinlock is a lightweight, non-suspending lock for very short critical sections. It uses a TTAS (Test-and-Test-and-Set) algorithm with CPU pause instructions for efficient spinning:

sync::spinlock sl;

coro::task<void> quick_update() {
    sl.lock();          // Spins until acquired (does not suspend coroutine)
    // Very short critical section
    sl.unlock();
    co_return;
}

Use spinlock_guard for RAII-style locking:

sync::spinlock sl;

coro::task<void> safe_update() {
    sync::spinlock_guard guard(sl);  // Locks on construction
    // Critical section
    co_return;
    // Automatically unlocked on destruction
}

When to use spinlock vs mutex: Use spinlock when the critical section is very short (a few assignments or pointer swaps) and contention is low. Use mutex when the critical section might suspend (e.g., performing I/O) or when contention is high — mutex suspends the coroutine instead of busy-waiting, allowing other coroutines to run.

Cancellation Safety

Coroutine-aware synchronization primitives (event, mutex, semaphore, condition_variable, channel, shared_mutex) embed waiter nodes in the suspended coroutine frame. If a frame is destroyed while its waiter is still linked, the awaiter's destructor removes that node from the primitive's internal waiter list.

This lifetime cleanup is not itself a cancellation API. with_timeout() runs its callable and timer in a structured task group. When the timer wins it requests cooperative cancellation, then waits for the callable to reach a terminal state before returning a timed-out result. The duration is therefore a deadline for requesting cancellation, not a guaranteed return-time bound. The child stops promptly only when it passes the supplied cancel_token or this_coro::cancel_token() to operations that support cancellation. Work that ignores cancellation delays the timeout result until natural completion. If deadline expiry and parent cancellation race before the timer branch publishes its terminal result, best-effort timer cancellation permits either a timed-out result or combinator_cancelled.

mutex::lock(token), semaphore::acquire(token), and event::wait(token) are cancellation-aware and return coro::cancel_result. Cancellation and normal notification atomically select one terminal result. If cancellation wins, a mutex lock or semaphore permit is not acquired. If normal notification wins first, the operation returns completed and the caller owns the acquired resource even if cancellation is requested immediately afterward. The overloads without a token remain non-cancellable and preserve their void await result.

shared_mutex::lock_shared(token), shared_mutex::lock(token), and every condition_variable wait mode now participate in the same terminal cancellation race. A shared-mutex cancellation winner owns no lock. A condition wait that released an associated lock always re-acquires it before returning, including when cancellation wins; a pre-cancelled wait leaves the held lock untouched.

channel::send(value, token) and channel::recv(token) return result objects that carry both cancel_result and the channel's existing sent/value result. A cancellation winner does not transfer the pending send value into the channel or consume a receive value. A normal transfer or channel close can win first; callers distinguish cancellation from closure with was_cancelled() and was_closed().

Key guarantees:

  • Destroying a frame whose waiter is still linked removes that waiter from the primitive's list
  • No manual waiter-list cleanup is required; unlinking happens in the awaiter's destructor
  • Timeout cancellation remains cooperative, requires a token-aware operation, and joins the timed-out child before returning
  • A completed lock or permit acquisition is not rolled back by a later cancellation request
  • A completed channel transfer is not rolled back by a later cancellation request

Condition Variable

condition_variable allows coroutines to wait for a condition to become true. It works with mutex, spinlock, or in an unlocked mode:

With mutex (recommended for most cases):

sync::mutex mtx;
sync::condition_variable cv;
bool ready = false;

coro::task<void> waiter() {
    co_await mtx.lock();
    while (!ready) {
        // Single co_await: wait() returns task<void>
        co_await cv.wait(mtx);
    }
    mtx.unlock();
    co_return;
}

coro::task<void> notifier() {
    co_await mtx.lock();
    ready = true;
    mtx.unlock();
    cv.notify_one();  // Wake one waiter
    co_return;
}

With spinlock:

sync::spinlock sl;
sync::condition_variable cv;
bool ready = false;

coro::task<void> waiter() {
    sl.lock();
    while (!ready) {
        co_await cv.wait(sl);  // Single co_await (spinlock re-lock is synchronous)
    }
    sl.unlock();
    co_return;
}

Unlocked mode (for single-worker scenarios where all coroutines run on the same thread):

sync::condition_variable cv;
bool ready = false;

coro::task<void> waiter() {
    while (!ready) {
        co_await cv.wait_unlocked();
    }
    co_return;
}

notify_one() wakes exactly one waiting coroutine; notify_all() wakes all of them.

Structured Task Groups

Use coro::task_group when a parent task must own the lifetime, cancellation, and failures of multiple scheduler-submitted children. A group uses the current scheduler by default, or one explicit scheduler supplied at construction. All child frames are initially submitted there, and group cancellation, cleanup, and join continuation return there. An awaited operation may still define its own resumption executor; task groups do not override that operation-level policy.

The explicit-scheduler task_scope() overload must initially be awaited on a worker of that scheduler; it does not migrate a foreign scheduler worker or an external thread into the scope.

coro::task<void> refresh_all(std::span<const key> keys) {
    coro::task_group group({.max_concurrency = 4});

    for (const auto& key : keys) {
        group.spawn([&key]() -> coro::task<void> {
            co_await refresh(key, coro::this_coro::cancel_token());
        });
    }

    co_await group.join();
}

The default fail_fast policy records the first child failure, requests cooperative cancellation of unfinished siblings, waits for every child frame, and then rethrows the failure from join(). Set the policy to collect_all to let siblings finish; join() then throws task_group_error, whose failures() view contains every observed exception. A bare group also exposes those records through its own failures() accessor. A nonzero max_concurrency limits how many child bodies execute at once; zero is unlimited.

join() is required exactly once for a bare group. Its destructor can request cancellation but cannot block a scheduler worker to join children. For lexical ownership, use coro::task_scope(): it always joins, and a body exception first cancels children before the scope waits for them. Fail-fast rethrows that body exception; collect-all combines it with child failures in task_group_error. The body runs under the group token, so child fail-fast cancellation wakes token-aware body waits. If a body awaitable resumes elsewhere, scope cleanup first returns to the selected scheduler. Return from the body to initiate joining; do not call join() inside it.

The scope retains the body callable and its captures until all children join. This does not extend automatic local lifetimes inside the body coroutine: those objects are destroyed when the body returns. Children that may continue after body return must capture durable external state or values instead of references to body-coroutine locals.

co_await coro::task_scope(
    [](coro::task_group& group) -> coro::task<void> {
        group.spawn(background_step);
        co_await foreground_step();
    });

Cancellation remains best-effort. Children inherit the group token through their runtime task context, but a child must pass coro::this_coro::cancel_token() into token-aware waits or poll it explicitly. Neither group cancellation nor scope cleanup forcibly destroys token-ignoring work. A child that observes group cancellation before body entry is skipped; cancellation racing with an already-starting body remains cooperative. Cancellation requested by an ordinary external thread, including the thread that called scheduler::start(), is synchronously dispatched onto the group scheduler before the request returns. A task running on another scheduler posts the request asynchronously, so two scheduler workers cannot deadlock by waiting for reciprocal cancellation dispatch. In either case, the selected scheduler must remain running until the group drains.

Cancellation

Elio provides cooperative, best-effort cancellation through explicit cancel_source / cancel_token pairs and through each running task's runtime cancellation context.

Basic Usage

#include <elio/coro/cancel_token.hpp>

coro::task<void> cancellable_work(coro::cancel_token token) {
    while (!token.is_cancelled()) {
        // Do work...
        
        // Cancellable sleep - returns early if cancelled
        auto result = co_await time::sleep_for(100ms, token);
        if (result == coro::cancel_result::cancelled) {
            break;
        }
    }
    co_return;
}

coro::task<void> controller() {
    coro::cancel_source source;
    
    // Start work with a token
    elio::go(cancellable_work, source.get_token());
    
    // Wait some time
    co_await time::sleep_for(5s);
    
    // Cancel the operation
    source.cancel();
    co_return;
}

How It Works

  1. cancel_source - Creates and controls cancellation state
  2. cancel_token - Lightweight handle passed to operations
  3. Operations periodically check token.is_cancelled()
  4. Calling source.cancel() selects registered callbacks for synchronous dispatch

For joinable runtime work, join_handle::request_cancel() publishes through the spawned task's shared execution context. coro::this_coro::cancel_token() reads that context from the currently executing Elio frame. A lazy child directly awaited by another Elio task is linked to that actual awaiter before first resume, so the request flows down the active Elio task chain. The link is one-way: cancelling a child does not cancel its parent, and separate spawn() calls remain independent. A foreign coroutine promise is a cancellation boundary unless adapter code deliberately bridges a token.

Outside an active Elio runtime frame, this_coro::cancel_token() returns a default never-cancelled token. An explicit token parameter remains an independent API boundary; runtime cancellation does not silently cancel an unrelated cancel_source. Framework or application code must deliberately choose which token to pass or bridge.

Cancellable Operations

Many Elio operations support cancellation:

// Sleep with cancellation
auto result = co_await time::sleep_for(1s, token);

// HTTP request with cancellation
auto response = co_await client.get(url, token);

// RPC call with cancellation
auto result = co_await rpc_client->call<Method>(req, timeout, token);

// WebSocket connect with cancellation
bool connected = co_await ws_client.connect(ws_url, token);

// WebSocket receive with cancellation
auto msg = co_await ws_client.receive(token);

// SSE connect with cancellation
bool listening = co_await sse_client.connect(sse_url, token);

// SSE event receive with cancellation
auto event = co_await sse_client.receive(token);

For client networking operations, cancellation is wired into pending I/O or the operation's protocol. Cancelling the token passed to an RPC call returns rpc_error::cancelled locally and sends a best-effort RPC cancel frame when the request was already sent; context-aware server handlers observe that through rpc_context::cancel_token. Cancelling the token passed to an HTTP request, WebSocket connect(), or SSE connect() can abort TCP connect, TLS handshake, request write, and response header/body reads. Cancelling the token passed to WebSocket or SSE receive() can abort a blocked frame/event read. These operations report cancellation via their normal failure return and set errno to ECANCELED where applicable.

Cancellation is a request, not rollback. An operation that completes before or during cancellation may report its actual completion, and already-produced I/O side effects remain. Backends that cannot promptly abort an accepted operation may wait for its natural terminal completion. Task-level cancellation also does not wake a synchronization primitive that has no token-aware wait overload.

Implementing Cancellable Operations

Register callbacks to respond to cancellation:

coro::task<void> custom_operation(coro::cancel_token token) {
    // Register a callback
    auto reg = token.on_cancel([&]() {
        // Cleanup or signal early exit
    });
    
    // Do work...
    
    // Registration automatically unregisters on destruction
    co_return;
}

Callbacks run synchronously on the thread that requests cancellation. Callback exceptions do not stop the remaining dispatch; afterward, cancel() (and a join_handle::request_cancel() using that context) rethrows the first exception. Destroying or unregistering a registration suppresses work not yet selected by cancellation. Outside callback dispatch, teardown waits once another thread has selected or started the callback. During callback reentry, cross-dispatch teardown is deferred instead of waiting, which prevents mutually unregistering callbacks from deadlocking while dispatcher ownership keeps the target payload alive. It is not a join point for externally owned captured state. Self- unregistration and removal of a later callback during the same synchronous dispatch are also supported. Callback code must handle reentry and synchronize shared mutable state.

Error Handling

Elio uses io_result for I/O operation results. io_result contains an int32_t result field (bytes transferred or negative errno) and a uint32_t flags field. Use result.success() to check for errors, result.bytes_transferred() for byte count, and result.error_code() for the errno value. Factory methods like tcp_listener::bind() use std::optional.

coro::task<void> handle_errors() {
    auto result = co_await stream.read(buffer, size);
    
    if (result.result > 0) {
        // Success - result.result contains bytes read
    } else if (result.result == 0) {
        // EOF - connection closed
    } else {
        // Error - result.result is negative errno
        ELIO_LOG_ERROR("Read error: {}", strerror(-result.result));
    }
    co_return;
}

For factory methods like tcp_listener::bind(), check for std::nullopt and use errno to get the error code:

auto listener = tcp_listener::bind(ipv4_address(port));
if (!listener) {
    ELIO_LOG_ERROR("Bind failed: {}", strerror(errno));
    co_return;
}

Logging

Elio includes a built-in logging system:

#include <elio/log/macros.hpp>

ELIO_LOG_DEBUG("Debug message");
ELIO_LOG_INFO("Info: value={}", 42);
ELIO_LOG_WARNING("Warning!");
ELIO_LOG_ERROR("Error: {}", strerror(errno));

// Set log level
log::logger::instance().set_level(log::level::debug);

Next Steps

Clone this wiki locally