Background
Over the past few weeks, I’ve been developing a WebSocket server using Boost Beast. The goal is to enable real-time data processing through WebSocket connections, with a twist - each session can communicate and synchronise with each other. In this series of blog posts, I’ll walk through the technology stack, design choices, and the challenges I encountered along the way.
Choosing Boost Beast
Boost Beast is a foundational library for networking in C++, and provides low-level support for HTTP, WebSocket, and other networking protocols. We chose Boost Beast over higher-level libraries (such as uNetworking/uWebSockets or zaphoyd/websocketpp) because our requirements are more complex than usual servers. Other than that, Boost is a reputable organisation in the C++ community, therefore we expect a much more stable library to use. In this post, I’ll focus on one specific aspect - implementing a custom async function using the Stackful Coroutine approach in Boost Beast.
Stackful Coroutine
Boost Beast offers various approaches for WebSocket servers, including Asynchronous, Synchronous, Awaitable, Stackful Coroutine, and Stackless Coroutine. In our project, we opted for a Stackful Coroutine due to its ability to handle many connections efficiently, especially in a resource-limited (cores, threads) environment. A coroutine is more lightweight than multi-threading, while ‘stackfulness’ refers to whether the call stack gets saved every time context switching occurs.
To implement a Stackful Coroutine, Boost Beast provide two important concepts:
yield_context
to enable coroutine context switching, and- Async version of IO functions such as
async_accept
,async_read
, andasync_write
.
yield_context
is like a handler that we can pass to async functions so that coroutine context switching can happen when the IO resource isn’t available yet. This allows the thread to continue the execution of another session. Below is the snippet of Stackful Coroutine example code:
// Echoes back all received WebSocket messages
void do_session(websocket::stream<beast::tcp_stream>& ws, net::yield_context yield) {
beast::error_code ec;
... // Other necessary code
// Accept the websocket handshake
ws.async_accept(yield[ec]);
if(ec)
return fail(ec, "accept");
while (ws.open()) {
// This buffer will hold the incoming message
beast::flat_buffer buffer;
// Read a message
ws.async_read(buffer, yield[ec]);
// This indicates that the session was closed
if(ec == websocket::error::closed)
break;
if(ec)
return fail(ec, "read");
// Echo the message back
ws.text(ws.got_text());
ws.async_write(buffer.data(), yield[ec]);
if(ec)
return fail(ec, "write");
}
}
// Accepts incoming connections and launches the sessions
void do_listen(net::io_context& ioc,
tcp::endpoint endpoint,
net::yield_context yield)
{
beast::error_code ec;
// Open the acceptor
tcp::acceptor acceptor(ioc);
... // other necessary codes
for(;;) {
tcp::socket socket(ioc);
acceptor.async_accept(socket, yield[ec]);
if(ec)
fail(ec, "accept");
else
boost::asio::spawn(
acceptor.get_executor(),
std::bind(
&do_session,
websocket::stream<
beast::tcp_stream>(std::move(socket)),
std::placeholders::_1),
// we ignore the result of the session,
// most errors are handled with error_code
boost::asio::detached);
}
}
Notice the usage of net::yield_context
and async_*
functions. Each placement of async functions is a possible point for coroutine to do context switching. These functions have the characteristic of using network resources that are possibly not always available (e.g., reading messages from clients).
Issue
Then the question is, can we implement a custom function that can utilise this yield_context while waiting for a task to finish? The answer is yes (as I have proven it myself), but unfortunately, there isn’t any clear guide in Boost Beast / Boost ASIO documentation on how to achieve that.
Approach
My first approach was to ‘google’ my way to it. Unfortunately, I could not find the solution straight away. Then, I resort to LLM services: BlackBox AI and ChatGPT. None of them seems to work, they hallucinate really hard. Moreover, it seems that Boost ASIO would break their API in version upgrades, which confused me even more. In desperation, I returned to Google and after a few hours, I found a deeply buried gem that fit my needs. It comes from a discussion in StackOverflow, but unfortunately, I can’t find it anymore. So here is the solution.
The Solution
namespace net = boost::asio;
template <typename Token>
typename net::async_result<typename std::decay<Token>::type,
void(boost::system::error_code, int)>::return_type
static async_wait(std::shared_future<int> sfut, Token&& token) {
auto init = [sfut](auto handler) {
boost::async(
boost::launch::async,
[sfut,
handler = std::move(handler),
work = make_work_guard(handler)]() mutable {
boost::system::error_code ec;
int result = sfut.get();
auto executor = net::get_associated_executor(handler);
net::dispatch(executor, [ec, handler = std::move(handler), result]() mutable {
std::move(handler)(ec, result);
});
});
};
return net::async_initiate<Token, void(boost::system::error_code, int)>(init, token);
}
The solution is to create a template function that:
- Return
net::async_result<>::return_type
, - Accept Token as a parameter, which we will pass the
yield_context
to it, and - Execute
net::async_initiate
that takes lambda that will launch an async task.
With my limited knowledge of Boost ASIO, I would never be able to come up with this solution myself. However, I believe this template code should be shown somewhere in Boost Beast or Boost ASIO documentation, especially Boost Beast. Even to this day, I haven’t fully understood the code itself.
The most important thing is that we can put whatever task in the passed std::shared_future
, or even without the need to use std::future
. For example, we can put any normal function that returns a value to the result variable. Moreover, we should also be able to modify the return type int to other types by changing the second template argument of net::async_result
and net::async_initiate
. For example, from void(boost::system::error_code, int)
to void(boost::system::error_code, std::vector<float>)
.
In conclusion, to create a custom async function on the Boost Beast WebSocket coroutine, we can write a template function that initiates an async task that utilises net::yield_context
.