Document Number |
P1442R0 |
Date |
2019-01-18 |
Project |
Programming Language C++ |
Audience |
Library Evolution Working Group |
Summary |
This paper presents a number of improvements to the Networking TS based on experience from Boost. |
- Overview
- 1. CompletionCondition cannot set an error code
- 2. CompletionCondition requires CopyConstructible
- 3. Underspecified ownership of asynchronous initiation function arguments
- 4. Exception safety of temporary storage deallocation
- 5. Lack of ordering guarantees of a single-threaded
io_context
- Acknowledgements
Overview
This paper presents a number of improvements that solve certain issues encountered when implementing high-performance, generic library components (mostly in Boost.Beast) based on top of the reference implementation of the Networking TS in the Boost.ASIO library.
The overall experience with using the Networking TS as a "base" has been positive. Unfortunately, networking at this level of abstraction is quite a complex topic and often confuses even experienced engineers. However, moving the TS "up" in terms of abstraction would come at a compromise to customizability, generality and performance. High-level, 3rd party libraries, built on top of the Networking TS, should be able to satisfy the the most common cases in a concise way.
The issues and improvements that solve them, presented in the rest of this paper, require only minor changes to the Networking TS. References to the TS are relative to N4771.
1. CompletionCondition cannot set an error code
Implementing parsing algorithms for protocols that work on top of a byte stream, like TCP/IP, often requires an online algorithm. Such an algorithm is able to process octets, read into a buffer, without having access to the entire input. This enables saving runtime resources (mostly memory) by facilitating the reuse of a single input buffer. Let us consider the following function object, which satisfies the constraints of CompletionCondition and represents a very simplified algorithm:
namespace net = std::experimental::net;
template<class DynamicBuffer>
struct condition
{
std::size_t operator()(std::error_code const& ec, std::size_t n)
{
std::uint8_t message_type = 0xFF;
net::buffer_copy(
net::buffer(&message_type, sizeof(type)),
db.data());
switch (message_type)
{
case 0x00:
// Not an error, the user gets a "success" error_code
return 0;
case 0x01:
// Not an error, the user gets a "success" error_code
return 0;
default:
// Is an error, we have no way of signalling
// that an error occurred.
return 0;
}
}
DynamicBuffer& db_;
};
The problem encountered here is that the user of a networking algorithm (e.g.
net::read()
) has no way of knowing whether the algorithm completed because the
condition was satisfied successfully or whether the condition encountered an
irrecoverable error when parsing the input and stopped early.
-
Allow the CompletionCondition to accept an
error_code&
as its first argument. -
Guarantee that changes made to the error code via the condition’s first argument are propagated to the caller (in case of synchronous algorithms) or completion handler(asynchronous algorithms).
2. CompletionCondition requires CopyConstructible
This requirement is unnecessary, because CompletionConditions are stored as a member of composed operations, which are only required to satisfy the requirements of MoveConstructible. This prevents users from efficiently reusing temporary storage, which might sometimes be necessary:
template<class DynamicBuffer>
struct condition
{
std::size_t operator()(std::error_code const ec, std::size_t n);
DynamicBuffer& db_;
// Temporary storage that is used for parsing and potentially too large to be
// allocate as a function-local array.
std::vector<char> temporary_;
};
-
Relax the requirement of CompletionCondition from CopyConstructible to MoveConstructible.
3. Underspecified ownership of asynchronous initiation function arguments
"Caller owned" (non-const lvalue references) arguments of asynchronous initiation functions are required to remain valid until the operation’s completion handler is invoked ([async.reqmts.async.lifetime]). The following example presents a declaration of an initiation function template:
template <class AsyncReadStream, class CompletionToken>
auto async_wait_read(AsyncReadStream& s, CompletionToken&& token);
In this example, the argument s
is required to remain valid until this
operation’s completion handler is invoked. However, the lifetime requirements
for operations that never complete are not specified. This most commonly happens
when pending operations and completion handlers are "abandoned" in
ExecutionContext::shutdown()
.
An operation can also be abandoned if the attempt to complete it
(using ex2.dispatch()
) results in an exception being thrown before invocation
(e.g. when performing dynamic allocation of temporary storage). This has a
surprising implication, that the destructor of a copy of a completion
handler, that is neither moved-from, nor invoked, can be destroyed outside the
context of a user’s executor.
-
Define "abandoning an operation" as "destruction of a completion handler that is not moved-from and has never been invoked".
-
Require that caller-owned initiation function arguments remain valid until operation’s completion handler is abandoned or invoked, whichever comes first.
-
Require that completion handler’s move constructor and destructor do not introduce data races if invoked outside its associated executor’s context. This allows implementations of ExecutionContexts to properly handle exceptions being thrown in threads not visible to the user.
4. Exception safety of temporary storage deallocation
Allocations made through a completion handler’s associated allocator must be deallocated before the completion handler is invoked. When performing this deallocation step, whether it happens before invocation or abandonment, the implementation has to move the handler out of the temporary storage:
Handler release_handler(temporary_storage<Handler>* storage)
{
allocator_type alloc{net::get_associated_allocator(storage->handler_)};
std::allocator_traits<allocator_type> traits;
auto h = std::move(storage->handler_); // can throw
traits.destroy(alloc, storage);
traits.deallocate(alloc, storage, 1);
return h;
}
CompletionHandlers are MoveConstructible, which implies their move constructors
can throw. The problem this creates, is that move constructors of handlers are
often invoked in "cleanup" contexts. In the above example, the exception thrown
out of the move constructor would propagate out to the caller, but it results in
a memory leak. The leak cannot be solved with either RAII or a catch
clause,
because by the time we catch the exception or execute a destructor we do not
have access to a copy of the handler that is neither invoked nor moved-from, therefore we
have to assume that the memory resource represented by the allocator is no
longer valid:
Handler release_handler(temporary_storage<Handler>* storage)
{
allocator_type alloc{net::get_associated_allocator(storage->handler_)};
std::allocator_traits<allocator_type> traits;
try {
auto h = std::move(storage->handler_); // can throw
traits.destroy(alloc, storage);
traits.deallocate(alloc, storage, 1);
return h;
} catch(...) {
// alloc may no longer be valid
traits.destroy(alloc, storage);
// even if alloc was still valid and the ownership remained in the source object,
// we just destroyed it, so the next line uses an allocator which
// may not refer to a valid memory resource
traits.deallocate(alloc, storage, 1);
throw;
}
}
-
Require that the allocator copy have shared ownership of the memory resource or that the handler does not participate in ownership of the memory resource at all.
-
Disallow throwing move constructors.
-
Allow the implementation to call the move constructor in a
noexcept
context (effectively means that handler move constructors can can throw, but may result in termination in some contexts).
The first option severely limits the usage patterns of allocators associated with a completion handlers and misuse is impossible to detect.
The second one, requires the author of a composed operation’s state machine to
manually define the move constructor, to make it possible to use some
common types that have potentially throwing move constructors (e.g. std::map
)
as non-static data members.
The last one seems to allow most flexibility in terms of usage patterns of
associated allocators. Calls to move constructors of completion
handlers may occur in contexts, in which an exception being thrown already
results in termination. An example of such a context would be a completion
handler being moved in a call to net::dispatch()
when the operation completes,
which can be performed by a private thread spawned by the implementation.
5. Lack of ordering guarantees of a single-threaded io_context
A very common pattern of usage in application based on the Networking TS is
using an "implicit strand". The user makes sure that no data races occur, by
only allowing at most one thread to run completion handlers on a particular
ExecutionContext. The problem is that io_context
lacks ordering guarantees in
the single-threaded case, which might be problematic to some higher-level
components built on top of it, that assume FIFO ordering (which is provided by net::strand
).
The following snippet presents where this problem may be observable:
int main()
{
net::io_context io;
net::post(io, []{ std::cout << "op1 "; });
net::post(io, []{ std::cout << "op2"; });
io.run();
}
The user might expect the output to be op1 op2
, but surprisingly there are no ordering
guarantees even for this implicitly synchronized case. It is unlikely the implementation can
gain anything by using LIFO ordering for executing completion handlers.
-
Provide an ordering guarantee for
io_context
if it only has 1 thread running it.
Acknowledgements
Many thanks to Vinnie Falco for feedback on drafts of this paper.