OnceCallback in Practice (Part 4): Cancellation Token Design
Introduction
A common requirement in asynchronous programming is that an external condition changes between the creation and execution of a callback, rendering the callback meaningless—for example, the object bound to the callback has been destroyed, or the task has been canceled. In such cases, we want the callback to check "should I still execute?" before running, rather than blindly executing.
This is the purpose of a cancellation token. In this post, we will implement a simplified cancellation token and see how it integrates into the execution flow of OnceCallback.
Learning Objectives
- Understand the concept and motivation behind cancellation tokens
- Understand the implementation of
CancelableTokenline by line- Understand how the cancellation mechanism integrates into
impl_run()- Understand the different cancellation behaviors between void and non-void callbacks
The Concept of Cancellation Tokens
You can think of a cancellation token as a "pass." When creating a callback, we issue a pass marked "valid." At some point, an external condition changes (such as the bound object being destroyed), and external code declares the pass "voided" (by calling invalidate()). Afterward, all callbacks holding this pass will find it "invalid" when checking before execution, and they will skip execution.
In Chromium, this "pass" is the control block inside WeakPtr—once the object pointed to by WeakPtr is destroyed, the flag in the control block is cleared, and all callbacks bound to this WeakPtr are automatically canceled. Our simplified version doesn't need to be as complex as WeakPtr; we only need a simple "valid/invalid" flag.
Core Requirements
A cancellation token needs to satisfy three conditions: multiple callbacks can share the same token (one invalidate() invalidates all callbacks simultaneously), the token must be copyable and movable (making it convenient to hold a copy both inside and outside the OnceCallback), and the invalidation check must be thread-safe (an external thread might call invalidate() on one thread, while the callback checks is_valid() on another).
Complete Implementation of CancelableToken
The entire cancellation token is only 18 lines of code, but every line has its purpose.
#pragma once
#include <atomic>
#include <memory>
namespace tamcpp::chrome {
class CancelableToken {
struct Flag {
std::atomic<bool> valid{true};
};
std::shared_ptr<Flag> flag_;
public:
CancelableToken() : flag_(std::make_shared<Flag>()) {}
void invalidate() {
flag_->valid.store(false, std::memory_order_release);
}
bool is_valid() const {
return flag_->valid.load(std::memory_order_acquire);
}
};
} // namespace tamcpp::chromeWhy Use a Nested Flag Struct
You might wonder—why not just put an std::atomic<bool> directly inside CancelableToken? The reason is that shared_ptr manages a heap-allocated object. If we put atomic<bool> directly inside CancelableToken, then shared_ptr would be managing the CancelableToken itself—but CancelableToken also has its own flag_ member, which creates a cycle of shared_ptr<CancelableToken> containing shared_ptr<Flag>.
By using a nested Flag struct to isolate the state that needs to be shared, shared_ptr directly manages Flag, and the copying and moving of CancelableToken are automatically handled through the reference counting of shared_ptr—simple and correct. Another benefit is that the Flag struct makes future extensions easy—if we need to add more atomic flags later (such as a cancellation reason code), we can just add them to Flag.
The Sharing Mechanism of shared_ptr
The copy constructor and copy assignment operator of CancelableToken are compiler-generated by default—what they do is copy the shared_ptr<Flag> and increment the reference count by one. All token copies created through copying share the same Flag object. When any copy calls invalidate(), it modifies the same Flag::valid, and all copies will see false the next time they call is_valid().
auto token1 = std::make_shared<CancelableToken>();
auto token2 = token1; // 共享同一个 Flag
token1->invalidate();
assert(!token2->is_valid()); // token2 也看到了失效The memory_order_acquire/release Pairing
invalidate() uses memory_order_release to store false, and is_valid() uses memory_order_acquire to load. This is a paired memory order. The release store guarantees that all write operations before the store (including any state modifications before calling invalidate()) are visible to other threads. The acquire load guarantees that all read operations after the load will see the writes preceding the release store.
In our scenario, this means that if one thread calls invalidate(), another thread calling is_valid() immediately afterward is guaranteed to see false—there will be no situation where "I just invalidated it, but is_valid still returns true." This is the thread-safety guarantee.
Integration into OnceCallback
The cancellation token is set into OnceCallback via the set_token() method:
void set_token(std::shared_ptr<CancelableToken> token) {
token_ = std::move(token);
}token_ is of type shared_ptr<CancelableToken>, defaulting to a null pointer (meaning the cancellation mechanism is not enabled). Once set, ownership of the cancellation token is transferred into the OnceCallback.
Complete Logic of is_cancelled()
[[nodiscard]] bool is_cancelled() const noexcept {
if (status_ != Status::kValid) return true;
if (token_ && !token_->is_valid()) return true;
return false;
}A two-layer check. The first layer: if the state is not kValid, return true—empty callbacks (kEmpty) and consumed callbacks (kConsumed) are both considered "canceled." This makes sense—an empty callback has nothing to execute, and a consumed callback has already been executed. The second layer: if there is a cancellation token and the token is invalid, also return true.
Cancellation Check in impl_run()
ReturnType impl_run(FuncArgs... args) {
assert(status_ == Status::kValid);
// 取消检查在执行前
if (token_ && !token_->is_valid()) {
status_ = Status::kConsumed;
func_ = nullptr;
if constexpr (std::is_void_v<ReturnType>) {
return;
} else {
throw std::bad_function_call{};
}
}
// 正常消费流程...
}The cancellation check occurs before executing the callable. If canceled, we consume the callback directly without executing—status_ is set to kConsumed, and func_ is set to nullptr (destructing the internal callable and releasing resources).
Differences in Cancellation Behavior Between void and Non-void Callbacks
There is a design decision here worth expanding upon—when a void callback is canceled, it simply returns (no execution, no error), whereas when a non-void callback is canceled, it throws a std::bad_function_call exception.
The reason is the different expectations of the caller. The caller of a void callback does not expect a return value—once std::move(cb).run() is called, it is done, and the caller doesn't care whether the callback actually executed. Therefore, a canceled void callback simply skips execution, which is transparent to the caller.
The caller of a non-void callback expects to receive a return value—int result = std::move(cb).run(). If the callback is canceled, we cannot provide a meaningful return value. Returning a default value (like 0) might mask the error—the caller would think the callback executed normally, when in fact nothing was done. Throwing an exception might seem aggressive, but it explicitly tells the caller "something went wrong," which is safer than silently returning an incorrect value.
Chromium chooses to terminate the program directly here (CHECK failure), on the grounds that in Chrome's architecture, a canceled callback should not be invoked—the caller should check is_cancelled() before calling. We chose exceptions to make it easier to catch and verify in tests, rather than crashing the program outright.
Usage Example
using namespace tamcpp::chrome;
// 创建令牌和回调
auto token = std::make_shared<CancelableToken>();
bool executed = false;
OnceCallback<void()> cb([&executed] { executed = true; });
cb.set_token(token);
// 令牌有效时,正常执行
assert(!cb.is_cancelled());
std::move(cb).run();
assert(executed); // 回调被执行了
// 创建另一个回调,这次先取消令牌
executed = false;
auto cb2 = OnceCallback<void()>([&executed] { executed = true; });
cb2.set_token(token);
token->invalidate(); // 作废令牌
assert(cb2.is_cancelled());
std::move(cb2).run(); // 取消的 void 回调不执行,不抛异常
assert(!executed); // 回调没有被执行Note in the second example—cb2.run() is called, but the lambda inside the callback does not execute. impl_run() detects that the token is invalid before execution, consumes the callback directly, and returns.
Summary
In this post, we implemented a cancellation token and integrated it into OnceCallback. CancelableToken uses shared_ptr + atomic<bool> to implement a lightweight cancellation mechanism—all token copies share the same Flag object, and one invalidate() invalidates all copies simultaneously. The integration method checks the token state before impl_run() executes—if canceled, it consumes the callback directly without executing. Void callbacks simply return, while non-void callbacks throw a std::bad_function_call; this difference stems from the caller's different expectations regarding the return value.
In the next post, we will look at then() chained composition—the most elegantly designed ownership model among the four features of OnceCallback.