Ch23.2: fast_io's Async Model

中文(简体)

1. Four Objects, One Loop

fast_io's async layer is small. Almost every program in this chapter is built from four objects:

Object What it is Who owns it
io_async_scheduler The OS backend: an io_uring ring on Linux, an I/O completion port on Windows, or a thread pool on other POSIX systems. The program; usually one per event loop.
io_async_observer A small, copyable, non-owning view of the scheduler. It is the first parameter of every async operation. Passed by value; the scheduler outlives it.
io_async_task<T> The coroutine return type. A lazy task that owns its frame, can be awaited, resumed, detached, and reports errors through the herbception channel. The task object, until it is detached or destroyed.
io_async_wait(sched) The event pump. It waits for one completion, dispatches it, and resumes the coroutine that was waiting for it. Called by main or by a supervisor loop.

The scheduler is created with the io_async tag. The tag selects the platform's best backend; the type alias io_async_scheduler resolves to the concrete owning type, and io_async_observer resolves to its observer.

fast_io::io_async_scheduler sched{fast_io::io_async};
fast_io::io_async_observer observer{sched};

You rarely write the second line. Passing sched to an async function decays it to the observer automatically, exactly as an owning file decays to its observer.

2. The Smallest Complete Program

This is examples/0045.async_io/a+b.cc from the fast_io repository. It reads two integers from a file, adds them, and writes the result to another file, all without blocking the event loop.

#include <fast_io.h>
#include <fast_io_device.h>

namespace fi = ::fast_io;

static fi::io_async_task<> work(fi::io_async_observer sched) throws
{
    fast_io::ibuf_file inb{
        "input.txt", fast_io::open_mode::in | fast_io::open_mode::no_block};
    fast_io::obuf_file outb{
        "output.txt", fast_io::open_mode::out | fast_io::open_mode::no_block};

    std::size_t a{}, b{};
    co_await fi::async_scan(sched, {}, inb, a, b);
    co_await fi::async_println(sched, {}, outb, a + b);
    co_await fi::operations::async_output_stream_flush(sched, {}, outb);
}

int main()
try
{
    fi::io_async_scheduler scheduler{fi::io_async};

    auto task{work(scheduler)};
    task.resume();
    while (!task.done())
    {
        fi::io_async_wait(scheduler);
    }
    task.rethrow_if_error();
}
catch throws(::std::error e)
{
    fi::perrln(e);
    return 1;
}

Read the control flow, not the syntax. work(scheduler) creates a frame and returns a lazy task. It has not run yet. task.resume() enters the body. The first co_await submits a read and suspends. Control returns to main, which calls io_async_wait. When the read completes, the pump resumes the frame, the scan finishes, the print submits a write and suspends again, and the loop continues until the task is done.

The main loop is the entire scheduler. It is single-threaded. The kernel does the waiting; the program only reaps completions and resumes coroutines.

3. The Operations You Should Use

fast_io provides the same I/O vocabulary in synchronous and asynchronous form. Use the high-level operation that matches the protocol. Do not start with raw pread/pwrite unless you are implementing a codec or a parser that genuinely needs a byte window.

Operation Use it for Result
async_scan(sched, timeout, in, args...) Formatted input: integers, strings, HTTP headers, tokens, line-oriented protocols. It refills the input buffer asynchronously when the pending window runs out. The scanned values; errors through the channel.
async_print / async_println Formatted output: numbers, strings, manipulators, HTTP responses. Formatting runs eagerly; the write is submitted to the scheduler. Void; call async_output_stream_flush when you need the bytes out.
async_transmit_some_bytes A byte pump in one direction: read a round from an input stream and write it to an output stream. Perfect for echo servers, proxies and file-to-socket copies. The number of bytes moved in this round; zero means end of input.
async_transmit_all_bytes The same pump, but continue until the input ends or the optional bound is reached. Void when the whole transfer is complete.
async_accept Wait for a TCP connection on a listening socket. An owning accepted socket handle; wrap it in a socket file.
async_connect Connect a socket to a peer address. Void when connected.
async_output_stream_flush Drain buffered output to the device. Void when every pending byte reached the handle.
async_pread_some_bytes / async_pwrite_some_bytes Low-level byte windows. Use only when the protocol needs exact byte counts or when you are building the layer that async_scan, async_print and async_transmit sit on. Bytes transferred in this round.

The rule is simple: scan for input, print for output, transmit for byte movement. These three operations already handle buffering, partial transfers and the suspend/resume protocol. Dropping to pread/pwrite throws that away and makes you reimplement the hard parts.

4. Backends: One API, Three Engines

Platform io_async_scheduler How completion works
Linux (non-Android) linux_io_uring An io_uring ring. Submission queue entries carry reads, writes, accepts, connects and timeouts; completion queue entries drive the callbacks. SQPOLL is tried first and a plain ring is used if the kernel or seccomp policy refuses it.
Windows win32_file holding an I/O completion port Every async-capable stream is opened with open_mode::no_block, which maps to FILE_FLAG_OVERLAPPED or WSA_FLAG_OVERLAPPED. Completions arrive on the port; the pump dispatches them to the coroutine.
Other POSIX posix_thread_pool A pool performs the blocking syscall on a worker and posts the completion back to the event loop. The coroutine still suspends and resumes; only the engine differs.

The source code does not change between these backends. That is the point of the operation layer. What changes is the requirement on the handle: on Windows, a socket or file used with the async layer must be opened with open_mode::no_block so it is overlapped. On Linux, io_uring does not need the flag, but passing it is harmless and keeps the code portable. On a thread-pool backend, the flag is also harmless.

The no_block name is historical. It does not mean "use non-blocking syscalls everywhere"; it means "make this handle usable by the asynchronous engine". Read it as async-capable.

5. Errors: Exceptions and Herbceptions

Every async operation reports failure through the same error channel as its synchronous counterpart. There are two promise hooks:

When a coroutine is awaited, await_resume() rethrows the stored error at the co_await site. When a coroutine is driven from main, rethrow_if_error() does the same after done() becomes true. A detached coroutine has nobody to report to: the promise's destructor releases an unreceived error payload, and the failure is silent unless the coroutine handled it itself. That is the correct behavior for a per-connection session that has already disconnected, and the wrong behavior for a supervisor that needs to restart a listener.

The throws marker after the parameter list is this book's herbception syntax. On a compiler without herbceptions, use the ordinary exception syntax; the coroutine structure and the async calls are unchanged. Ch3.16 explains the model in detail.

6. Task Ownership and Detachment

io_async_task owns its frame. The destructor destroys the frame if the task object still holds it. This gives you two safe patterns and one unsafe one:

Pattern What happens When to use it
Keep the task, drive it to done() resume() starts it; the event loop resumes it; after completion, rethrow_if_error() reports failure. The task destructor frees the frame. The top-level operation in main, or a child you need to await.
task.detach() The task releases ownership, marks the frame detached, and resumes it. The frame destroys itself at final_suspend. Errors are not reported to the caller. A per-connection session that owns its socket and has no supervisor.
Drop a suspended task The frame is destroyed while an operation may still hold a pointer to its awaiter. Do not do this. Keep the task alive until it is done, or detach it.

A coroutine parameter is copied or moved into the frame. Passing a socket by value to a session coroutine therefore transfers ownership to the frame: when the session finishes, the socket closes. Passing a reference would be a bug, because the referenced object may die before the coroutine resumes.

7. Timeouts, Blocking and Flow Control

Every operation takes a posix_statx_timestamp_opt as its second argument. {} means no timeout; a value means the operation fails with a timeout error when the deadline elapses. The timeout is per operation, not per connection. If you want an idle timeout, arm it on each read; if you want a total deadline, compute the remaining time before each operation.

The event loop is cooperative. A CPU-bound loop, a synchronous file read, a mutex wait or a sleep inside a coroutine stops every other coroutine on the same scheduler. Move CPU work to a worker, or break it into chunks that co_await a yield. The async layer schedules I/O; it does not preempt your code.

Flow control is explicit. async_print formats into a buffer and submits the write; if the peer is slow, the write completes slowly or partially. The operation layer handles the partial write, but it does not invent an unbounded queue for you. If you read a stream and write to a slower stream, use async_transmit_* so the read side is throttled by the write side, or add your own bounded queue. An unbounded queue turns a slow client into a memory leak.

Key Takeaways

The next page writes the server: a listener, an accept loop, one detached session per connection, and a supervisor that survives a dead listener.