Boost.Asio Introduction: io_context and async_read
Why Async I/O?
The traditional approach to handling network connections is one thread per connection. It works fine for tens of connections — and breaks down at thousands.
The problem with one-thread-per-connection:
1,000 connections × 1 MB stack per thread = 1 GB just for stacks
Most threads blocked in read() waiting for data
Context switching overhead grows with connection count
Async I/O with Asio:
Small thread pool (e.g., one thread per CPU core, or even a single thread)
Threads execute handlers when I/O completes
Waiting for data costs zero CPU and minimal memory
10,000 idle connections = 10,000 registered file descriptors, not 10,000 threads
Asio wraps the OS-level event notification (epoll on Linux, kqueue on macOS, IOCP on Windows) behind a consistent async interface.
The stack figure deserves a footnote: thread stacks are reserved virtual memory, and the OS only commits the pages a thread actually touches, so 1,000 idle threads rarely consume a full gigabyte of RAM. The real costs are elsewhere. Each blocked thread is a kernel object the scheduler has to manage, context switches between thousands of runnable threads evict CPU caches, and any shared state now needs locking between all of them. Async I/O turns the problem around: instead of a thread waiting on each socket, the kernel tells one event loop which sockets are ready, and a handful of threads run short handlers in response.
The price is a different programming model. Code that was a straight line (read, then process, then write) becomes a chain of callbacks, each started by the previous one, and object lifetimes stop being tied to a function’s scope. Most of the bugs in the “Common Mistakes” section below come from that shift. Asio exists in two flavors with the same API: Boost.Asio (namespace boost::asio, used in this post) and standalone Asio (namespace asio, no Boost dependency). The networking parts of both are what the C++ Networking TS was based on.
io_context and work_guard
io_context
io_context is the event loop. It tracks pending async operations and dispatches completion handlers:
#include <boost/asio.hpp>
namespace asio = boost::asio;
asio::io_context ioc;
// Queue a simple task
asio::post(ioc, [] {
std::cout << "Hello from io_context\n";
});
// Run until no pending work
ioc.run();
// Output: Hello from io_context
Key methods:
run()— blocks until all pending handlers are dispatchedpoll()— runs ready handlers without blocking (useful for game loops)stop()— signals run() to returnrestart()— resets after run() exits, so you can run() again
The central idea is work. io_context counts outstanding work: every async operation you start (async_read, async_wait, …) and every handler you post counts as one unit until its completion handler has run. run() keeps dispatching handlers while that count is above zero and returns when it reaches zero. That is why the post example prints once and exits: one unit of work, one handler, and then nothing left. It also explains the most common Asio question, “why does run() return immediately?”: nothing had been started yet. Handlers only ever run inside a thread that is currently calling run() (or poll(), run_one()), never on some hidden Asio thread, which is what makes single-threaded Asio code free of data races by construction.
work_guard — Keeping the Loop Alive
Without pending work, run() returns immediately. Use work_guard to keep it alive:
auto guard = asio::make_work_guard(ioc);
std::thread t([&ioc] { ioc.run(); });
// ... do async work in other threads ...
guard.reset(); // allow run() to return when work finishes
t.join();
A work guard is an artificial unit of work. While it exists, the count never drops to zero, so a thread that is waiting for work that will be posted later (from a GUI thread, a message queue, or another subsystem) does not exit early. guard.reset() releases it; run() then returns once the real work drains. A common shutdown bug is forgetting the reset, which makes t.join() hang forever because the loop still believes work is pending. If you need to shut down immediately rather than drain, call ioc.stop(), which makes every run() return as soon as the current handlers finish, leaving pending operations unexecuted.
Async Timer
steady_timer is the simplest async operation — useful for timeouts, delays, and periodic tasks:
#include <boost/asio.hpp>
#include <boost/asio/steady_timer.hpp>
#include <chrono>
#include <iostream>
namespace asio = boost::asio;
int main() {
asio::io_context ioc;
asio::steady_timer timer(ioc, std::chrono::seconds(2));
timer.async_wait([](const boost::system::error_code& ec) {
if (!ec) {
std::cout << "Timer fired after 2 seconds\n";
}
});
ioc.run();
}
async_wait returns immediately; the lambda runs later, from inside ioc.run(), when the timer expires. The error_code check is not decoration. A timer’s handler is also called when the wait is cancelled, via timer.cancel(), by changing the expiry of a pending timer, or by destroying the timer, and in those cases ec is asio::error::operation_aborted. Code that ignores ec and runs its timeout logic unconditionally will, for example, close a connection that just received data and cancelled its own timeout. Using steady_timer rather than system_timer matters for the same reason as with std::chrono: a wall-clock adjustment should not make a 30-second timeout fire early or late.
Periodic Timer (Re-armed in Handler)
class PeriodicTask {
asio::steady_timer timer_;
std::chrono::milliseconds interval_;
public:
PeriodicTask(asio::io_context& ioc, std::chrono::milliseconds interval)
: timer_(ioc), interval_(interval) {
schedule();
}
private:
void schedule() {
timer_.expires_after(interval_);
timer_.async_wait([this](const boost::system::error_code& ec) {
if (!ec) {
doWork();
schedule(); // re-arm for next tick
}
});
}
void doWork() {
std::cout << "Tick at "
<< std::chrono::steady_clock::now().time_since_epoch().count()
<< '\n';
}
};
Asio timers are one-shot, so a periodic task re-arms the timer from its own handler. Two details in this class are worth noticing. First, capturing this is safe here only because the handler checks ec before touching the object: destroying PeriodicTask destroys timer_, which cancels the pending wait, and the handler then runs with operation_aborted and returns without using this. If you add code before the ec check that reads a member, it becomes a use-after-free; the shared_from_this() pattern used in the client below is the safer default. Second, expires_after(interval_) measures the interval from “now”, that is, from when the handler ran, so the period slowly drifts by however long doWork() and scheduling latency take. For a fixed rate, advance from the previous deadline instead: timer_.expires_at(timer_.expiry() + interval_);.
Async TCP Client
A typical async client chains operations: resolve → connect → write → read.
#include <boost/asio.hpp>
#include <iostream>
#include <memory>
#include <string>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
class TcpClient : public std::enable_shared_from_this<TcpClient> {
tcp::socket socket_;
asio::streambuf buf_;
std::string request_;
public:
explicit TcpClient(asio::io_context& ioc)
: socket_(ioc) {}
void connect(const std::string& host, const std::string& port, const std::string& msg) {
request_ = msg;
tcp::resolver resolver(socket_.get_executor());
auto endpoints = resolver.resolve(host, port);
asio::async_connect(socket_, endpoints,
[self = shared_from_this()](boost::system::error_code ec, tcp::endpoint) {
if (!ec) self->write();
});
}
private:
void write() {
asio::async_write(socket_, asio::buffer(request_),
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (!ec) self->read();
});
}
void read() {
asio::async_read_until(socket_, buf_, '\n',
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (!ec) {
std::istream is(&self->buf_);
std::string line;
std::getline(is, line);
std::cout << "Response: " << line << '\n';
}
});
}
};
int main() {
asio::io_context ioc;
auto client = std::make_shared<TcpClient>(ioc);
client->connect("localhost", "8080", "hello\n");
ioc.run();
}
The chain works like this: connect starts async_connect and returns; when the connection completes, its handler starts async_write; that handler starts async_read_until; and when the last handler returns without starting anything, there is no work left and ioc.run() in main returns. Each lambda captures self = shared_from_this(), a shared_ptr to the client. As long as an operation is pending, its handler holds a reference, so the client stays alive even though main’s client variable is the only other owner. This is why TcpClient must be created with std::make_shared: calling shared_from_this() on an object not owned by a shared_ptr throws std::bad_weak_ptr.
A few simplifications are worth knowing before reusing this code. resolver.resolve is the synchronous overload, so it blocks the calling thread during DNS lookup; that is fine at startup but not inside a server handler, where async_resolve should be used. Every handler silently gives up on error, so a refused connection just ends the program with no output; real code logs ec.message(). And async_read_until may read more bytes than the first line, because it reads in chunks. The extra bytes stay in buf_, which is why a streambuf that persists across reads is used instead of a fresh buffer each time; getline consumes only up to the delimiter. Finally, there is no timeout: if the server accepts the connection but never answers, this client waits forever. Pairing each network operation with a steady_timer that closes the socket on expiry is the standard remedy.
Async TCP Echo Server
The server pattern: accept → spawn session → loop (read → write → read):
#include <boost/asio.hpp>
#include <iostream>
#include <memory>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
// Session handles one connection lifetime
class Session : public std::enable_shared_from_this<Session> {
tcp::socket socket_;
asio::streambuf buf_;
public:
explicit Session(tcp::socket socket)
: socket_(std::move(socket)) {}
void start() { read(); }
private:
void read() {
asio::async_read_until(socket_, buf_, '\n',
[self = shared_from_this()](boost::system::error_code ec, std::size_t bytes) {
if (!ec) {
self->write(bytes);
} else if (ec != asio::error::eof) {
std::cerr << "Read error: " << ec.message() << '\n';
}
// eof = clean close, just let the session die
});
}
void write(std::size_t bytes) {
// Echo back exactly what we read (including the newline)
asio::async_write(socket_, buf_,
[self = shared_from_this()](boost::system::error_code ec, std::size_t) {
if (!ec) {
self->read(); // wait for next message
}
});
}
};
class Server {
tcp::acceptor acceptor_;
public:
Server(asio::io_context& ioc, unsigned short port)
: acceptor_(ioc, {tcp::v4(), port}) {
accept();
}
private:
void accept() {
acceptor_.async_accept(
[this](boost::system::error_code ec, tcp::socket socket) {
if (!ec) {
std::make_shared<Session>(std::move(socket))->start();
}
accept(); // always re-arm for next connection
});
}
};
int main() {
asio::io_context ioc;
Server server(ioc, 8080);
std::cout << "Echo server on :8080\n";
ioc.run();
}
Nothing in this server owns the Session objects. make_shared<Session>(...)->start() creates one, starts its first read, and immediately drops the temporary shared_ptr; from then on the only owners are the handlers of its pending operations. When a read fails with eof and the handler returns without starting a new operation, the last shared_ptr disappears and the session (with its socket) is destroyed, which closes the connection. This “a session lives exactly as long as it has I/O in flight” design is idiomatic Asio and avoids a separate registry of connections. The trade-off is that the server cannot enumerate or shut down its sessions; if you need graceful shutdown or per-connection limits, keep weak_ptrs to sessions in a container on the server.
Note how the echo works: async_write(socket_, buf_, ...) writes the whole streambuf and consumes it, so everything read so far, possibly more than one line, is echoed and the buffer is emptied for the next read. That is why the bytes parameter of write goes unused. Also note that the session never reads and writes at the same time; it always waits for the write to finish before reading again. Asio does not allow two outstanding async_write calls on the same socket, because their bytes could interleave on the wire. Servers that need to send unsolicited messages keep an outgoing queue and only start the next write from the previous write’s handler.
Handling error_code in handlers
Always check error_code in every handler. Don’t throw in handlers — it propagates to io_context::run() and is usually unhandled:
void handleRead(boost::system::error_code ec, std::size_t bytes) {
if (ec == asio::error::eof) {
// Client closed connection cleanly — not an error
shutdown();
return;
}
if (ec == asio::error::operation_aborted) {
// Operation was cancelled (e.g., timer expired, socket closed)
return;
}
if (ec) {
// connection_reset, broken_pipe, etc.
std::cerr << "I/O error: " << ec.message() << '\n';
shutdown();
return;
}
// Process bytes...
}
Asio reports errors through error_code in the handler instead of exceptions because the failure happens long after the function that started the operation has returned; there is no caller left to catch anything. If a handler does throw, the exception propagates out of io_context::run() in whatever thread was running it. That stops the event loop in that thread, and unless you catch it and call run() again, every other pending operation stalls. The distinctions in the example matter in practice: eof is a normal close, operation_aborted means your own code cancelled the operation (closing a socket or timer cancels everything pending on it), and connection_reset or broken_pipe mean the peer went away abruptly, which happens routinely with mobile clients and should be handled quietly rather than logged as a server fault. Note that bytes can be non-zero even when ec is set, for example a read that received part of the data before the connection closed.
Dangling this, stack buffers, and a stopped io_context
1. Dangling this in handlers
Handlers can execute after the object is destroyed. Always use shared_from_this():
// Wrong
socket_.async_read_some(buf, [this](auto ec, auto n) { /* this might be dead */ });
// Right — extends lifetime until handler runs
socket_.async_read_some(buf, [self = shared_from_this()](auto ec, auto n) { /* safe */ });
2. Stack-allocated buffers
Async operations keep a pointer to the buffer. Stack memory is gone when the enclosing function returns:
// Wrong — buffer destroyed before async_write completes
std::string msg = "hello\n";
asio::async_write(socket_, asio::buffer(msg), handler);
// Right — buffer lives in the session object or heap
// (member variable, shared_ptr<std::string>, etc.)
3. Forgetting to re-accept
If you don’t call async_accept again in the accept handler, the server stops accepting after the first connection:
acceptor_.async_accept([this](auto ec, auto socket) {
if (!ec) handleNewConnection(std::move(socket));
accept(); // ALWAYS re-arm — even on error (unless you want to stop)
});
Re-arming on error has one sharp edge. Some accept errors are persistent, the classic one being “too many open files” (EMFILE) when the process hits its file descriptor limit. Re-arming immediately then fails again instantly, and the server spins at 100% CPU logging the same error thousands of times per second, which is exactly the situation where the machine is already struggling. A short delay before re-accepting after an error (a steady_timer of, say, 100 ms) avoids that, and ulimit -n or the service manager’s LimitNOFILE is usually the real fix. A typical way to run into this is the first load test of a new Asio server: connections pile up faster than sessions close, accept starts failing, and the retry loop makes the logs unreadable until the limit is raised and a backoff added.
4. Running io_context twice without restart
After run() returns, you must call restart() before running again:
ioc.run();
// ... some setup ...
ioc.restart(); // required
ioc.run(); // now works
Running one io_context on a thread pool
For production servers, run the io_context on multiple threads:
asio::io_context ioc;
auto guard = asio::make_work_guard(ioc);
// Thread pool — 4 threads share the event loop
std::vector<std::thread> pool;
for (int i = 0; i < 4; ++i) {
pool.emplace_back([&ioc] { ioc.run(); });
}
// When done:
guard.reset();
for (auto& t : pool) t.join();
When multiple threads run the same io_context, handlers may run concurrently. Use asio::strand to serialize handlers that share state:
auto strand = asio::make_strand(ioc);
// These two handlers will never run simultaneously
asio::post(strand, [] { /* access shared socket */ });
asio::post(strand, [] { /* access shared socket */ });
Posting to a strand is only half the story, because the handlers you care about most are completion handlers of socket operations, which you do not post yourself. The usual way to put a whole connection on a strand is to create the socket with a strand executor: accept with acceptor_.async_accept(asio::make_strand(ioc), handler), and the accepted socket’s operations then complete on that strand. Every handler of that session is serialized without any locks, while different sessions still run in parallel on different threads. Code that skips this and touches the same socket or session state from handlers running on several threads is a data race, and it usually shows up as rare, unreproducible crashes under load rather than as a clear error.
Before reaching for a thread pool at all, it is worth trying a single thread. One run() thread can handle a large number of connections if handlers are short, and it removes the need for strands entirely. Scale out with more threads when profiling shows the event loop thread saturated, or run several independent io_contexts, one per thread, and distribute connections among them, which avoids cross-thread synchronization altogether. Anything CPU-heavy or blocking (database calls, file compression) should not run inside a handler; post it to a separate asio::thread_pool and post the result back.
A Note on Coroutines
The callback chains in this post are the classic style, and understanding them makes every other style easier to debug. Since Boost 1.70 and C++20, Asio also supports coroutines: with asio::co_spawn and asio::use_awaitable, the client becomes co_await async_connect(...); co_await async_write(...); co_await async_read_until(...); in one function, with ordinary local variables and try/catch for errors. The lifetime rules do not disappear, since the coroutine frame now owns the buffers and must outlive the operations, but they become far easier to see. For new code on a C++20 compiler, coroutines are usually the more maintainable choice; see C++ coroutines for the language side.
Asio rules that prevent crashes and hangs
io_contextis the event loop —run()dispatches completion handlers until there’s no more work- Async operations take a callback; they return immediately and call the handler when I/O completes
- Always use
shared_from_this()in handlers to prevent use-after-free when an object outlives its async operations - Never use stack-allocated buffers for async operations — use member variables or
shared_ptr - Always re-arm
async_acceptin the accept handler or the server stops accepting after one connection asio::strandserializes handlers when multiple threads share anio_context, avoiding data races without explicit mutexes
Frequently Asked Questions (FAQ)
Q. Why does io_context::run() return immediately and my handlers never run?
A. run() returns as soon as there is no pending work, so if you call it before starting any async operation, or the last handler finishes without starting a new one, it exits right away. Start the first async_accept, async_read or timer wait before calling run(), and keep the chain alive by starting the next operation inside each completion handler. If worker threads must wait for work that will be posted later, hold an asio::make_work_guard(ioc) until you are ready to shut down.