Quick answer: io_context::run() blocks until there’s no work. Don’t call it on the game thread — spawn a dedicated worker thread for it, and marshal completion handlers back via a thread-safe queue.
A networked game freezes when there’s no traffic. io_context.run() was called on the main thread “briefly” — but it blocks until there’s work, which is forever when idle.
Run on a Worker Thread
asio::io_context ctx;
auto work = asio::make_work_guard(ctx); // keep run() alive when idle
std::thread io_thread([&]() { ctx.run(); });
The worker thread owns Asio. Your game thread stays free. The work guard keeps run() alive even with no pending ops — remove it when shutting down so run() returns and the thread joins.
Marshal Results Back
Asio completion handlers run on whichever thread called run() — the worker. Don’t touch game state from there. Push results onto a thread-safe queue; drain it on the game thread each frame.
// in handler (worker thread):
results_queue.push(parsed);
// in game loop (main thread):
while (results_queue.try_pop(r)) ApplyResult(r);
Strands for Ordered Handlers
If multiple worker threads run the same io_context (a pool), use a strand to order handlers for one connection. Avoids interleaving from the same socket.
Verifying
The game runs smoothly with no network traffic. Connections handle messages without main-thread stalls. Shutdown is clean: release the work guard, join the thread.
“Asio runs where you call run(). Put it on a worker; marshal results back to the game thread.”
Same pattern works for any external IO framework — libuv, grpc, custom socket loops. Network on its own thread, game thread reads results.