...one of the most highly
regarded and expertly designed C++ library projects in the
world.
— Herb Sutter and Andrei
Alexandrescu, C++
Coding Standards
Asynchronous operations are started by calling a free function or member
function known as an asynchronous initiating
function. This function accepts parameters specific to
the operation as well as a "completion token." The token is either
a completion handler, or a type defining how the caller is informed of the
asynchronous operation result. Boost.Asio
comes with the special tokens boost::asio::use_future
and boost::asio::yield_context
for using futures
and coroutines respectively. This system of customizing the return value
and method of completion notification is known as the Extensible
Asynchronous Model described in N3747, and a built in to Networking.TS. Here is an example of an initiating
function which reads a line from the stream and echoes it back. This function
is developed further in the next section:
template< class AsyncStream, class CompletionToken> auto async_echo(AsyncStream& stream, CompletionToken&& token)
Authors using Beast can reuse the library's primitives to create their own initiating functions for performing a series of other, intermediate asynchronous operations before invoking a final completion handler. The set of intermediate actions produced by an initiating function is known as a composed operation. To ensure full interoperability and well-defined behavior, Boost.Asio imposes requirements on the implementation of composed operations. These classes and functions make it easier to develop initiating functions and their composed operations:
Table 1.8. Asynchronous Helpers
Name |
Description |
---|---|
This function creates a new handler which, when invoked, calls the original handler with the list of bound arguments. Any parameters passed in the invocation will be substituted for placeholders present in the list of bound arguments. Parameters which are not matched to placeholders are silently discarded. The passed handler and arguments are forwarded into the returned handler, whose associated allocator and associated executor will will be the same as those of the original handler. |
|
This is a smart pointer container used to manage the internal state of a composed operation. It is useful when the state is non trivial. For example when the state has non-movable or contains expensive to move types. The container takes ownership of the final completion handler, and provides boilerplate to invoke the final handler in a way that also deletes the internal state. The internal state is allocated using the final completion handler's associated allocator, benefiting from all handler memory management optimizations transparently. |
This example develops an initiating function called echo. The operation will read up to the first newline on a stream, and then write the same line including the newline back on the stream. The implementation performs both reading and writing, and has a non-trivially-copyable state. First we define the input parameters and results, then declare our initiation function. For our echo operation the only inputs are the stream and the completion token. The output is the error code which is usually included in all completion handler signatures.
/** Asynchronously read a line and echo it back. This function is used to asynchronously read a line ending in a carriage-return ("CR") from the stream, and then write it back. The function call always returns immediately. The asynchronous operation will continue until one of the following conditions is true: @li A line was read in and sent back on the stream @li An error occurs. This operation is implemented in terms of one or more calls to the stream's `async_read_some` and `async_write_some` functions, and is known as a <em>composed operation</em>. The program must ensure that the stream performs no other operations until this operation completes. The implementation may read additional octets that lie past the end of the line being read. These octets are silently discarded. @param The stream to operate on. The type must meet the requirements of @b AsyncReadStream and @AsyncWriteStream @param token The completion token to use. If this is a completion handler, copies will be made as required. The equivalent signature of the handler must be: @code void handler( error_code ec // result of operation ); @endcode Regardless of whether the asynchronous operation completes immediately or not, the handler will not be invoked from within this function. Invocation of the handler will be performed in a manner equivalent to using `boost::asio::io_context::post`. */ template< class AsyncStream, class CompletionToken> BOOST_ASIO_INITFN_RESULT_TYPE( CompletionToken, void(boost::beast::error_code)) async_echo( AsyncStream& stream, CompletionToken&& token);
|
|
This is the signature for the completion handler |
Now that we have a declaration, we will define the body of the function. We want to achieve the following goals: perform static type checking on the input parameters, set up the return value as per N3747, and launch the composed operation by constructing the object and invoking it.
template<class AsyncStream, class Handler> class echo_op; // Read a line and echo it back // template<class AsyncStream, class CompletionToken> BOOST_ASIO_INITFN_RESULT_TYPE(CompletionToken, void(boost::beast::error_code)) async_echo(AsyncStream& stream, CompletionToken&& token) { // Make sure stream meets the requirements. We use static_assert // to cause a friendly message instead of an error novel. // static_assert(boost::beast::is_async_stream<AsyncStream>::value, "AsyncStream requirements not met"); // This helper manages some of the handler's lifetime and // uses the result and handler specializations associated with // the completion token to help customize the return value. // boost::asio::async_completion<CompletionToken, void(boost::beast::error_code)> init{token}; // Create the composed operation and launch it. This is a constructor // call followed by invocation of operator(). We use BOOST_ASIO_HANDLER_TYPE // to convert the completion token into the correct handler type, // allowing user-defined specializations of the async_result template // to be used. // echo_op< AsyncStream, BOOST_ASIO_HANDLER_TYPE( CompletionToken, void(boost::beast::error_code))>{ stream, init.completion_handler}(boost::beast::error_code{}, 0); // This hook lets the caller see a return value when appropriate. // For example this might return std::future<error_code> if // CompletionToken is boost::asio::use_future, or this might // return an error code if CompletionToken specifies a coroutine. // return init.result.get(); }
The initiating function contains a few relatively simple parts. There is
the customization of the return value type, static type checking, building
the return value type using the helper, and creating and launching the
composed operation object. The echo_op
object does most of the work here, and has a somewhat non-trivial structure.
This structure is necessary to meet the stringent requirements of composed
operations (described in more detail in the Boost.Asio
documentation). We will touch on these requirements without explaining
them in depth.
Here is the boilerplate present in all composed operations written in this style:
// This composed operation reads a line of input and echoes it back. // template<class AsyncStream, class Handler> class echo_op { // This holds all of the state information required by the operation. struct state { // The stream to read and write to AsyncStream& stream; // Indicates what step in the operation's state machine // to perform next, starting from zero. int step = 0; // The buffer used to hold the input and output data. // // We use a custom allocator for performance, this allows // the implementation of the io_context to make efficient // re-use of memory allocated by composed operations during // a continuation. // boost::asio::basic_streambuf<typename std::allocator_traits< boost::asio::associated_allocator_t<Handler> >:: template rebind_alloc<char> > buffer; // handler_ptr requires that the first parameter to the // contained object constructor is a reference to the // managed final completion handler. // explicit state(Handler const& handler, AsyncStream& stream_) : stream(stream_) , buffer((std::numeric_limits<std::size_t>::max)(), boost::asio::get_associated_allocator(handler)) { } }; // The operation's data is kept in a cheap-to-copy smart // pointer container called `handler_ptr`. This efficiently // satisfies the CopyConstructible requirements of completion // handlers with expensive-to-copy state. // // `handler_ptr` uses the allocator associated with the final // completion handler, in order to allocate the storage for `state`. // boost::beast::handler_ptr<state, Handler> p_; public: // Boost.Asio requires that handlers are CopyConstructible. // In some cases, it takes advantage of handlers that are // MoveConstructible. This operation supports both. // echo_op(echo_op&&) = default; echo_op(echo_op const&) = default; // The constructor simply creates our state variables in // the smart pointer container. // template<class DeducedHandler, class... Args> echo_op(AsyncStream& stream, DeducedHandler&& handler) : p_(std::forward<DeducedHandler>(handler), stream) { } // Associated allocator support. This is Asio's system for // allowing the final completion handler to customize the // memory allocation strategy used for composed operation // states. A composed operation should use the same allocator // as the final handler. These declarations achieve that. using allocator_type = boost::asio::associated_allocator_t<Handler>; allocator_type get_allocator() const noexcept { return (boost::asio::get_associated_allocator)(p_.handler()); } // Executor hook. This is Asio's system for customizing the // manner in which asynchronous completion handlers are invoked. // A composed operation needs to use the same executor to invoke // intermediate completion handlers as that used to invoke the // final handler. using executor_type = boost::asio::associated_executor_t< Handler, decltype(std::declval<AsyncStream&>().get_executor())>; executor_type get_executor() const noexcept { return (boost::asio::get_associated_executor)( p_.handler(), p_->stream.get_executor()); } // The entry point for this handler. This will get called // as our intermediate operations complete. Definition below. // void operator()(boost::beast::error_code ec, std::size_t bytes_transferred); };
Next is to implement the function call operator. Our strategy is to make
our composed object meet the requirements of a completion handler by being
copyable (also movable), and by providing the function call operator with
the correct signature. Rather than using std::bind
or boost::bind
, which destroys the type information
and therefore breaks the allocation and invocation hooks, we will simply
pass std::move(*this)
as
the completion handler parameter for any operations that we initiate. For
the move to work correctly, care must be taken to ensure that no access
to data members are made after the move takes place. Here is the implementation
of the function call operator for this echo operation:
// echo_op is callable with the signature void(error_code, bytes_transferred), // allowing `*this` to be used as both a ReadHandler and a WriteHandler. // template<class AsyncStream, class Handler> void echo_op<AsyncStream, Handler>:: operator()(boost::beast::error_code ec, std::size_t bytes_transferred) { // Store a reference to our state. The address of the state won't // change, and this solves the problem where dereferencing the // data member is undefined after a move. auto& p = *p_; // Now perform the next step in the state machine switch(ec ? 2 : p.step) { // initial entry case 0: // read up to the first newline p.step = 1; return boost::asio::async_read_until(p.stream, p.buffer, "\r", std::move(*this)); case 1: // write everything back p.step = 2; // async_read_until could have read past the newline, // use buffers_prefix to make sure we only send one line return boost::asio::async_write(p.stream, boost::beast::buffers_prefix(bytes_transferred, p.buffer.data()), std::move(*this)); case 2: p.buffer.consume(bytes_transferred); break; } // Invoke the final handler. The implementation of `handler_ptr` // will deallocate the storage for the state before the handler // is invoked. This is necessary to provide the // destroy-before-invocation guarantee on handler memory // customizations. // // If we wanted to pass any arguments to the handler which come // from the `state`, they would have to be moved to the stack // first or else undefined behavior results. // p_.invoke(ec); return; }
This is the most important element of writing a composed operation, and the part which is often neglected or implemented incorrectly. It is the forwarding of the final handler's associated allocator and associated executor to the composed operation.
Our composed operation stores the final handler and performs its own intermediate asynchronous operations. To ensure that I/O objects, in this case the stream, are accessed safely it is important to use the same executor to invoke intermediate handlers as that used to invoke the final handler. Similarly, for memory allocations our composed operation should use the allocator associated with the final handler.
There are some common mistakes that should be avoided when writing composed operations:
boost::asio::io_context
.
executor_type
and get_executor
for
the composed operation. This will cause undefined behavior. For example,
if someone calls the initiating function with a strand-wrapped function
object, and there is more than thread running on the boost::asio::io_context
, the underlying
stream may be accessed in a fashion that violates safety guarantees.
boost::asio::post
to invoke the final
handler. This breaks the following initiating function guarantee:
Regardless of whether the asynchronous operation completes
immediately or not, the handler will not be invoked from within this
function. Invocation of the handler will be performed in a manner equivalent
to using boost::asio::post
. The function
bind_handler
is provided for
this purpose.
A complete, runnable version of this example may be found in the examples directory.