The task Type

task<T> is declared in:

#include <boost/capy/task.hpp>

How this manual names headers. Each page gives the specific header for what it introduces, so you can include only what you use. The runnable examples instead include the umbrella header, which pulls in everything:

#include <boost/capy.hpp>

Overview

task<T> is Capy’s primary coroutine return type. It represents an asynchronous operation that eventually produces a value of type T (or nothing, for task<void>).

Key characteristics:

  • Lazy execution — The coroutine does not start until awaited

  • Symmetric transfer — Efficient resumption without stack accumulation

  • Executor inheritance — Inherits the caller’s executor unless explicitly bound

  • Stop token propagation — Forward-propagates cancellation signals

  • HALO support — Enables Heap Allocation eLision Optimization when possible

Declaring task Coroutines

Any function that returns task<T> and contains coroutine keywords (co_await, co_return) is a task coroutine:

#include <boost/capy.hpp>
using namespace boost::capy;

task<int> compute_value()
{
    co_return 42;
}

task<std::string> fetch_greeting()
{
    co_return "Hello, Capy!";
}

task<> do_nothing()  // task<void>
{
    co_return;
}

The syntax task<> is equivalent to task<void>.

Returning Values with co_return

Use co_return to complete the coroutine and provide its result:

task<int> add(int a, int b)
{
    int result = a + b;
    co_return result;  // Completes with value
}

task<> log_message(std::string msg)
{
    std::cout << msg << "\n";
    co_return;  // Completes without value
}

For task<void>, you can either use co_return; explicitly or let execution fall off the end of the function body.

Reporting Errors: io_result and io_task

I/O operations report an expected failure as a value rather than an exception. They return the error alongside the result, in a io_result<Ts…​>: a std::tuple holding a std::error_code followed by zero or more payload values. Exceptions remain for genuine errors, such as a failed frame allocation.

io_task<Ts…​> names a task returning one of those results:

// io_result<Ts...> holds an error code `ec` plus zero or more payload
// values. io_task<Ts...> is just an alias for task<io_result<Ts...>>.

io_task<> ensure_ready(bool ready)
{
    if(! ready)
        co_return make_error_code(std::errc::not_connected);  // ec converts
    co_return {};                                             // success
}

io_task<std::size_t> count_ready(bool ready)
{
    using result = io_result<std::size_t>;
    if(! ready)
        co_return result{make_error_code(std::errc::not_connected), 0};
    co_return result{std::error_code(), 42};  // success, carrying a value
}

task<> use_them()
{
    // io_result models the tuple protocol: ec first, then the payloads.
    auto [ec, n] = co_await count_ready(true);
    if(ec)
        co_return;  // always check ec first
    (void)n;        // n is only meaningful when ec is falsy
}

Because io_result is a std::tuple, the whole standard tuple API applies: structured bindings, std::tie, std::apply, std::get, std::tuple_cat, comparisons, and tuple assignment. The error code is the first element, so auto [ec, n] = co_await s.read_some(buf); destructures it directly, and std::tie rebinds into existing variables without introducing new ones. Always test the error code before reading a payload; a payload’s meaning when it is set is defined by the operation that produced it.

For io_result<> — no payload — a std::error_code converts implicitly, so co_return some_ec; compiles. With payloads present you must supply the whole result, as count_ready shows.

These two names are the vocabulary the stream concepts and the concurrent combinators are written in. Concurrent Composition and Stream Concepts both assume them.

Running a Task

A task is lazy, so declaring one does not run it. To run a task to completion from ordinary (non-coroutine) code, pass it to run_async with an executor and a completion handler. The task runs on the executor, and its result is delivered to the handler:

        // You have a task; run it on an executor and observe its result.
        thread_pool pool(1);
        auto ex = pool.get_executor();

        int total = 0;
        run_async(ex, [&](int result) {
            std::cout << "Result: " << result << "\n";  // prints 5
            total = result;
        })(add(2, 3));

        pool.join();  // wait for the pooled task to finish

Here add(2, 3) runs on a thread_pool executor, and the completion handler receives the result, 5. The call to pool.join() waits for the pooled work to finish before the result is read.

Awaiting Other Tasks

Tasks can await other tasks using co_await. This is the primary mechanism for composing asynchronous operations:

task<int> step_one()
{
    co_return 10;
}

task<int> step_two(int x)
{
    co_return x * 2;
}

task<int> full_operation()
{
    int a = co_await step_one();  // Suspends until step_one completes
    int b = co_await step_two(a); // Suspends until step_two completes
    co_return b + 5;              // Final result: 25
}

When you co_await a task:

  1. The current coroutine suspends

  2. The awaited task starts executing

  3. When the awaited task completes, the current coroutine resumes

  4. The co_await expression evaluates to the awaited task’s result

Lazy Execution

A critical property of task<T> is lazy execution: creating a task does not start its execution.

task<int> compute()
{
    std::cout << "Computing...\n";  // Not printed until awaited
    co_return 42;
}

task<> example()
{
    auto t = compute();   // Task created, but "Computing..." NOT printed yet
    std::cout << "Task created\n";

    int result = co_await std::move(t);  // NOW "Computing..." is printed
    std::cout << "Result: " << result << "\n";
}

Output:

Task created
Computing...
Result: 42

Lazy execution enables efficient composition—tasks that are never awaited never run, consuming no resources beyond their initial allocation.

Symmetric Transfer

When a task completes, control transfers directly to its continuation (the coroutine that awaited it) using symmetric transfer. This avoids stack accumulation even with deep chains of coroutine calls.

task<> a() { co_await b(); }
task<> b() { co_await c(); }
task<> c() { co_return; }

Without symmetric transfer, each co_await would add a stack frame, potentially causing stack overflow with deep nesting. With symmetric transfer, c returning to b returning to a uses constant stack space regardless of depth.

This is implemented through the await_suspend returning a coroutine handle rather than void:

// Inside task's final_suspend awaiter
std::coroutine_handle<> await_suspend(std::coroutine_handle<>) const noexcept
{
    return continuation_;  // Transfer directly to continuation
}

Move Semantics

Tasks are move-only. Copying a task would create aliasing problems where multiple handles reference the same coroutine frame.

task<int> compute();

task<> example()
{
    auto t1 = compute();
    auto t2 = std::move(t1);  // OK: ownership transferred, t1 is now empty

    // auto t3 = t2;  // Error: task is not copyable

    int result = co_await std::move(t2);
}

After moving, the source task becomes empty and must not be awaited.

Exception Propagation

Exceptions thrown inside a task are captured and rethrown when the task is awaited:

task<int> might_fail(bool should_fail)
{
    if (should_fail)
        throw std::runtime_error("Operation failed");
    co_return 42;
}

task<> example()
{
    try
    {
        int result = co_await might_fail(true);
    }
    catch (std::runtime_error const& e)
    {
        std::cout << "Caught: " << e.what() << "\n";
    }
}

The exception is stored in the promise when it occurs and rethrown in await_resume when the calling coroutine resumes.