What Are ASIO's I/O Objects and How Are They Used: A Complete Guide

ASIO's I/O objects are class templates that wrap asynchronous resources—including sockets, timers, and acceptors—and bind them to an executor that dispatches completion handlers through an event loop.

ASIO (the standalone version of Boost.Asio maintained in the chriskohlhoff/asio repository) models every asynchronous resource as an I/O object. These objects serve as the primary interface between your application code and the underlying operating system resources, providing a consistent API for initiating asynchronous operations and handling completions.

Core Architecture of ASIO I/O Objects

The architecture relies on a clear separation between the user-facing object, the executor that schedules work, and the service that performs the actual I/O.

The basic_io_object Base Class

All I/O objects inherit (directly or indirectly) from basic_io_object, defined in include/asio/basic_io_object.hpp at lines 53-56. Although this base class is deprecated, it remains the foundation for the current implementation, storing the underlying implementation and providing access to the associated io_context.

Executor Association with any_io_executor

Every I/O object has an executor_type typedef, defaulting to any_io_executor. This polymorphic executor, defined in include/asio/any_io_executor.hpp at lines 37-40, satisfies the property set required by any I/O object and allows generic code to work with different executors—whether from a thread pool, io_context, or custom implementations.

As shown in include/asio/basic_socket.hpp at lines 74-76, the executor controls how completion handlers are dispatched. When you construct an I/O object with an io_context, the object captures the context's executor, ensuring that all callbacks run on that executor's event loop.

The io_context as the Core I/O Service

The io_context class, declared in include/asio/io_context.hpp at lines 66-73, owns the low-level services that perform I/O (such as the socket service and timer service). It also supplies the default executor for all I/O objects created from it. The io_context represents the central hub where asynchronous operations are registered and where their completions are processed.

Common Types of I/O Objects in ASIO

ASIO provides specialized I/O objects for different asynchronous resources:

  • ip::tcp::socket – Stream-oriented TCP socket for reliable, bidirectional communication.
  • ip::udp::socket – Datagram socket for connectionless UDP communication.
  • ip::tcp::acceptor – Listening socket that accepts incoming TCP connections.
  • steady_timer and deadline_timer – Timer objects for time-based asynchronous events, with deadline_timer defined in include/asio/basic_deadline_timer.hpp.
  • basic_pipe – Pipe handle for inter-process communication on supported platforms.

Each of these types follows the same pattern: they inherit the base characteristics, expose an executor, and provide asynchronous member functions that initiate operations through the underlying service.

How to Use ASIO I/O Objects

Working with I/O objects follows a consistent lifecycle across all resource types.

1. Construction with an Executor

An I/O object must be constructed with either an explicit executor or an io_context (from which the executor is obtained).

asio::io_context ctx;
asio::ip::tcp::socket sock(ctx);  // Uses ctx's executor

// Or with an explicit executor
asio::any_io_executor exec = ctx.get_executor();
asio::steady_timer timer(exec);

2. Opening and Binding

Many objects, particularly sockets, must be opened or bound before use. Constructor overloads in basic_socket.hpp can open the resource automatically, or you can call open() and bind() explicitly after construction.

3. Initiating Asynchronous Operations

The object provides async_* member functions that accept completion tokens or handlers. Internally, these functions forward the request to the underlying service (socket service, timer service, etc.) and register the handler with the associated executor.

sock.async_connect(endpoint,
    [&](const asio::error_code& ec) {
        if (!ec) std::cout << "connected!\n";
    });

4. Running the Event Loop

The io_context (or any executor driving the I/O objects) must be run to process pending asynchronous operations.

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

5. Work Tracking for Composed Operations

When composing operations using asio::compose or asio::co_spawn, additional I/O objects can be passed to keep their work alive. This is why many APIs accept a variadic io_objects_or_executors parameter, as referenced in include/asio/compose.hpp at line 41. The executor ensures that the objects remain valid until all asynchronous operations complete.

Practical Code Examples

TCP Echo Server

This example demonstrates the core I/O objects: io_context, ip::tcp::acceptor, and ip::tcp::socket.

#include <asio.hpp>

int main()
{
    // 1. Core I/O object – io_context
    asio::io_context ctx;

    // 2. Listener (I/O object)
    asio::ip::tcp::acceptor acceptor(
        ctx,
        asio::ip::tcp::endpoint(asio::ip::make_address("0.0.0.0"), 12345));

    // 3. Accept loop
    std::function<void()> accept_loop;
    accept_loop = [&]{
        auto socket = std::make_shared<asio::ip::tcp::socket>(ctx);
        acceptor.async_accept(*socket,
            [&, socket](const asio::error_code& ec){
                if (!ec) {
                    // 4. Echo back whatever is read
                    std::array<char, 1024> data;
                    socket->async_read_some(asio::buffer(data),
                        [socket, data](const asio::error_code& ec, std::size_t n) mutable {
                            if (!ec) {
                                asio::async_write(*socket,
                                    asio::buffer(data, n), [](auto...){});
                            }
                        });
                }
                accept_loop();   // keep accepting
            });
    };
    accept_loop();

    // 5. Run the event loop – drives all I/O objects
    ctx.run();
}

Timer with Thread Pool Executor

This example shows how I/O objects can use executors from sources other than io_context, such as thread_pool.

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

int main()
{
    // 1. Create a thread-pool executor (I/O executor)
    asio::thread_pool pool(4);
    asio::any_io_executor exec = pool.get_executor();

    // 2. Timer I/O object bound to the executor
    asio::steady_timer timer(exec, std::chrono::seconds(2));

    // 3. Async wait
    timer.async_wait([&](const asio::error_code& ec){
        if (!ec) std::cout << "Timer fired on thread "
                          << std::this_thread::get_id() << "\n";
    });

    // 4. Run the pool – this drives the timer's executor
    pool.join();
}

Composing Multiple I/O Objects

This coroutine example illustrates how multiple I/O objects (socket and timer) can be kept alive within a composed operation.

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

asio::awaitable<void> echo_with_timeout(asio::ip::tcp::socket sock,
                                        asio::steady_timer timer)
{
    char data[512];
    for (;;) {
        // Wait for either read completion or timeout
        std::size_t n = co_await asio::async_read(sock,
                         asio::buffer(data),
                         asio::as_tuple(asio::use_awaitable));
        co_await asio::async_write(sock,
                         asio::buffer(data, n),
                         asio::use_awaitable);
        timer.expires_after(std::chrono::seconds(10));
        co_await timer.async_wait(asio::use_awaitable); // keep connection alive
    }
}

The coroutine receives two I/O objects by value, ensuring both remain alive for the duration of the composed operation. This pattern demonstrates the flexibility of ASIO's executor model when managing multiple asynchronous resources.

Summary

  • ASIO I/O objects wrap asynchronous resources like sockets, timers, and acceptors, providing a unified interface for initiating operations in the chriskohlhoff/asio library.
  • All I/O objects inherit from basic_io_object (defined in include/asio/basic_io_object.hpp) and associate with an any_io_executor that controls handler dispatch.
  • The io_context serves as the default executor and owns the low-level services that perform I/O, as implemented in include/asio/io_context.hpp.
  • Usage requires constructing objects with an executor, initiating asynchronous operations via async_* methods, and running the executor's event loop to process completions.
  • Multiple I/O objects can be composed together using coroutines or asio::compose, with the executor ensuring proper work tracking and object lifetime management.

Frequently Asked Questions

What is the difference between an I/O object and an executor in ASIO?

An I/O object (such as ip::tcp::socket or steady_timer) represents the actual asynchronous resource and provides methods to initiate operations like async_read or async_wait. An executor (such as any_io_executor or the io_context executor) is responsible for scheduling and dispatching the completion handlers associated with those operations. While the I/O object initiates the work, the executor determines where and when the callback executes.

Can I use ASIO I/O objects without an io_context?

Yes, you can construct I/O objects with any valid executor, such as those from asio::thread_pool or custom strand executors. However, the io_context remains the most common choice because it provides the core I/O services that actually perform the asynchronous operations. If you use a different executor, you must still ensure that an io_context or similar service is running somewhere to process the I/O, or use platform-specific native handles that don't require ASIO's service layer.

How does ASIO ensure I/O objects remain valid during asynchronous operations?

ASIO uses work tracking through the executor associated with the I/O object. When an asynchronous operation is initiated, the service increments the work count for the executor. As shown in include/asio/compose.hpp, composition functions can accept multiple I/O objects to keep them alive until all operations complete. Additionally, using std::shared_ptr or capturing I/O objects in completion handlers (as demonstrated in the TCP echo server example) ensures they remain valid until the callbacks finish executing.

Why is basic_io_object deprecated but still used in the codebase?

The basic_io_object class in include/asio/basic_io_object.hpp is marked as deprecated because ASIO is transitioning toward a more executor-centric model where I/O objects directly store their implementation and executor references. However, the class remains the base for current I/O objects to maintain backward compatibility and provide a migration path. New code should be aware of this deprecation but can rely on the current inheritance structure until the library fully removes the deprecated base class in a future major version.

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 →