How to Perform Non-Blocking I/O with ASIO: Complete Implementation Guide

You perform non-blocking I/O with ASIO by initiating async_read or async_write operations on stream objects, which return immediately and invoke a completion handler later through the io_context event loop.

ASIO (Asynchronous I/O) is a cross-platform C++ library for network and low-level I/O programming. To perform non-blocking I/O with ASIO, you leverage the io_context event loop and asynchronous initiating functions defined in headers like asio/read.hpp and asio/write.hpp. This guide examines the actual implementation in the chriskohlhoff/asio repository, showing how these components work together to enable scalable asynchronous operations.

Core Concepts for Non-Blocking I/O

ASIO’s non-blocking architecture relies on several key abstractions that separate the initiation of an operation from its completion.

The io_context Event Loop

The io_context class, defined in asio/io_context.hpp, serves as the central event loop that dispatches I/O completion events. When you call an asynchronous function, the operation is posted to the io_context’s executor, which returns control to your thread immediately. The underlying OS operation continues in the background until readiness is signaled, at which point the library invokes your completion handler.

AsyncReadStream and AsyncWriteStream Concepts

ASIO uses concept-based programming to define stream requirements. The AsyncReadStream and AsyncWriteStream concepts specify that a type must satisfy certain interface requirements to be used with generic async I/O functions. These concepts are implemented by classes like basic_socket in asio/basic_socket.hpp, which provides the primitive async_read_some and async_write_some operations that higher-level functions consume.

Completion Tokens and Handlers

The CompletionToken mechanism determines how asynchronous results are delivered to your code. According to asio/default_completion_token.hpp, you can choose from:

  • Callback functions (functors or lambdas) that receive std::error_code and bytes transferred
  • std::future via asio::use_future (defined in asio/use_future.hpp)
  • C++20 coroutines via asio::use_awaitable (defined in asio/use_awaitable.hpp)

When you call async_read (starting at line 45 in asio/read.hpp) or async_write (starting at line 45 in asio/write.hpp), the function returns immediately and the completion handler runs later on the specified executor.

Typical Non-Blocking Workflow

To implement non-blocking I/O, follow this four-step pattern:

  1. Create an io_context instance to manage the event loop.
  2. Open a stream object (such as tcp::socket) and establish connectivity.
  3. Initiate asynchronous operations using async_read, async_write, or async_connect with your chosen completion token.
  4. Run the event loop by calling io_context.run() on one or more threads to process completions.

Implementation Approaches

Callback-Based Asynchronous Operations

The traditional approach uses functors or lambdas as completion handlers. This provides maximum flexibility for immediate error handling and chaining operations.

#include <asio.hpp>
#include <iostream>
#include <array>

using asio::ip::tcp;

void example_callback()
{
    asio::io_context ctx;
    tcp::socket sock(ctx);
    // Assume sock is already connected...

    std::array<char, 1024> data;

    // Asynchronously read exactly 512 bytes
    asio::async_read(sock,
        asio::buffer(data),
        asio::transfer_exactly(512),
        [&](std::error_code ec, std::size_t bytes_transferred)
        {
            if (!ec)
            {
                std::cout << "Read " << bytes_transferred << " bytes\n";
            }
            else
            {
                std::cerr << "Read error: " << ec.message() << "\n";
            }
        });

    // Asynchronously write a string
    std::string msg = "Hello, ASIO!\n";
    asio::async_write(sock,
        asio::buffer(msg),
        [&](std::error_code ec, std::size_t /*unused*/)
        {
            if (ec) std::cerr << "Write error: " << ec.message() << "\n";
        });

    ctx.run();      // Blocks until all work is complete
}

The lambda handlers capture the result parameters (error_code and bytes_transferred) defined in the async_read and async_write signatures in asio/read.hpp and asio/write.hpp.

Future-Based Asynchronous I/O

For code that requires blocking-style semantics at specific synchronization points, use asio::use_future to convert async operations into std::future objects.

#include <asio.hpp>
#include <string>

using asio::ip::tcp;

void example_future()
{
    asio::io_context ctx;
    tcp::socket sock(ctx);
    // Connect socket...

    std::string msg = "Future style\n";
    auto write_fut = asio::async_write(sock,
                     asio::buffer(msg),
                     asio::use_future);   // Returns std::future<std::size_t>

    // Perform other work here...

    write_fut.get();                     // Blocks until the write finishes
    ctx.run();                           // Process any pending completions
}

This pattern, defined in asio/use_future.hpp, allows integration with existing thread-based code while maintaining non-blocking internals.

C++20 Coroutine Style (co_await)

Modern ASIO supports C++20 coroutines through the awaitable class template in asio/awaitable.hpp, enabling sequential-looking code that executes asynchronously.

#include <asio.hpp>
#include <array>

using asio::ip::tcp;
using asio::awaitable;
using asio::use_awaitable;

awaitable<void> echo(tcp::socket socket)
{
    std::array<char, 1024> buf;
    for (;;)
    {
        std::size_t n = co_await socket.async_read_some(
                            asio::buffer(buf), use_awaitable);
        if (n == 0) co_return;          // Connection closed
        co_await asio::async_write(socket,
                                   asio::buffer(buf, n),
                                   use_awaitable);
    }
}

int main()
{
    asio::io_context ctx;
    tcp::acceptor acc(ctx, tcp::endpoint(tcp::v4(), 12345));

    // Launch a coroutine for each incoming connection
    asio::co_spawn(ctx,
        [&]()->awaitable<void>
        {
            for (;;)
            {
                tcp::socket sock = co_await acc.async_accept(use_awaitable);
                asio::co_spawn(ctx, echo(std::move(sock)), asio::detached);
            }
        },
        asio::detached);

    ctx.run();
}

The co_spawn helper function, implemented in asio/co_spawn.hpp, launches coroutines on the specified executor, while use_awaitable (from asio/use_awaitable.hpp) converts the completion token into an awaitable object that works with co_await.

Serialized Execution with Strands

When multiple threads call io_context.run(), handlers for the same socket can execute concurrently, causing data races. The strand class in asio/strand.hpp provides serialized execution guarantees.

#include <asio.hpp>
#include <array>

using asio::ip::tcp;

void example_strand()
{
    asio::io_context ctx;
    asio::strand<asio::io_context::executor_type> strand(ctx.get_executor());

    tcp::socket sock(ctx);
    // Connect socket...

    // Both write and read are posted through the same strand,
    // guaranteeing they never execute concurrently
    asio::co_spawn(strand,
        [&]() -> asio::awaitable<void>
        {
            std::array<char, 512> buf;
            std::size_t n = co_await sock.async_read_some(
                              asio::buffer(buf), asio::use_awaitable);
            co_await asio::async_write(sock,
                      asio::buffer(buf, n), asio::use_awaitable);
        },
        asio::detached);
    ctx.run();
}

The strand wrapper ensures that handlers posted through it execute sequentially, even when the io_context runs on multiple threads.

Summary

  • Non-blocking I/O in ASIO uses the io_context event loop to dispatch completion handlers without blocking calling threads.
  • Initiating functions like async_read and async_write (defined in asio/read.hpp and asio/write.hpp) return immediately and complete later via the io_context::run() loop.
  • Completion tokens determine result delivery: callbacks for flexibility, asio::use_future for blocking synchronization, or asio::use_awaitable for C++20 coroutines.
  • Thread safety requires asio::strand (from asio/strand.hpp) when multiple threads process the same socket to prevent concurrent handler execution.
  • Key files include asio/io_context.hpp for the event loop, asio/basic_socket.hpp for stream concepts, and asio/co_spawn.hpp for coroutine management.

Frequently Asked Questions

What is the difference between async_read and async_read_some?

async_read is a composed operation that repeatedly calls async_read_some until the buffer is full or a specific condition is met, while async_read_some is a primitive operation that reads whatever data is currently available. According to asio/read.hpp, async_read handles the logic of calling the underlying stream's async_read_some multiple times until completion, whereas async_read_some transfers immediately available bytes and completes right away.

How do I cancel an in-flight asynchronous operation?

ASIO supports operation cancellation through the cancellation_signal class defined in asio/cancellation_signal.hpp. You can attach a cancellation_signal to an operation and emit a cancellation signal (terminal or partial) to stop the operation. The underlying stream must support cancellation for this to take effect, and the completion handler will typically receive an operation_aborted error code.

Can I use ASIO non-blocking I/O without callbacks?

Yes. You can use asio::use_future to receive results as std::future objects, or use C++20 coroutines with asio::use_awaitable to write sequential-looking code that uses co_await. The asio/use_future.hpp and asio/use_awaitable.hpp headers provide these completion tokens, allowing you to avoid manual callback registration while maintaining non-blocking execution.

Do I need a strand if I only use one thread?

No. If you call io_context.run() on only one thread, handlers execute sequentially by definition, and you do not need an asio::strand. However, if you use io_context::poll() or run the io_context on a thread pool, you must use strands (from asio/strand.hpp) to serialize access to shared socket state and prevent data races between concurrent handlers.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →