Writing Your Own Task Type (Early Quitter)
Build a task type whose coroutines end at their current co_await when a stop is requested.
The bodies of these coroutines contain no cancellation checks.
The type uses only the public ContextAwaitable protocol from The ContextAwaitable Protocol.
|
Example code, not library API
|
What This Example Shows
-
A second task type that satisfies
ContextAwaitableandContextRunnable, as ContextRunnable Concept defines it -
A stop policy that ends the body instead of resuming it
-
Shutting down on
Ctrl+C, with RAII cleanup across a chain of quitters -
The constraints that a task type built outside the library accepts
|
Why this example exists
An application that shuts down wants every task in flight to end cleanly.
With |
Choosing Between task and a Quitter
A quitter fits when a stop always means "drop the work and release its resources."
Application shutdown is the usual case, especially across call chains with many suspension points.
The header comment of shutdown.cpp shows the alternative: with task, each wait needs if(ec) co_return; after it.
task |
early::quitter |
|
|---|---|---|
Status |
A library type with a reference page |
Example code in namespace |
On a stop |
The body resumes, and stop-aware operations report |
The body is not resumed: when the pending operation completes, the run of quitters ends |
Checks in the body |
Wherever the body must react to a stop |
None |
What the first awaiter outside the quitters receives |
The result of the task |
|
Result type at that boundary |
Any |
|
When the locals of a stopped coroutine are destroyed |
Before the completion handler runs |
After the completion handler returns, under |
A stop requested before the start |
The body runs and sees the stop through its checks and its operations |
The body runs to its first |
Cost of each |
No stop check of its own |
Two stop checks; a |
Stop Tokens and Cancellation describes how a task observes a stop.
|
Why a separate type
The task type is a policy point.
A program that wants explicit error handling uses |
Source Code
//
// Copyright (c) 2026 Steve Gerbino
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
//
// Official repository: https://github.com/cppalliance/capy
//
// Deep call chain with quitter: a stop ends the whole chain at
// whichever co_await it is suspended in, with no checks at any level.
//
// The quitter here is example code (quitter.hpp), built only on capy's
// public coroutine protocol: no exceptions, no hooks in the library.
#include "quitter.hpp"
#include <boost/capy.hpp>
#include <array>
#include <atomic>
#include <cctype>
#include <chrono>
#include <csignal>
#include <iostream>
#include <latch>
#include <sstream>
#include <stop_token>
#include <string>
#include <system_error>
#include <thread>
#include <tuple>
#include <vector>
namespace capy = boost::capy;
using namespace std::chrono_literals;
static std::stop_source g_stop;
// request_stop runs the stop callbacks, which post to executors and
// allocate, so the signal handler only sets a lock-free flag and the
// ticker thread requests the stop.
static std::atomic<bool> g_signaled{false};
static_assert(std::atomic<bool>::is_always_lock_free);
extern "C" void on_signal(int) { g_signaled.store(true); }
// Logs its release, standing in for a socket, file, or buffer.
struct resource_guard
{
std::string what;
~resource_guard()
{
std::ostringstream oss;
oss << " released " << what << "\n";
std::cout << oss.str();
}
};
std::string transform(std::string s)
{
for(auto& c : s)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return s;
}
early::quitter<std::string>
fetch(int id, int item, capy::async_waker& waker)
{
resource_guard conn{"connection for item " + std::to_string(item)};
std::ignore = co_await waker.wait(); // connect
std::ignore = co_await waker.wait(); // read
co_return "worker " + std::to_string(id) +
" item " + std::to_string(item);
}
early::quitter<>
store(std::string const& data, capy::async_waker& waker)
{
resource_guard file{"output file"};
std::ignore = co_await waker.wait(); // open
std::ignore = co_await waker.wait(); // write
std::ostringstream oss;
oss << " stored " << data << "\n";
std::cout << oss.str();
}
early::quitter<>
process_item(int id, int item, capy::async_waker& waker)
{
co_await store(transform(co_await fetch(id, item, waker)), waker);
}
early::quitter<std::error_code> worker(int id, capy::async_waker& waker)
{
resource_guard guard{"worker " + std::to_string(id)};
for(int item = 0; ; ++item)
co_await process_item(id, item, waker);
}
int main()
{
std::signal(SIGINT, on_signal);
constexpr int n = 4;
capy::thread_pool pool(n);
std::latch done(n);
std::array<capy::async_waker, n> wakers;
std::vector<capy::strand<capy::thread_pool::executor_type>> strands;
for(int i = 0; i < n; ++i)
strands.emplace_back(pool.get_executor());
// Each wake stands in for one I/O operation completing.
std::atomic<bool> ticker_stop{false};
std::thread ticker([&] {
while(!ticker_stop.load())
{
std::this_thread::sleep_for(20ms);
if(g_signaled.load() && !g_stop.stop_requested())
g_stop.request_stop();
for(auto& w : wakers)
w.wake();
}
});
std::cout << "Press Ctrl+C to quit.\n";
for(int i = 0; i < n; ++i)
capy::run_async(strands[i], g_stop.get_token(),
// A stop arrives here as canceled.
[&](std::error_code) { done.count_down(); })(
worker(i, wakers[i]));
done.wait();
ticker_stop = true;
ticker.join();
std::cout << "Shutdown complete.\n";
}
quitter.hpp
//
// Copyright (c) 2026 Steve Gerbino
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
//
// Official repository: https://github.com/cppalliance/capy
//
#ifndef EARLY_QUITTER_HPP
#define EARLY_QUITTER_HPP
/* A quitter built only on capy's public coroutine protocol.
A stop ends a quitter at the co_await it is suspended in, without
resuming its body and without throwing. The quitter that sees the
stop walks up its run of quitters to the topmost one, stores the
stopped value (canceled) as that quitter's result, and resumes
whatever awaited it: a task, run, run_async, or a combinator. To
that awaiter the quitter simply completed. Destroying the top
quitter then destroys the suspended frames below it, so RAII runs
at every level.
Unlike a task, whose locals are gone by the time it completes, a
stopped quitter's locals live until its owner destroys the frame.
run_async calls its handler first, so a handler that signals "done"
runs before the quitter's RAII cleanup: wait for the executor's
work to finish (thread_pool::join, for example) before touching
state those destructors use.
Each quitter hands its operations a gate (a small coroutine frame)
instead of its own handle. A completion resumes the gate, which
decides before the body runs: with a stop pending, the operation's
result is taken early, and a canceled results or error_code (or any
result that cannot carry the stop) ends the quitter; any other
result is delivered, and the stop lands at the next co_await.
Because the stop is a value, the first awaiter that is not a
quitter must receive a T of results<...> or std::error_code.
*/
#include <boost/capy/concept/context_awaitable.hpp>
#include <boost/capy/cond.hpp>
#include <boost/capy/error.hpp>
#include <boost/capy/ex/await_context.hpp>
#include <boost/capy/ex/context_awaitable_promise_base.hpp>
#include <boost/capy/ex/frame_alloc_mixin.hpp>
#include <boost/capy/ex/frame_allocator.hpp>
#include <coroutine>
#include <exception>
#include <optional>
#include <system_error>
#include <tuple>
#include <type_traits>
#include <utility>
namespace early {
namespace capy = boost::capy;
/// Test whether `T` can carry a stop as a value.
template<class T>
struct carries_stop : std::false_type {};
template<>
struct carries_stop<std::error_code> : std::true_type {};
template<class... Ts>
struct carries_stop<std::tuple<std::error_code, Ts...>> : std::true_type {};
template<class T>
inline constexpr bool carries_stop_v = carries_stop<T>::value;
template<class T>
class quitter;
namespace detail {
template<class T>
struct is_quitter : std::false_type {};
template<class T>
struct is_quitter<quitter<T>> : std::true_type {};
template<class T>
bool is_canceled(T const& v) noexcept
{
if constexpr (std::is_same_v<T, std::error_code>)
return v == capy::cond::canceled;
else
return std::get<0>(v) == capy::cond::canceled;
}
template<class T>
T stopped_value()
{
std::error_code ec = capy::error::canceled;
if constexpr (std::is_same_v<T, std::error_code>)
return ec;
else
return [&]<class... Ts>(std::tuple<std::error_code, Ts...>*) {
return T{ec, Ts{}...};
}(static_cast<T*>(nullptr));
}
class promise_base;
// What a quitter's operations resume instead of its body.
struct gate
{
struct promise_type : capy::frame_alloc_mixin
{
promise_base* owner = nullptr;
gate get_return_object() noexcept
{
return {std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() noexcept {}
void unhandled_exception() noexcept { std::terminate(); }
};
std::coroutine_handle<promise_type> h;
};
// The parts of a quitter's promise that do not depend on T.
class promise_base
{
protected:
~promise_base()
{
if(gate_)
gate_.destroy();
}
public:
promise_base* parent_ = nullptr;
std::coroutine_handle<> body_;
void* awaiter_ = nullptr;
bool (*stops_)(void*) noexcept = nullptr;
bool stopped_ = false;
capy::await_context const* stop_env_ = nullptr;
virtual void store_stopped() noexcept = 0;
virtual std::coroutine_handle<> take_continuation() noexcept = 0;
bool stop_pending() const noexcept
{
return stop_env_->stop_token.stop_requested();
}
std::coroutine_handle<> gate_handle();
// End the run of quitters this one belongs to, delivering the
// stopped value from the top.
std::coroutine_handle<> stop() noexcept
{
promise_base* top = this;
top->stopped_ = true;
while(top->parent_)
{
top = top->parent_;
top->stopped_ = true;
}
top->store_stopped();
return top->take_continuation();
}
std::coroutine_handle<> decide() noexcept
{
if(stop_pending() && stops_(awaiter_))
return stop();
return body_;
}
private:
std::coroutine_handle<gate::promise_type> gate_;
};
struct decide_awaiter
{
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(
std::coroutine_handle<gate::promise_type> h) const noexcept
{
return h.promise().owner->decide();
}
void await_resume() const noexcept {}
};
inline gate make_gate()
{
for(;;)
co_await decide_awaiter{};
}
inline std::coroutine_handle<> promise_base::gate_handle()
{
if(!gate_)
{
gate_ = make_gate().h;
gate_.promise().owner = this;
}
return gate_;
}
template<class T>
struct return_base
{
std::optional<T> result_;
void return_value(T v) { result_.emplace(std::move(v)); }
T&& result() noexcept { return std::move(*result_); }
};
template<>
struct return_base<void>
{
void return_void() noexcept {}
};
} // namespace detail
/** A coroutine that a stop ends at its current `co_await`.
The body needs no cancellation checks: with a stop requested, the
quitter ends at the next `co_await` that cannot complete without
it, and its frames are destroyed without resuming. Awaited by
another quitter, it ends that one too. Awaited by anything else,
it completes with `canceled`, so `T` must then be `results<...>`
or `std::error_code`.
A stopped quitter's locals are destroyed when its owner destroys
it, not when it completes. Under `run_async` that is after the
handler returns.
@tparam T The result type.
*/
template<class T = void>
class [[nodiscard]] quitter
{
public:
struct promise_type
: capy::context_awaitable_promise_base<promise_type>
, detail::promise_base
, detail::return_base<T>
{
using base = capy::context_awaitable_promise_base<promise_type>;
std::exception_ptr ep_;
quitter get_return_object() noexcept
{
return quitter{
std::coroutine_handle<promise_type>::from_promise(*this)};
}
auto initial_suspend() noexcept
{
struct awaiter
{
promise_type* p_;
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<>) const noexcept {}
void await_resume() const noexcept
{
capy::set_current_frame_allocator(
p_->environment()->frame_allocator);
}
};
return awaiter{this};
}
auto final_suspend() noexcept
{
struct awaiter
{
promise_type* p_;
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(
std::coroutine_handle<>) const noexcept
{
return p_->base::continuation();
}
void await_resume() const noexcept {}
};
return awaiter{this};
}
void unhandled_exception() noexcept
{
ep_ = std::current_exception();
}
std::exception_ptr exception() const noexcept { return ep_; }
// Only a launcher calls this, and it reads the result, which
// must be able to hold the stop.
void set_continuation(std::coroutine_handle<> h) noexcept
{
static_assert(carries_stop_v<T>,
"a launched quitter must have T = results<...> or "
"std::error_code");
base::set_continuation(h);
}
// Also kept in the base, so the stop check needs no virtual call.
void set_environment(capy::await_context const* env) noexcept
{
base::set_environment(env);
this->stop_env_ = env;
}
void store_stopped() noexcept override
{
if constexpr (carries_stop_v<T>)
this->result_.emplace(detail::stopped_value<T>());
else
std::terminate(); // the top of a run always carries the stop
}
std::coroutine_handle<> take_continuation() noexcept override
{
return base::continuation();
}
template<class Awaitable>
struct transform_awaiter
{
using A = std::decay_t<Awaitable>;
using R = decltype(std::declval<A&>().await_resume());
// Hold a stop-carrying result taken early by value; any
// other result cannot carry the stop, so a stop wins.
static constexpr bool holds =
!std::is_reference_v<R> && carries_stop_v<R>;
using held_type = std::conditional_t<holds, R, char>;
A a_;
promise_type* p_;
std::optional<held_type> held_{};
std::exception_ptr ep_{};
bool stop_here_ = false;
bool stops_on_completion() noexcept
{
try
{
if constexpr (holds)
{
held_.emplace(a_.await_resume());
return detail::is_canceled(*held_);
}
else
{
// Called so anything handed over is destroyed.
static_cast<void>(a_.await_resume());
return true;
}
}
catch(...)
{
ep_ = std::current_exception();
return false;
}
}
static bool stops_at_gate(void* self) noexcept
{
return static_cast<transform_awaiter*>(
self)->stops_on_completion();
}
bool await_ready()
{
if(p_->stop_pending())
{
stop_here_ = true;
return false;
}
if(!a_.await_ready())
return false;
stop_here_ = p_->stop_pending() && stops_on_completion();
return !stop_here_;
}
std::coroutine_handle<> await_suspend(
std::coroutine_handle<promise_type> h)
{
if(stop_here_)
return p_->stop();
p_->body_ = h;
p_->awaiter_ = this;
p_->stops_ = &stops_at_gate;
auto gate = p_->gate_handle();
auto const* env = p_->environment();
if constexpr (detail::is_quitter<A>::value)
{
return a_.start(gate, env, p_);
}
else
{
using S = decltype(a_.await_suspend(gate, env));
if constexpr (std::is_void_v<S>)
{
a_.await_suspend(gate, env);
return std::noop_coroutine();
}
else if constexpr (std::is_same_v<S, bool>)
{
if(a_.await_suspend(gate, env))
return std::noop_coroutine();
return p_->decide();
}
else
{
return a_.await_suspend(gate, env);
}
}
}
decltype(auto) await_resume()
{
capy::set_current_frame_allocator(
p_->environment()->frame_allocator);
if(ep_)
std::rethrow_exception(std::exchange(ep_, nullptr));
if constexpr (holds)
{
if(held_)
return R(std::move(*held_));
}
return a_.await_resume();
}
};
template<class Awaitable>
auto transform_awaitable(Awaitable&& a)
{
static_assert(capy::ContextAwaitable<std::decay_t<Awaitable>>,
"requires ContextAwaitable");
return transform_awaiter<Awaitable>{
std::forward<Awaitable>(a), this};
}
};
/// Destroy the quitter and its frame.
~quitter() { destroy_frame(); }
/// Construct by taking the frame of `other`.
quitter(quitter&& other) noexcept
: h_(std::exchange(other.h_, nullptr))
, lent_(std::exchange(other.lent_, false))
{
}
/// Destroy the held frame, then take the frame of `other`.
quitter& operator=(quitter&& other) noexcept
{
if(this != &other)
{
destroy_frame();
h_ = std::exchange(other.h_, nullptr);
lent_ = std::exchange(other.lent_, false);
}
return *this;
}
/// Return false: a quitter always starts by suspending its awaiter.
bool await_ready() const noexcept { return false; }
/** Start the quitter, awaited by something that is not a quitter.
@param cont The awaiter, resumed when the quitter completes or
stops.
@param env The environment the quitter runs in.
@return The quitter's frame, to resume.
*/
std::coroutine_handle<> await_suspend(
std::coroutine_handle<> cont, capy::await_context const* env)
{
static_assert(carries_stop_v<T>,
"a quitter awaited outside a quitter must have "
"T = results<...> or std::error_code");
return start(cont, env, nullptr);
}
/** Return the quitter's result, or its stopped value.
@throws Any exception the body let escape.
*/
auto await_resume()
{
lent_ = false;
auto& p = h_.promise();
if(p.ep_)
std::rethrow_exception(p.ep_);
if constexpr (!std::is_void_v<T>)
return std::move(*p.result_);
}
/// Return the coroutine handle; the quitter keeps the frame.
std::coroutine_handle<promise_type> handle() const noexcept { return h_; }
/// Give up the frame without destroying it.
void release() noexcept { h_ = nullptr; }
private:
template<class>
friend class quitter;
std::coroutine_handle<promise_type> h_;
bool lent_ = false;
explicit quitter(std::coroutine_handle<promise_type> h) noexcept
: h_(h)
{
}
std::coroutine_handle<> start(
std::coroutine_handle<> cont,
capy::await_context const* env,
detail::promise_base* parent) noexcept
{
auto& p = h_.promise();
p.parent_ = parent;
p.promise_type::base::set_continuation(cont);
p.set_environment(env);
lent_ = true;
return h_;
}
// A lent frame is destroyed only by a stop of its run, or by the
// unwind of an abandoned chain.
void destroy_frame() noexcept
{
if(!h_)
return;
if(lent_ && !h_.done())
{
auto& p = h_.promise();
auto* owner = p.environment()->owner;
if(!p.stopped_ && !(owner && owner->abandoned()))
std::terminate();
}
std::exchange(h_, nullptr).destroy();
lent_ = false;
}
};
} // namespace early
#endif
shutdown.cpp
//
// Copyright (c) 2026 Michael Vandeberg
// Copyright (c) 2026 Steve Gerbino
//
// Distributed under the Boost Software License, Version 1.0. (See accompanying
// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
//
// Official repository: https://github.com/cppalliance/capy
//
/* Quitter Shutdown Example
Demonstrates quitter<T> for responsive application shutdown.
Four workers simulate a batch file-processing pipeline: each
"downloads" data, "transforms" it, and "writes" the result.
A single "ticker" thread plays the role of the clock: it wakes
each worker's async_waker on an interval, and the worker's
co_await waker.wait() is the suspension point. Workers are
quitter<std::error_code> coroutines (the example quitter in
quitter.hpp): their bodies contain zero cancellation-handling code.
Press Ctrl+C to request shutdown. Every in-flight worker
exits at its next co_await, RAII cleanup runs (each worker
holds a resource_guard that logs its cleanup), and the
application prints a summary and exits.
Contrast with task<>:
With task<>, every co_await that touches I/O needs:
auto [ec] = co_await waker.wait();
if(ec) co_return; // <-- cancellation boilerplate
This is repeated at every suspension point.
With a quitter, a stop request ends the worker without
resuming it: the launcher's handler receives canceled as the
worker's result, then the frame is destroyed and its RAII
guards run. The worker body is pure business logic.
*/
#include "quitter.hpp"
#include <boost/capy.hpp>
#include <array>
#include <atomic>
#include <chrono>
#include <csignal>
#include <iostream>
#include <latch>
#include <sstream>
#include <stop_token>
#include <system_error>
#include <thread>
#include <tuple>
#include <vector>
namespace capy = boost::capy;
using namespace std::chrono_literals;
// Global stop source; the ticker requests the stop after Ctrl+C.
static std::stop_source g_stop;
static std::chrono::steady_clock::time_point g_stop_time{};
// request_stop runs the stop callbacks, which post to executors and
// allocate, so it cannot be called from a signal handler. The handler
// only sets a lock-free flag, which the ticker thread turns into the
// stop request.
static std::atomic<bool> g_signaled{false};
static_assert(std::atomic<bool>::is_always_lock_free);
extern "C" void signal_handler(int)
{
g_signaled.store(true, std::memory_order_relaxed);
}
// RAII resource that logs construction and destruction.
// Simulates holding a file handle, socket, or temp buffer
// that must be released on shutdown.
struct resource_guard
{
int id;
std::atomic<int>& cleanup_count;
resource_guard(int id_, std::atomic<int>& count)
: id(id_)
, cleanup_count(count)
{
std::ostringstream oss;
oss << " [worker " << id << "] acquired resources\n";
std::cout << oss.str();
}
~resource_guard()
{
++cleanup_count;
std::ostringstream oss;
oss << " [worker " << id << "] released resources "
<< "(cleanup)\n";
std::cout << oss.str();
}
resource_guard(resource_guard const&) = delete;
resource_guard& operator=(resource_guard const&) = delete;
};
// A single worker: download → transform → write, repeated.
// No cancellation code. quitter handles it.
early::quitter<std::error_code> worker(
int id,
capy::async_waker& waker,
std::atomic<int>& items_processed,
std::atomic<int>& cleanup_count)
{
resource_guard guard(id, cleanup_count);
for(int item = 0; ; ++item)
{
// Simulate download: suspend until the ticker wakes us.
std::ignore = co_await waker.wait();
// Simulate transform (CPU work — no co_await needed)
{
std::ostringstream oss;
oss << " [worker " << id << "] processing item "
<< item << "\n";
std::cout << oss.str();
}
// Simulate write: suspend for another wakeup.
std::ignore = co_await waker.wait();
++items_processed;
}
// Never reached — the loop is infinite.
// A stop request ends the worker at the co_await it is waiting in.
}
int main()
{
std::signal(SIGINT, signal_handler);
#ifdef SIGTERM
std::signal(SIGTERM, signal_handler);
#endif
constexpr int num_workers = 4;
capy::thread_pool pool(num_workers);
std::latch done(num_workers);
std::atomic<int> items_processed{0};
std::atomic<int> cleanup_count{0};
// One waker per worker (single-waiter precondition); each
// runs on its own strand so the pool's num_workers OS threads
// still keep every waker's resumption single-threaded.
std::array<capy::async_waker, num_workers> wakers;
std::vector<capy::strand<capy::thread_pool::executor_type>> strands;
strands.reserve(num_workers);
for(int i = 0; i < num_workers; ++i)
strands.emplace_back(pool.get_executor());
// Ticker thread paces the workers: it periodically wakes
// every worker's waker, and requests the stop once Ctrl+C has
// been pressed.
std::atomic<bool> ticker_stop{false};
std::thread ticker([&] {
while(!ticker_stop.load(std::memory_order_relaxed))
{
std::this_thread::sleep_for(50ms);
if(g_signaled.load(std::memory_order_relaxed) &&
!g_stop.stop_requested())
{
g_stop_time = std::chrono::steady_clock::now();
g_stop.request_stop();
}
for(auto& waker : wakers)
waker.wake();
}
});
std::cout << "Starting " << num_workers
<< " workers. Press Ctrl+C to quit.\n\n";
for(int i = 0; i < num_workers; ++i)
{
capy::run_async(
strands[i],
g_stop.get_token(),
// A stop arrives here as canceled.
[&](std::error_code) { done.count_down(); })(
worker(i, wakers[i], items_processed, cleanup_count));
}
done.wait();
// Stop and join the ticker now that every worker has finished,
// so it cannot wake a waker (or post to a strand) after the
// pool starts tearing down.
ticker_stop.store(true, std::memory_order_relaxed);
ticker.join();
// A stopped worker's frame, and the resource_guard in it, is
// destroyed after its handler runs: wait for that before reading
// the counters it updates.
pool.join();
// The ticker wrote g_stop_time before it was joined.
auto stop_at = g_stop_time;
auto now = std::chrono::steady_clock::now();
std::cout << "\nShutdown complete.\n"
<< " Items processed: " << items_processed << "\n"
<< " Workers cleaned up: " << cleanup_count
<< "/" << num_workers << "\n";
if(stop_at != std::chrono::steady_clock::time_point{})
{
auto us = std::chrono::duration_cast<
std::chrono::microseconds>(now - stop_at).count();
std::cout << " Shutdown latency: " << us << " us\n";
}
}
The self-checks are in checks.cpp, in the example directory.
Build
add_executable(capy_example_early_quitter main.cpp quitter.hpp)
target_link_libraries(capy_example_early_quitter Boost::capy)
add_executable(capy_example_early_quitter_shutdown shutdown.cpp quitter.hpp)
target_link_libraries(capy_example_early_quitter_shutdown Boost::capy)
add_executable(capy_example_early_quitter_checks checks.cpp quitter.hpp)
target_link_libraries(capy_example_early_quitter_checks Boost::capy)
add_test(NAME capy_example_early_quitter_checks
COMMAND capy_example_early_quitter_checks)
The directory has a CMakeLists.txt but no Jamfile, so b2 does not build it.
CI builds the three programs and runs the checks.
Walkthrough
A Call Chain with No Cancellation Checks
In main.cpp, fetch and store each await the waker twice.
Each wait stands in for one I/O operation.
process_item awaits fetch, then store, and worker calls process_item in an endless loop.
None of these functions looks at a stop token.
Each resource_guard stands in for a connection, a file, or a buffer, and logs its release.
With task, every wait would need the check from Checking for Cancellation.
Checking the Token describes that check.
Only worker returns early::quitter<std::error_code>, because it is the top of the run, and its result must carry the stop.
The other three can use a result type that need not carry the stop, because only another quitter awaits them.
Starting the Workers
The run_async loop at the end of main starts one worker on each strand.
The completion handler takes the result of the worker, a std::error_code.
After a stop, that result is canceled.
Each worker gets its own strand and its own async_waker.
A waker needs an executor that never runs its continuations concurrently, as The Threading Model explains.
Shutting Down from a Signal
The signal handler on_signal only sets g_signaled, a lock-free atomic flag.
It cannot call request_stop() on g_stop, because that call runs the stop callbacks, which post and allocate.
Neither is safe inside a signal handler.
The ticker thread in main checks the flag every 20 ms.
Once the flag is set, the ticker calls g_stop.request_stop().
On each tick, the ticker also wakes every waker, standing in for I/O completions.
Capy has no signal facility. Signal handling needs a platform reactor, so for now it belongs in Corosio, as What Lives Where explains. Discussion #432 about signals in Capy is still open.
Operations Resume a Gate
The promise of a quitter wraps each awaited operation in a transform_awaiter, in quitter.hpp.
Its await_suspend hands the operation the handle of a gate instead of the handle of the body.
A child quitter receives the same gate handle.
The gate is a small coroutine frame of type gate.
make_gate creates it the first time an awaited operation is not ready, and ~promise_base destroys it.
The gate loops on decide_awaiter, which calls promise_base::decide each time an operation resumes the gate.
decide resumes the body, unless a stop is pending and the result ends the quitter.
The quitter never abandons an operation in flight.
It acts when that operation completes.
A stop-aware operation, such as the wait() of an async_waker, completes at once with canceled, because its stop callback posts the resumption.
A Stop Ends the Run from the Top
When decide ends the quitter, it calls promise_base::stop.
That function walks the parent_ pointers to the topmost quitter of the run, and marks each quitter on the way as stopped.
It stores canceled as the result of the top quitter, then returns the continuation of that quitter.
No body in the run resumes.
Results That Race a Stop
transform_awaiter::await_ready and transform_awaiter::await_suspend decide what a stop does to an operation.
There are two cases.
A stop is already pending when the co_await begins.
await_ready checks for the stop before it asks the operation anything, and reports that the operation is not ready.
await_suspend then calls stop().
The operation is never started, so nothing, such as a lock or a buffer, is acquired only to be dropped.
Nothing is delivered, and the run stops.
A stop arrives while the operation completes.
The stop can arrive inside the readiness check of the operation.
It can also arrive after the operation completes, but before the gate runs.
In both cases, stops_on_completion takes the result early:
-
A
canceledresult ends the quitter. -
Any other
results<…>orstd::error_codevalue is delivered to the body. The stop takes effect at the nextco_await. -
A result that cannot carry the stop, such as
void, a reference, or any other type, is dropped, and the quitter ends. The operation still hands the result over, so anything it owns is destroyed. -
An exception from the operation reaches the body. The stop takes effect at the next
co_await.
The constant holds in transform_awaiter decides which results the quitter can keep.
It uses the trait carries_stop, described in the next section.
The Boundary Rule
A stop becomes a value at the first awaiter that is not a quitter.
That awaiter can be a task, run, run_async, or when_all, and it receives canceled as the result of the quitter.
So the result type at that boundary must be able to hold canceled.
The trait carries_stop accepts std::error_code and any std::tuple<std::error_code, Ts…>, which includes results.
The static_assert in set_continuation checks a quitter that a launcher starts.
A second static_assert, in quitter::await_suspend, checks a quitter that a task or a combinator awaits.
A quitter with any other result type at the boundary is a compile error.
Cleanup Runs After the Completion Handler
A stopped quitter keeps its locals until its owner destroys the frame.
Under run_async, the completion handler runs first, and then the frame is destroyed.
So a completion handler that counts down a latch can wake main before the resource guards run.
shutdown.cpp handles this in main.
After done.wait(), it joins the ticker, then calls pool.join(), and only then reads the counters that the guards update.
main.cpp prints its final message without calling pool.join().
Pool threads can still be destroying frames, so released lines can follow that message.
main.cpp needs no pool.join() for this, because only the order of its output lines depends on it.
It reads no state that the guards update.
The strands and the wakers are destroyed before the pool, and that is safe here for two reasons.
Each run_async call keeps its own copy of its strand, and the copies share one reference-counted state.
A waiter that a wake or a stop already claimed never touches its waker again.
Real code should still copy the order in shutdown.cpp.
|
What the Self-Checks Cover
checks.cpp runs 13 checks on a single-threaded queue, except the last one, which uses a thread pool:
-
value_without_stop: without a stop, the value of a quitter reaches the completion handler unchanged. -
deep_stop_while_suspended: a stop ends three nested quitters at once, no body resumes, and all three guards run. -
stop_before_start: with the stop requested before the start, the body runs up to its firstco_awaitand ends there. -
ready_success_then_stop_delivers: a stop arrives while a ready operation completes, the successful result is delivered, and the nextco_awaitstops. -
ready_canceled_then_stop_stops: the same race with acanceledresult ends the quitter at once. -
stop_pending_leaves_operation_untouched: with a stop pending at aco_await, the operation is never started. -
early_exception_reaches_body: an exception from a result taken early reaches the body. -
stop_after_wake_before_gate: a stop arrives after a wake posts the gate, but before the gate runs. The wake is delivered, and the nextco_awaitstops. -
task_between_quitters: a task between two quitters receivescanceledas a value, and the outer quitter then ends. -
exception_propagates: an exception from the body of a child quitter reaches the parent quitter. -
teardown_through_closed_executor: the waker posts the gate to a closed executor, which abandons it. The chain unwinds, and no completion handler runs. -
when_all_branches_stop: two quitters in the branches ofwhen_allboth stop, and the task that awaitswhen_allresumes. -
stop_races_wake: a wake and a stop from two threads race 2000 times against a quitter on a thread pool. Each run completes exactly once, and its frame always unwinds. The check awaits the waker directly on the pool, without a strand, which the threading model leaves undefined. It holds only because that one coroutine is the sole waiter, so no two of its continuations can run at once.
Run them with ctest --test-dir build -R early_quitter.
Known Limitations
-
A stop requested before the start still lets the body run to its first
co_await. -
A result that cannot carry the stop is dropped when a stop arrives as the operation completes. That includes a reference result and a
const-qualified result, becausecarries_stopmatches neither. -
A stopped
results<Ts…>needs default-constructibleTs. -
taskworks around a symmetric-transfer limit of MSVC, and the quitter does not. -
The checks do not cover
runonto another executor,when_any, or a handoff through a strand or a mutex.
Output
capy_example_early_quitter, with Ctrl+C pressed after about one second.
The interleaving of the lines varies from run to run.
Press Ctrl+C to quit.
released connection for item 0
released connection for item 0
released connection for item 0
released connection for item 0
stored WORKER 0 ITEM 0
stored WORKER 1 ITEM 0
released output file
released output file
stored WORKER 2 ITEM 0
released output file
stored WORKER 3 ITEM 0
released output file
...
released connection for item 8
released connection for item 8
released connection for item 8
released connection for item 8
released output file
released output file
released worker 2
released worker 1
released output file
released worker 3
released output file
released worker 0
Shutdown complete.
The last lines show the stop at work.
Each worker had fetched item 8 and was waiting inside store.
The stop releases its output file and its worker guard, and nothing more is stored.
In this run, every released line came before Shutdown complete., but another run can print some of them after it.
For code that reads state after shutdown, copy the order in shutdown.cpp: ticker.join(), then pool.join(), then read the state.
capy_example_early_quitter_shutdown prints a summary after the cleanup.
The block leaves out its last line, which reports the shutdown latency of that run:
Starting 4 workers. Press Ctrl+C to quit.
[worker 0] acquired resources
[worker 2] acquired resources
[worker 1] acquired resources
[worker 3] acquired resources
[worker 0] processing item 0
[worker 1] processing item 0
...
[worker 1] processing item 7
[worker 0] released resources (cleanup)
[worker 3] released resources (cleanup)
[worker 2] released resources (cleanup)
[worker 1] released resources (cleanup)
Shutdown complete.
Items processed: 32
Workers cleaned up: 4/4
Exercises
-
Rewrite
workerand the functions it calls astaskcoroutines, with the checks they then need. -
Await
workerfrom ataskand branch oncond::canceled. -
Add
pool.join()tomain.cppbefore the final message, and compare where thereleasedlines appear. -
Build
checks.cppwith-fsanitize=threadand run it.
See Also
-
The ContextAwaitable Protocol — the protocol a task type implements
-
Stop Tokens and Cancellation — how a
taskobserves a stop -
Synchronization Primitives — the waker that the workers wait on
-
Timeout with Cancellation — stop handling with
task -
Why Capy Is Separate — why signals belong in Corosio
-
Coroutine Execution Model — user-defined task types in the comparison with Boost.Asio