โฌ†๏ธ โฌ‡๏ธ

Using Boost.Asio with Coroutines TS

Introduction



Using callbacks is a popular approach to building network applications using the Boost.Asio library (and not only that). The problem with this approach is the deterioration of readability and maintainability of the code with the complication of the logic of the data exchange protocol [1] .



As an alternative to callbacks, coroutines can be used to write asynchronous code, the readability level of which will be close to the readability of synchronous code. Boost.Asio supports this approach by providing the ability to use the Boost.Coroutine library to handle callbacks.



Boost.Coroutine implements the coroutines by storing the execution context of the current thread. This approach competed for the inclusion of the C ++ standard in the next edition of the proposal from Microsoft, which introduces the new keywords co_return, co_yield and co_await. The Microsoft offer has received the status of Technical Specification (TS) [2] and has a high chance of becoming a standard.



Article [3] demonstrates the use of Boost.Asio with Coroutines TS and boost :: future. In my article I want to show how you can do without boost :: future. We will take as an example an asynchronous TCP echo server from Boost.Asio and modify it using coroutines from Coroutines TS.





At the time of writing, Coroutines TS is implemented in the Visual C ++ 2017 compilers and clang 5.0. We will use clang. You must set compiler flags to enable experimental support for the C ++ 20 standard (-std = c ++ 2a) and Coroutines TS (-fcoroutines-ts). You also need to include the header file <experimental / coroutine>.





Coroutine to read from socket





In the original example, the function to read from the socket looks like this:



void do_read() { auto self(shared_from_this()); socket_.async_read_some( boost::asio::buffer(data_, max_length), [this, self](boost::system::error_code ec, std::size_t length) { if (!ec) { do_write(length); } }); } 


We initiate an asynchronous read from the socket and set the callback to be called upon receiving the data and initiate sending it back. The original recording function looks like this:



 void do_write(std::size_t length) { auto self(shared_from_this()); boost::asio::async_write( socket_, boost::asio::buffer(data_, length), [this, self](boost::system::error_code ec, std::size_t /*length*/) { if (!ec) { do_read(); } }); } 


If we successfully write data to the socket, we again initiate an asynchronous read. In essence, the program logic is reduced to a loop (pseudocode):



')

 while (!ec) { ec = read(buffer); if (!ec) { ec = write(buffer); } } 


It would be convenient to encode this in the form of an explicit loop, but in that case we would have to read and write synchronous operations. This is not suitable for us, since we want to serve several client sessions in one execution thread at the same time. Coroutines come to the rescue. Rewrite the do_read () function as follows:





 void do_read() { auto self(shared_from_this()); const auto[ec, length] = co_await async_read_some( socket_, boost::asio::buffer(data_, max_length)); if (!ec) { do_write(length); } } 


Using the co_await keyword (as well as co_yield and co_return) turns a function into a coroutine. Such a function has several points (suspension point), where its execution is suspended (suspend) while preserving the state (values โ€‹โ€‹of local variables). Later, the coroutine can be resumed (resume), starting at the last stop. The co_await keyword in our function creates a suspension point: after an asynchronous reading is initiated, the do_read () coroutine execution will be suspended until the reading is completed. The function does not return from the function, but the program continues from the point where the coroutine is called. When the client connects, session :: start () is called, where do_read () is called the first time for this session. After the start of asynchronous reading, the start () function continues, the return from it occurs and the reception of the next connection is initiated. Then the code from Asio, which called the handler argument async_accept (), continues.



In order for the co_await magic to work, its expression โ€” in our case, the async_read_some () function โ€” must return a class object that corresponds to a specific contract. The implementation of async_read_some () is taken from the comment to the article [3] .





 template <typename SyncReadStream, typename DynamicBuffer> auto async_read_some(SyncReadStream &s, DynamicBuffer &&buffers) { struct Awaiter { SyncReadStream &s; DynamicBuffer buffers; std::error_code ec; size_t sz; bool await_ready() { return false; } void await_suspend(std::experimental::coroutine_handle<> coro) { s.async_read_some(std::move(buffers), [this, coro](auto ec, auto sz) mutable { this->ec = ec; this->sz = sz; coro.resume(); }); } auto await_resume() { return std::make_pair(ec, sz); } }; return Awaiter{s, std::forward<DynamicBuffer>(buffers)}; } 


async_read_some () returns an Awaiter class object that implements the contract required by co_await:





If we now try to build our program, we get a compilation error:





 error: this function cannot be a coroutine: 'std::experimental::coroutines_v1::coroutine_traits<void, session &>' has no member named 'promise_type' void do_read() { ^ 


The reason is that the compiler requires that some kind of contract be implemented for the coroutine too. This is done using the std :: experimental :: coroutine_traits template specialization:





 template <typename... Args> struct std::experimental::coroutine_traits<void, Args...> { struct promise_type { void get_return_object() {} std::experimental::suspend_never initial_suspend() { return {}; } std::experimental::suspend_never final_suspend() { return {}; } void return_void() {} void unhandled_exception() { std::terminate(); } }; }; 


We specialized coroutine_traits for coroutines with a return value of type void and any number and type of parameters. The coroutine do_read () fits this description. The template specialization contains the type promise_type with the following functions:





Now you can start the server and check its operation by opening several connections using telnet.





Coroutine to write to socket



The do_write () write function is still based on the use of a callback. Fix it. Rewrite do_write () as follows:





 auto do_write(std::size_t length) { auto self(shared_from_this()); struct Awaiter { std::shared_ptr<session> ssn; std::size_t length; std::error_code ec; bool await_ready() { return false; } auto await_resume() { return ec; } void await_suspend(std::experimental::coroutine_handle<> coro) { const auto[ec, sz] = co_await async_write( ssn->socket_, boost::asio::buffer(ssn->data_, length)); this->ec = ec; coro.resume(); } }; return Awaiter{self, length}; } 


Let's write an awaitable wrapper for writing to the socket:





 template <typename SyncReadStream, typename DynamicBuffer> auto async_write(SyncReadStream &s, DynamicBuffer &&buffers) { struct Awaiter { SyncReadStream &s; DynamicBuffer buffers; std::error_code ec; size_t sz; bool await_ready() { return false; } auto await_resume() { return std::make_pair(ec, sz); } void await_suspend(std::experimental::coroutine_handle<> coro) { boost::asio::async_write( s, std::move(buffers), [this, coro](auto ec, auto sz) mutable { this->ec = ec; this->sz = sz; coro.resume(); }); } }; return Awaiter{s, std::forward<DynamicBuffer>(buffers)}; } 


The last step is to rewrite do_read () as an explicit loop:





 void do_read() { auto self(shared_from_this()); while (true) { const auto[ec, sz] = co_await async_read_some( socket_, boost::asio::buffer(data_, max_length)); if (!ec) { auto ec = co_await do_write(sz); if (ec) { std::cout << "Error writing to socket: " << ec << std::endl; break; } } else { std::cout << "Error reading from socket: " << ec << std::endl; break; } } } 


The program logic is now recorded in a form close to synchronous code, however, it runs asynchronously. The tar spoon is that we had to write an additional awaitable class for the return value of do_write (). This illustrates one of the shortcomings of Coroutines TS - the spread of co_await up the stack of asynchronous calls [4] .



Alteration of the server :: do_accept () function into a coroutine will be left as an exercise. The full text of the program can be found on GitHub .





Conclusion



We looked at using Boost.Asio with Coroutines TS for programming asynchronous network applications. The advantage of this approach is to improve the readability of the code, since it becomes close in shape to synchronous. The disadvantage is the need to write additional wrappers to support the coroutines model implemented in Coroutines TS.





Links



  1. Asynchrony: back to the future
  2. Working Draft, Technical Specification for C ++ Extensions for Coroutines
  3. Using C ++ Coroutines with Boost C ++ Libraries
  4. Objection to making Coroutines with await in C ++ 17

Source: https://habr.com/ru/post/348602/



All Articles