Concurrent Composition

This section explains how to run multiple tasks concurrently using when_all and when_any.

Prerequisites

Overview

Sequential execution—one task after another—is the default when using co_await:

task<> sequential()
{
    co_await task_a();  // Wait for A
    co_await task_b();  // Then wait for B
    co_await task_c();  // Then wait for C
}

For independent operations, concurrent execution is more efficient:

task<> concurrent()
{
    // Run A, B, C simultaneously
    co_await when_all(task_a(), task_b(), task_c());
}

when_all: Wait for All Tasks

when_all launches multiple io_task children concurrently and waits for all of them to complete. It returns task<io_result<R1, R2, …​, Rn>>, a single ec plus the flattened payloads:

#include <boost/capy/when_all.hpp>

io_task<int> fetch_a() { co_return io_result<int>{{}, 1}; }
io_task<int> fetch_b() { co_return io_result<int>{{}, 2}; }
io_task<std::string> fetch_c() { co_return io_result<std::string>{{}, "hello"}; }

task<> example()
{
    auto [ec, a, b, c] = co_await when_all(fetch_a(), fetch_b(), fetch_c());

    // ec == std::error_code{} (success)
    // a == 1
    // b == 2
    // c == "hello"
}

Result Type

when_all returns io_result<R1, …​, Rn> where each Ri is the child’s payload flattened: io_result<T> contributes T, io_result<> contributes tuple<>. Check ec first; values are only meaningful when !ec.

Void io_tasks

io_task<> children contribute tuple<> to the result:

io_task<> void_task() { co_return io_result<>{}; }
io_task<int> int_task() { co_return io_result<int>{{}, 42}; }

task<> example()
{
    auto [ec, a, b, c] = co_await when_all(int_task(), void_task(), int_task());
    // a == 42       (int)
    // b == tuple<>  (from void io_task)
    // c == 42       (int)
}

When all children are io_task<>, just check r.ec:

task<> example()
{
    auto r = co_await when_all(void_task_a(), void_task_b());
    if (r.ec)
        // handle error
}

Error Handling

I/O errors are reported through the ec field of the io_result. When any child returns a non-zero ec:

  1. Stop is requested for sibling tasks

  2. All tasks complete (or respond to stop)

  3. The first ec is propagated in the outer io_result

task<> example()
{
    auto [ec, a, b] = co_await when_all(task_a(), task_b());
    if (ec)
        std::cerr << "Error: " << ec.message() << "\n";
}

If a task throws an exception, it is captured and rethrown after all tasks complete. Exceptions take priority over ec.

io_task<int> might_throw(bool fail)
{
    if (fail)
        throw std::runtime_error("failed");
    co_return io_result<int>{{}, 42};
}

task<> example()
{
    try
    {
        co_await when_all(might_throw(true), might_throw(false));
    }
    catch (std::runtime_error const& e)
    {
        // Catches the exception from the failing task
    }
}

Stop Propagation

When one task fails, when_all requests stop for its siblings. Well-behaved tasks should check their stop token and exit promptly:

io_task<> long_running()
{
    auto token = co_await this_coro::stop_token;

    for (int i = 0; i < 1000; ++i)
    {
        if (token.stop_requested())
            co_return io_result<>{};  // Exit early when sibling fails

        co_await do_iteration();
    }
    co_return io_result<>{};
}

when_any: First-to-Succeed Wins

when_any launches multiple io_task children concurrently and returns when the first one succeeds (!ec):

#include <boost/capy/when_any.hpp>

task<> example()
{
    auto result = co_await when_any(
        fetch_int(),     // io_task<int>
        fetch_string()   // io_task<std::string>
    );
    // result is std::variant<std::error_code, int, std::string>
    // index 0: all tasks failed (error_code)
    // index 1: fetch_int won
    // index 2: fetch_string won
}

The result is a variant with error_code at index 0 (failure/no winner) and one alternative per input task at indices 1..N. Only tasks returning !ec can win; errors and exceptions do not count as winning. When a winner is found, stop is requested for all siblings. All tasks complete before when_any returns.

Errors Do Not Win (wait_for_one_success)

A child that returns a non-zero ec (or throws) does not win, and it does not cancel its siblings. when_any keeps waiting until some child succeeds or until every child has finished. Only when all children fail does the result settle at index 0, holding an error_code.

If you need "complete on the first child to finish, success or error," that behavior is opt-in — wrap the child as shown below.

Treating an Error as a Win

To make a child win on an error, wrap it so the error becomes a success before when_any sees it.

The first pattern translates a specific, benign error into success. Other errors propagate unchanged, so they still do not win:

// canceled is benign here: translate it to success so when_any picks this child.
io_task<> wrapped()
{
    auto [ec] = co_await inner();
    if (ec == cond::canceled)
        co_return io_result<>{};   // success: when_any sees a winner
    co_return io_result<>{ec};     // propagate other errors unchanged
}

The second pattern lifts the inner ec into the payload. The wrapper always succeeds, so it wins on its first completion, carrying the original error code to the caller:

// Always succeeds; the winner's payload carries the original ec.
io_task<std::error_code> wrapped()
{
    auto [ec] = co_await inner();
    co_return io_result<std::error_code>{{}, ec};
}

// when_any(wrapped(), ...) -> variant<error_code, std::error_code, ...>
//   index 0: every child failed
//   index i: child i won; std::get<i>(result) is its original ec

Practical Patterns

Parallel Fetch

Fetch multiple resources simultaneously:

io_task<page_data> fetch_page_data(std::string url)
{
    auto [ec, header, body, sidebar] = co_await when_all(
        fetch_header(url),
        fetch_body(url),
        fetch_sidebar(url)
    );
    if (ec)
        co_return io_result<page_data>{ec, {}};

    co_return io_result<page_data>{{}, {
        std::move(header),
        std::move(body),
        std::move(sidebar)
    }};
}

Fan-Out/Fan-In

Process items in parallel, then combine results using the range overload:

io_task<int> process_item(item const& i);

task<int> process_all(std::vector<item> const& items)
{
    std::vector<io_task<int>> tasks;
    for (auto const& item : items)
        tasks.push_back(process_item(item));

    auto [ec, results] = co_await when_all(std::move(tasks));
    if (ec)
        co_return 0;

    int total = 0;
    for (auto v : results)
        total += v;
    co_return total;
}

Asynchronous Sleep

delay is the awaitable counterpart to std::this_thread::sleep_for. Instead of blocking the thread, it suspends the current coroutine until the duration elapses, leaving the thread free to run other coroutines in the meantime:

#include <boost/capy/delay.hpp>

task<> example()
{
    auto [ec] = co_await delay(100ms);
    // 100ms have elapsed; other coroutines ran on this thread while we waited
}

A thread is not consumed per sleeping coroutine. All concurrently sleeping coroutines on the same execution context share a single timer thread, so a thousand simultaneous delay() calls cost one thread, not a thousand.

delay is cancellable. If the environment’s stop token is activated before the deadline, the coroutine resumes early with ec set to error::canceled (compare with cond::canceled); otherwise ec is clear. A zero or negative duration completes synchronously without scheduling a timer.

Timeout

The timeout combinator races an awaitable against a deadline. It is built directly on delay — the inner awaitable is run against a delay of the given duration, and whichever completes first cancels the other:

#include <boost/capy/timeout.hpp>

task<> example()
{
    auto [ec, n] = co_await timeout(sock.read_some(buf), 50ms);
    if (ec == cond::timeout)
    {
        // deadline expired before read completed
    }
}

timeout returns the same io_result type as the inner awaitable. On timeout, ec is set to error::timeout and payload values are default-initialized. Unlike when_any, exceptions from the inner awaitable are always propagated and never swallowed by the timer.

Implementation Notes

Task Storage

when_all stores all tasks in its coroutine frame. Tasks are moved from the arguments, so the original task objects become empty after the call.

Completion Tracking

A shared atomic counter tracks how many tasks remain. Each task completion decrements the counter. When it reaches zero, the parent coroutine is resumed.

Runner Coroutines

Each child task is wrapped in a "runner" coroutine that:

  1. Receives context (executor, stop token) from when_all

  2. Awaits the child task

  3. Stores the result in shared state

  4. Signals completion

This design ensures proper context propagation to all children.

Reference

Header Description

<boost/capy/when_all.hpp>

Concurrent composition with when_all

<boost/capy/when_any.hpp>

First-completion racing with when_any

<boost/capy/delay.hpp>

Asynchronous sleep that suspends instead of blocking the thread

<boost/capy/timeout.hpp>

Race an awaitable against a deadline

<boost/capy/timeout.hpp>

Race an awaitable against a deadline

You have now learned how to compose tasks concurrently with when_all and when_any. In the next section, you will learn about frame allocators for customizing coroutine memory allocation.