Part 6 ended on a pattern that sender algorithms cannot express by
composition: a retry loop. A fallback chain is a fixed sequence of steps, so let_error handles it.
A retry with backoff, “try up to five times, sleeping longer between attempts,” is a loop, and the answer
there was to write a custom sender or to use a ready-made one like the retry algorithm in stdexec’s
examples.
Here is a retry without the backoff, written as a coroutine:
task<void> retry_n(sender auto work, int n)
{
while (true) {
try {
co_await work;
co_return;
} catch (...) {
if (n-- == 0) throw;
}
}
}
And a flaky operation that fails twice before it succeeds:
int attempts = 0;
sender auto flaky = just() | then([&] {
if (++attempts < 3) throw std::runtime_error("not yet");
std::cout << "Success after " << attempts << " attempts\n";
});
sync_wait(retry_n(flaky, 5)); // prints: Success after 3 attempts
retry_n takes a sender and returns a task, the coroutine type C++26 adds as std::
(covered at the end of this post). task implements the sender interface itself, so sync_wait runs it
like any other pipeline. Inside, co_await work starts the sender and suspends the coroutine until it
completes. When work completes with set_error, the error comes out of the co_await as an exception,
so an ordinary try/catch in a loop does the job that otherwise takes a custom sender such as stdexec’s
retry, at the price of reporting every error as an std::. With n set to 2 and a sender
that never succeeds, retry_n makes three attempts, the first one and two retries. Then the last exception
propagates out of it, and sync_wait rethrows it.
The retry uses both directions of the interop: a coroutine awaiting a sender, and a coroutine that is itself a sender.
A coroutine task is a sender#
A task<int> is a sender with three
completions: set_ when the body reaches co_return, set_ when an
exception escapes the body, and set_stopped() when it is stopped (more on that below). So it goes
wherever a sender goes:
task<int> answer()
{
int base = co_await just(40);
co_return base + 2;
}
int main()
{
exec::static_thread_pool pool{4};
sender auto work = starts_on(pool.get_scheduler(), answer())
| then([](int value) { return value * 10; });
auto [result] = sync_wait(std::move(work)).value();
std::cout << result << '\n'; // prints: 420
}
when_ works the same way, and so does let_value returning a task from its
callable.
Awaiting a sender#
Inside a task, every co_await operand goes through the promise’s await_, which turns a sender
into an awaitable with as_awaitable ([task.promise]). The result of the co_await depends on how the
sender completes:
| The sender completes with | co_await sndr |
|---|---|
set_value() | resumes, and the expression is void |
set_value(a) | resumes, and yields a |
set_ | resumes, and yields std:: |
set_error(e) | resumes, and throws |
set_stopped() | never resumes: the promise’s unhandled_ is called instead |
What gets thrown depends on the type of e. An std:: is rethrown, an std:: is
thrown as std::, and anything else is thrown as itself ([exec.general], AS-EXCEPT-PTR):
task<void> tour()
{
co_await just(); // void
int v = co_await just(0); // int
auto [i, b, c] = co_await just(0, true, 'c'); // std::tuple<int, bool, char>
try {
co_await just_error(std::make_error_code(std::errc::timed_out));
} catch (const std::system_error& e) {
std::cout << "caught: " << e.code().message() << '\n';
}
co_await just_stopped(); // never resumes
std::cout << "unreachable\n";
}
Run with sync_wait, it prints caught: , never prints unreachable, and sync_wait
returns an empty std::optional, which is how it reports a stopped completion.
Senders a coroutine cannot await#
A sender can succeed in more than one shape (two set_value completion signatures with different
arguments), and a co_await expression can have only one type. Racing a reply against a deadline is a
common way to get such a sender. The exec:: from Part 4
completes with whatever the winning child sent, so its value completions are those of all its children
together:
sender auto read_reply(); // sends an int
sender auto deadline(); // sends nothing
task<void> client()
{
// int reply = co_await exec::when_any(read_reply(), deadline()); // does not compile
auto result = co_await into_variant(exec::when_any(read_reply(), deadline()));
if (auto* reply = std::get_if<std::tuple<int>>(&result))
std::cout << "reply: " << std::get<0>(*reply) << '\n';
else
std::cout << "timed out\n";
}
The commented-out line fails because as_awaitable requires at most one value completion signature. For
anything else, unless the sender opts in through the hook described below, it hands the sender back
unchanged ([exec.as.awaitable]), and a sender is not an awaiter.
That is why GCC 15 complains about a missing member await_ready instead of saying anything about
completions. into_variant collapses all the value completions into one, a std::variant of tuples, and
that the coroutine can await. A sender can also collapse its completions itself: P3570, adopted for C++26,
gave senders an opt-in hook for it, get_.
What a stop request does to a coroutine#
Part 4 made the case that stopping is not an error, so there is no
exception to throw for it. Instead, as_awaitable does not resume the coroutine at all. It calls
unhandled_ on the promise, which for a task destroys the coroutine frame and completes the task
with set_stopped() ([task.promise]). Locals are destroyed with the frame, so RAII still runs. If that task
was itself awaited by another task, the parent receives set_stopped() and is unwound the same way, and so
on up the chain until something intercepts it or the outermost receiver gets set_stopped().
Here it is on a request raced against a timeout:
struct trace {
std::string name;
~trace() { std::cout << " ~trace(" << name << ")\n"; }
};
exec::timed_thread_context timer_ctx;
scheduler auto timer = timer_ctx.get_scheduler();
task<int> slow_lookup()
{
trace t{"slow_lookup"};
try {
co_await exec::schedule_after(timer, 10s); // a slow backend
std::cout << " slow_lookup resumed\n";
co_return 42;
} catch (...) {
std::cout << " slow_lookup: catch (...) entered\n";
throw;
}
}
task<int> handle_request()
{
trace t{"handle_request"};
int value = co_await slow_lookup();
std::cout << " handle_request resumed\n";
co_return value + 1;
}
int main()
{
std::cout << "racing the request against a 100 ms timeout\n";
sender auto timeout = exec::schedule_after(timer, 100ms) | then([] { return -1; });
auto [result] = sync_wait(exec::when_any(handle_request(), timeout)).value();
std::cout << "result: " << result << '\n';
}
racing the request against a 100 ms timeout
~trace(slow_lookup)
~trace(handle_request)
result: -1
The timeout wins, when_any asks handle_ to stop, and the request is forwarded down to the timer
that slow_lookup is waiting on. The timer completes with set_stopped() early, and both coroutine frames
are destroyed innermost first. Neither “resumed” line prints, and neither does the catch (...). The whole
program finishes in about 100 ms instead of 10 s. The stop is cooperative in the usual way: it arrived
quickly because the timer registers a stop callback. A coroutine busy in a loop that never awaits anything
would not notice it.
The language has no way to cancel a coroutine, only to destroy its frame. unhandled_ is the
library hook that does that and reports a stopped completion the parent can see, which is how coroutines get
the cancellation that Part 4 said they lack natively.
To handle a stop inside the coroutine, convert it before it reaches the co_await. stopped_,
or the upon_stopped and let_stopped from Part 3’s table, turn it
into a value first:
task<int> cached_value(bool canceled); // may complete with set_stopped()
task<int> value_or_default(bool canceled)
{
if (auto v = co_await stopped_as_optional(cached_value(canceled)))
co_return *v;
std::cout << "stopped, falling back\n";
co_return -1;
}
Where a coroutine resumes#
Part 5 started from a callback that runs its continuation on whatever
thread completed the previous step. A coroutine that resumes inside the receiver has the
same property: the line after co_await runs wherever the awaited sender completed. In a coroutine it is
harder to notice because the code reads like sequential code on one thread.
std:: is scheduler-affine by default. When it starts, it takes the scheduler it was started on from its
receiver’s environment and keeps it. Every sender it awaits is wrapped in affine, which moves the
completion back to that scheduler with a continues_on ([exec.affine], [task.promise]). Here the coroutine
starts on one single-threaded pool and awaits work that completes on another:
exec::static_thread_pool ui_pool{1}; // stands in for a UI or reactor thread
exec::static_thread_pool io_pool{1}; // where the I/O completes
sender auto fetch()
{
return starts_on(io_pool.get_scheduler(), just() | then([] {
std::cout << "fetch runs on the " << where() << '\n';
return 42;
}));
}
task<void> process()
{
std::cout << "before co_await: " << where() << '\n';
int data = co_await fetch();
std::cout << "got " << data << " on the " << where() << '\n';
}
// where() returns the name of the pool the calling thread belongs to
sync_wait(starts_on(ui_pool.get_scheduler(), process()));
before co_await: ui thread
fetch runs on the io thread
got 42 on the ui thread
Affinity can be switched off through the task’s environment parameter, and the output shows what you give up:
struct no_affinity {
using start_scheduler_type = inline_scheduler;
};
task<void, no_affinity> process_no_affinity(); // same body as process()
before co_await: ui thread
fetch runs on the io thread
got 42 on the io thread
A few details of how it behaves:
- Affinity is to a scheduler. A pool scheduler may resume the coroutine on any of its threads, so
thread_localdata is not stable across aco_await. P3552 spells this out. - It costs a scheduling operation per
co_await, unless the awaited sender is known to complete where it started. The working draft recommends, without requiring it, thatjust,just_error,just_stopped,read_env, andwrite_envbe recognized as such, so that awaiting them does not reschedule ([exec.affine]/6). - A default
taskneeds a scheduler to start on.sync_waitprovides one (its run loop), and so doesstarts_on.spawninto acounting_from Part 7 provides none.scope
Spawning a task into a scope shows the last point, and one more restriction:
counting_scope scope;
// spawn(log_event(1), scope.get_token()); // does not compile
spawn(starts_on(sched, log_event(1))
| upon_error([](std::exception_ptr) noexcept { /* log it */ }),
scope.get_token());
sync_wait(scope.join());
The commented-out line fails for two reasons. The task finds no start scheduler in the environment spawn
gives it. And spawn’s receiver accepts only set_value() and set_stopped() ([exec.spawn]), while a
default task<void> can also complete with set_. starts_on fixes the first and
upon_error the second.
The C++26 std::execution::task #
Until C++26, the standard had no task type, so code that combined coroutines with senders used one from a
library or wrote its own. P3552 surveys several of them (cppcoro, libunifex, stdexec’s own exec::task) and
standardizes one: std::.
Apart from no_affinity, the task examples so far use its defaults: the three completion signatures,
scheduler affinity, and a stop request unwinding the coroutine. The second template parameter is a struct
whose members change those defaults, and a missing member means the default ([task.class]).
Controlling the error signatures#
The default says a task fails only with an std::. A task that can also fail with an
std:: lists it in error_types, next to the std::. Its value channel is
unchanged, and the body completes with the error code through co_, without
throwing:
struct io_errors {
using error_types = completion_signatures<set_error_t(std::error_code),
set_error_t(std::exception_ptr)>;
};
task<int, io_errors> read_config(bool fail)
{
if (fail) co_yield with_error{std::make_error_code(std::errc::timed_out)};
co_return 42;
}
It is co_yield rather than co_return for a mechanical reason P3552 gives: the promise of a task<void>
has return_void(), and, when P3552 was written, a promise could not have both that and a return_.
P3950 by Robert Leahy, adopted in June 2026 as a Defect Report, lifts that restriction. The
working draft still spells the error co_.
Used as a sender, the error keeps its type. An upon_error after read_ is called with an
std:: whose message is Connection . Awaited from another coroutine, the same error
is thrown at the co_await as an std::.
A version that awaits just_error instead completes with a different error type:
task<int, io_errors> read_config_wrong()
{
co_await just_error(std::make_error_code(std::errc::timed_out));
co_return 0;
}
The co_await throws std::, the exception escapes the body, and the task completes with
set_. The std:: entry in error_types is never used, and an
upon_error after it receives an std::. co_ is the only way the standard
provides for the body to complete with a typed error directly.
If error_types leaves out std::, the task has no way to report an exception. When one
escapes the body anyway, the promise’s unhandled_ calls std:: ([task.promise]).
Allocating the frame#
The intro to this series said coroutine frames often force a heap
allocation. Counting calls to the global operator new makes it concrete: a sender pipeline of two then
steps run with sync_wait allocates nothing, while a task that awaits two other tasks allocates three
times in stdexec, once per coroutine call. task can take its frames from an allocator you supply
instead. Allocators, stop tokens, and the rest of what a receiver’s environment carries get a post of
their own, which comes back to task and shows how.
Reach for senders when:
- the code is on a hot path, or has to allocate nothing: a well-built pipeline is one operation state, and the counts above show zero allocations
- you are writing generic library code whose callers pick the scheduler and the context
- you want the whole computation to be a value the compiler can see and inline
The costs are deep types, long compile times, and error messages that run to pages.
Reach for coroutines when the control flow has loops and branches, like the retry above, or when a
try/catch is the natural way to say what happens on failure. It is also usually the better choice for
application code that runs often but not in a tight loop, where a frame allocation per call is an
acceptable price for code that reads top to bottom.
In practice, you use both, with the low-level operations written as senders and the logic that strings them together written as coroutines. A coroutine task type is also the easiest way to write a new asynchronous algorithm. If the allocations and indirect calls a coroutine brings are acceptable for it, you rarely need to write a sender, a receiver, or an operation state by hand.
The next post turns to data parallelism: bulk, execution policies, and running the same pipeline on a
thread pool or a GPU. How task is built, and how to teach a coroutine type of your own to await senders,
get a post of their own once the series has covered receivers and operation states. Coroutines
themselves, the frames, promises, and awaiters this post used without opening up, get their own series
later.
Mastering std::execution covers the tradeoff between the two models and when to combine them. For teams that want the coroutine machinery as well, Structured Concurrency in C++ combines the coroutine and the senders and receivers material in one class of three to four days.