...one of the most highly
regarded and expertly designed C++ library projects in the
world.
— Herb Sutter and Andrei
Alexandrescu, C++
Coding Standards
Network programs must handle adverse connection conditions; the most common is that a connected peer goes offline unexpectedly. Protocols have no way of identifying this reliably: the peer is offline after all, and unable to send a message announcing the absence. A peer can go offline for various reasons:
To determine when a peer is offline or idle, a program will implement a timeout algorithm, which closes the connection after a specified amount of time if some condition is met. For example, if no data is received for the duration. A timeout may be used to:
Traditionally, programs use a net::steady_timer
to determine when a timeout occurs, and then call close
on the socket to release
the resources. The complexity of managing a separate timer is often a source
of frustration
for non-experts.
Note | |
---|---|
For portability reasons, networking does not provide timeouts or cancellation features for synchronous stream operations. |
To simplify the handling of timeouts, these provided types wrap a net::basic_stream_socket
to provide additional features:
Name |
Features |
---|---|
|
|
|
The tcp_stream
is designed
as a replacement for net::ip::tcp::socket
. Any program which currently
uses a socket, can switch to a tcp_stream
and achieve the features above (although some interfaces are different, see
below). Networking now allows I/O objects to construct with any instance
of ExecutionContext
or Executor
objects. Here we construct a stream which uses a particular I/O context to
dispatch completion handlers:
// `ioc` will be used to dispatch completion handlers tcp_stream stream(ioc);
Alternatively, we can construct the stream from an executor:
// The resolver is used to look up the IP addresses for a domain name net::ip::tcp::resolver resolver(ioc); // The stream will use the same executor as the resolver tcp_stream stream(resolver.get_executor());
The function make_strand
returns a strand constructed
from an execution context or executor. When a net::strand
is chosen for the stream's executor, all completion handlers which do not
already have an associated executor will use the strand. This is both a notational
convenience (no need for strand::wrap
or bind_executor
at call
sites) and a measure of safety, as it is no longer possible to forget to
use the strand.
// The strand will be used to invoke all completion handlers tcp_stream stream(net::make_strand(ioc));
Before data can be exchanged, the stream needs to be connected to a peer.
The following code sets a timeout for an asynchronous connect operation.
In Beast, functions to connect to a range of endpoints (such as the range
returned by net::ip::tcp::resolver::resolve
) are members of the class
rather than free functions such as net::async_connect
.
// Set the logical operation timer to 30 seconds stream.expires_after (std::chrono::seconds(30)); // If the connection is not established within 30 seconds, // the operation will be canceled and the handler will receive // error::timeout as the error code. stream.async_connect(resolver.resolve("www.example.com", "http"), [](error_code ec, net::ip::tcp::endpoint ep) { if(ec == error::timeout) std::cerr << "async_connect took too long\n"; else if(! ec) std::cout << "Connected to " << ep << "\n"; } ); // The timer is still running. If we don't want the next // operation to time out 30 seconds relative to the previous // call to `expires_after`, we need to turn it off before // starting another asynchronous operation. stream.expires_never();
A server will use an acceptor bound to a particular IP address and port to
listen to and receive incoming connection requests. The acceptor returns
an ordinary socket. A tcp_stream
can be move-constructed from the underlying basic_stream_socket
thusly:
// The acceptor is used to listen and accept incoming connections. // We construct the acceptor to use a new strand, and listen // on the loopback address with an operating-system assigned port. net::ip::tcp::acceptor acceptor(net::make_strand(ioc)); acceptor.bind(net::ip::tcp::endpoint(net::ip::make_address_v4("127.0.0.1"), 0)); acceptor.listen(0); // This blocks until a new incoming connection is established. // Upon success, the function returns a new socket which is // connected to the peer. The socket will have its own executor, // which in the call below is a new strand for the I/O context. net::ip::tcp::socket s = acceptor.accept(net::make_strand(ioc)); // Construct a new tcp_stream from the connected socket. // The stream will use the strand created when the connection // was accepted. tcp_stream stream(std::move(s));
Timeouts apply to the logical operation, expressed as a series of asynchronous calls, rather than just the next call. This code reads a line from the stream and writes it back. Both the read and the write must complete within 30 seconds from when the timeout was set; the timer is not reset between operations.
std::string s; // Set the logical operation timer to 30 seconds. stream.expires_after (std::chrono::seconds(30)); // Read a line from the stream into the string. net::async_read_until(stream, net::dynamic_buffer(s), '\n', [&s, &stream](error_code ec, std::size_t bytes_transferred) { if(ec) return; // read_until can read past the '\n', these will end up in // our buffer but we don't want to echo those extra received // bytes. `bytes_transferred` will be the number of bytes // up to and including the '\n'. We use `buffers_prefix` so // that extra data is not written. net::async_write(stream, buffers_prefix(bytes_transferred, net::buffer(s)), [&s](error_code ec, std::size_t bytes_transferred) { // Consume the line from the buffer s.erase(s.begin(), s.begin() + bytes_transferred); if(ec) std::cerr << "Error: " << ec.message() << "\n"; }); });
Since reads and writes can take place concurrently, it is possible to have two simultaneous logical operations where each operation either only reads, or only writes. The beginning of a new read or write operation will use the most recently set timeout. This will not affect operations that are already outstanding.
std::string s1; std::string s2; // Set the logical operation timer to 15 seconds. stream.expires_after (std::chrono::seconds(15)); // Read another line from the stream into our dynamic buffer. // The operation will time out after 15 seconds. net::async_read_until(stream, net::dynamic_buffer(s1), '\n', handler); // Set the logical operation timer to 30 seconds. stream.expires_after (std::chrono::seconds(30)); // Write the contents of the other buffer. // This operation will time out after 30 seconds. net::async_write(stream, net::buffer(s2), handler);
When a timeout is set, it cancels any previous read or write timeout for
which no outstanding operation is in progress. Algorithms which loop over
logical operations simply need to set the timeout once before the logical
operation, it is not necessary to call expires_never
in this case. Here we implement an algorithm which continuously echoes lines
back, with a timeout. This example is implemented as a complete function.
/** This function echoes back received lines from a peer, with a timeout. The algorithm terminates upon any error (including timeout). */ template <class Protocol, class Executor> void do_async_echo (basic_stream<Protocol, Executor>& stream) { // This object will hold our state when reading the line. struct echo_line { basic_stream<Protocol, Executor>& stream; // The shared pointer is used to extend the lifetime of the // string until the last asynchronous operation completes. std::shared_ptr<std::string> s; // This starts a new operation to read and echo a line void operator()() { // If a line is not sent and received within 30 seconds, then // the connection will be closed and this algorithm will terminate. stream.expires_after(std::chrono::seconds(30)); // Read a line from the stream into our dynamic buffer, with a timeout net::async_read_until(stream, net::dynamic_buffer(*s), '\n', std::move(*this)); } // This function is called when the read completes void operator()(error_code ec, std::size_t bytes_transferred) { if(ec) return; net::async_write(stream, buffers_prefix(bytes_transferred, net::buffer(*s)), [this](error_code ec, std::size_t bytes_transferred) { s->erase(s->begin(), s->begin() + bytes_transferred); if(! ec) { // Run this algorithm again echo_line{stream, std::move(s)}(); } else { std::cerr << "Error: " << ec.message() << "\n"; } }); } }; // Create the operation and run it echo_line{stream, std::make_shared<std::string>()}(); }
It is important to note that all of the examples thus far which perform reads
and writes with a timeout, make use of the existing networking stream algorithms.
As these algorithms are written generically to work with any object meeting
the stream requirements, they transparently support timeouts when used with
tcp_stream
. This can be used
to enable timeouts for stream wrappers that do not currently support timeouts.
The following code establishes an encrypted connection, writes an HTTP request,
reads the HTTP response, and closes the connection gracefully. If these operations
take longer than 30 seconds total, a timeout occurs. This code is intended
to show how tcp_stream
can
be used to enable timeouts across unmodified stream algorithms which were
not originally written to support timing out, and how a blocking algorithm
may be written from asynchronous intermediate operations.
/** Request an HTTP resource from a TLS host and return it as a string, with a timeout. This example uses fibers (stackful coroutines) and its own I/O context. */ std::string https_get (std::string const& host, std::string const& target, error_code& ec) { // It is the responsibility of the algorithm to clear the error first. ec = {}; // We use our own I/O context, to make this function blocking. net::io_context ioc; // This context is used to hold client and server certificates. // We do not perform certificate verification in this example. net::ssl::context ctx(net::ssl::context::tlsv12); // This string will hold the body of the HTTP response, if any. std::string result; // Note that Networking TS does not come with spawn. This function // launches a "fiber" which is a coroutine that has its own separately // allocated stack. boost::asio::spawn(ioc, [&](boost::asio::yield_context yield) { // We use the Beast ssl_stream wrapped around a beast tcp_stream. ssl_stream<tcp_stream> stream(ioc, ctx); // The resolver will be used to look up the IP addresses for the host name net::ip::tcp::resolver resolver(ioc); // First, look up the name. Networking has its own timeout for this. // The `yield` object is a CompletionToken which specializes the // `net::async_result` customization point to make the fiber work. // // This call will appear to "block" until the operation completes. // It isn't really blocking. Instead, the fiber implementation saves // the call stack and suspends the function until the asynchronous // operation is complete. Then it restores the call stack, and resumes // the function to the statement following the async_resolve. This // allows an asynchronous algorithm to be expressed synchronously. auto const endpoints = resolver.async_resolve(host, "https", {}, yield[ec]); if(ec) return; // The function `get_lowest_layer` retrieves the "bottom most" object // in the stack of stream layers. In this case it will be the tcp_stream. // This timeout will apply to all subsequent operations collectively. // That is to say, they must all complete within the same 30 second // window. get_lowest_layer(stream).expires_after(std::chrono::seconds(30)); // `tcp_stream` range connect algorithms are member functions, unlike net:: get_lowest_layer(stream).async_connect(endpoints, yield[ec]); if(ec) return; // Perform the TLS handshake stream.async_handshake(net::ssl::stream_base::client, yield[ec]); if(ec) return; // Send an HTTP GET request for the target { http::request<http::empty_body> req; req.method(http::verb::get); req.target(target); req.version(11); req.set(http::field::host, host); req.set(http::field::user_agent, "Beast"); http::async_write(stream, req, yield[ec]); if(ec) return; } // Now read the response flat_buffer buffer; http::response<http::string_body> res; http::async_read(stream, buffer, res, yield[ec]); if(ec) return; // Try to perform the TLS shutdown handshake stream.async_shutdown(yield[ec]); // `net::ssl::error::stream_truncated`, also known as an SSL "short read", // indicates the peer closed the connection without performing the // required closing handshake (for example, Google does this to // improve performance). Generally this can be a security issue, // but if your communication protocol is self-terminated (as // it is with both HTTP and WebSocket) then you may simply // ignore the lack of close_notify: // // https://github.com/boostorg/beast/issues/38 // // https://security.stackexchange.com/questions/91435/how-to-handle-a-malicious-ssl-tls-shutdown // // When a short read would cut off the end of an HTTP message, // Beast returns the error beast::http::error::partial_message. // Therefore, if we see a short read here, it has occurred // after the message has been completed, so it is safe to ignore it. if(ec == net::ssl::error::stream_truncated) ec = {}; else if(ec) return; // Set the string to return to the caller result = std::move(res.body()); }, // this will capture exceptions thrown by the coroutine, // which we're ignoring, since we're using error_codes to capture them. asio::detached); // `run` will dispatch completion handlers, and block until there is // no more "work" remaining. When this call returns, the operations // are complete and we can give the caller the result. ioc.run(); return result; }