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:
-
unhandled_exception()receives an ordinary C++ exception that escaped the coroutine body. It must not return normally. -
unhandled_herbception(std::cxx_std_error)receives a fast_io herbception. It stores the error handle in the promise instead of unwinding through the runtime. The parameter is the raw two-word form,{domain, code}, introduced in Ch19.1; the public::std::errorhandle used incatch throws (::std::error e)is introduced in Ch3.16. They describe the same error at different layers.
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
- One scheduler, one observer, one task type, one event pump. That is the whole model.
-
async_scan,async_printandasync_transmitare the default tools. Usepread/pwriteonly when you are building the layer beneath them. -
open_mode::no_blockmeans async-capable, not "never blocks". -
unhandled_herbceptionstores the error;co_awaitorrethrow_if_error()delivers it. Detached tasks are silent. - Keep an owned task alive until it is done, or detach it. Never drop a suspended task with an operation in flight.
- The loop is single-threaded and cooperative. Blocking work belongs somewhere else.
The next page writes the server: a listener, an accept loop, one detached session per connection, and a supervisor that survives a dead listener.