December 11, 2019·4 min read·engineeringcppboostasioasync

Boost 1.72 gives Asio completion tokens more control

Boost 1.72 updates Asio's return-type deduction and associates default completion tokens with executors. The changes extend asynchronous APIs without requiring a separate operation for each calling style.

An asynchronous networking API has to describe both the operation and the way its caller receives completion. Those concerns become harder to combine when an application wants different calling styles without duplicating the networking operation. Boost 1.72 changes Asio's initiation machinery so that completion tokens can incorporate operation-specific initiation into the return type without type erasure.

Boost 1.72.0 was released on December 11. Its versioned Asio revision history identifies the included Asio release as 1.16.0 and explains the return-type change. I find this direction consistent with C++'s strongest abstraction argument. Types can retain information that would otherwise need an indirect runtime representation, although understanding the resulting interface requires more work from its author.

Deducing the operation's return type

Asio's async_initiate helper now deduces its return type when building with C++11 or later. Asynchronous operations themselves use deduced return types with C++14 or later. The revision history distinguishes those two thresholds, so the presence of C++11 support for the helper does not imply that every completion-token implementation can use the newer mechanism in C++11.

For C++11 and earlier, a token's async_result specialization must still supply the nested return_type typedef. With C++14, per-operation return-type deduction lets a token account for the particular initiation involved. This is useful to library authors because one token implementation can participate in operations whose initiation objects differ. The release explicitly describes avoiding type erasure as a consequence.

There is a corresponding vocabulary for constraining the interface. completion_signature<T> checks for a signature of the form R(Args...). completion_handler_for<T, Signature> checks whether a type can act as a handler for that signature. completion_token_for<T, Signature> checks whether a token can be used with async_initiate and the signature.

The compatibility macros for those constraints expand to typename when concepts are unavailable. That lets the library express the stronger requirements where the compiler supports them while preserving a declaration form for older language modes. It does not make the older compiler enforce the same constraints. The distinction matters when reading generic code that uses the same macro spelling across several compiler configurations.

Executors can select the default token

The release also adds default_completion_token. Its argument is an I/O executor type, and its result supplies a default token type for asynchronous operation declarations. Without a specialization, the result is void, which means no default token is available for that executor. Omitting an explicit token therefore requires an executor type with the appropriate default.

Asio specializes the mechanism for use_awaitable. The revision history shows adapting a socket so its executor has that default token. Inside a suitable coroutine, the resulting socket can then use the shorter connection expression below. This is an excerpt illustrating the documented adaptation, not a complete standalone program.

auto socket = use_awaitable.as_default_on(tcp::socket(my_context));
co_await socket.async_connect(my_endpoint);

The socket's type changes during that adaptation. The missing token argument is supplied by a type-level policy associated with its executor. Reading the call consequently requires knowing the socket's type, as well as the member function's name. This is a meaningful convenience for code that consistently uses one completion style, but an interface should make that choice visible where it constructs or names the I/O object.

I am cautious about convenience that requires reconstructing too much surrounding C++ code. Here the documented transformation gives the choice a specific location. The default comes from the adapted object's executor, so a reader can trace the abbreviated call back to the declaration that selected it.

Executor information must survive initiation

The supporting changes extend beyond token defaults. Every I/O object type gains the nested rebind_executor template, which computes a corresponding object type using another executor. Initiation function objects also report their associated executor through executor_type and get_executor(). The history advises treating those members as optional and using the associated-executor trait and helper when appropriate.

Accepted sockets now preserve the acceptor's executor type when the accept operation does not specify another executor or execution context. This connects the generic type machinery to a concrete networking operation. An acceptor configured with a custom executor should produce a socket with that executor type under the documented conditions.

These changes make more sense beside Asio's existing description of its asynchronous model. An initiator starts work, an operation completes, and the completion handler processes its result. The documentation explains that a handler can start the next operation, allowing a higher-level operation to combine several reads or writes. Token and executor machinery sits around that initiation and completion relationship.

The same documentation records the memory consequence of asynchronous I/O. Buffer storage must remain committed for the operation's duration, potentially indefinitely, and concurrent operations need separate buffers. A more convenient completion expression does not remove that requirement. Boost 1.72 gives library authors more ways to express how completion is received while retaining the asynchronous operation's underlying resource obligations.