第23.2章:fast_io 的异步模型

English

1. 四个对象,一个循环

fast_io 的异步层很小。这一章几乎每个程序都由四个对象构成:

对象 它是什么 谁拥有它
io_async_scheduler 操作系统后端:Linux 上的 io_uring 环、Windows 上的 I/O 完成端口,或其他 POSIX 系统上的 线程池。 程序;通常每个事件循环一个。
io_async_observer 调度器的一个小的、可复制的、非拥有视图。它是每个异步操作的第一个参数。 按值传递;调度器必须比它活得久。
io_async_task<T> 协程返回类型。一个惰性任务,拥有自己的帧,可以被等待、恢复、detach,并通过赫布异常通道 报告错误。 任务对象,直到被 detach 或销毁。
io_async_wait(sched) 事件泵。它等待一个完成事件,分发它,并恢复等待它的协程。 由 main 或监督循环调用。

调度器用 io_async 标签创建。标签选择平台最好的后端;类型别名 io_async_scheduler 解析为具体的拥有类型,io_async_observer 解析为它的观察者。

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

你很少需要写第二行。把 sched 传给异步函数时,它会自动退化为观察者, 就像拥有型文件退化为它的观察者一样。

2. 最小的完整程序

这是 fast_io 仓库里的 examples/0045.async_io/a+b.cc。它从文件读两个整数, 相加,把结果写到另一个文件,全程不阻塞事件循环。

#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;
}

读控制流,不要读语法。work(scheduler) 创建帧并返回惰性任务。它还没有运行。 task.resume() 进入函数体。第一个 co_await 提交读操作并暂停。 控制权回到 main,它调用 io_async_wait。读完成后,泵恢复帧, scan 完成,print 提交写操作并再次暂停,循环继续直到任务结束。

主循环就是整个调度器。它是单线程的。内核负责等待;程序只负责回收完成事件并恢复协程。

3. 应该使用的操作

fast_io 在同步和异步形式中提供同一套 I/O 词汇。用与协议匹配的高层操作。不要一上来就用裸的 pread/pwrite,除非你正在实现编解码器或确实需要字节窗口的解析器。

操作 用于 结果
async_scan(sched, timeout, in, args...) 格式化输入:整数、字符串、HTTP 头、token、面向行的协议。当待处理窗口用完时, 它异步补充输入缓冲区。 扫描出的值;错误走通道。
async_print / async_println 格式化输出:数字、字符串、操纵器、HTTP 响应。格式化立即执行;写操作提交给调度器。 无返回值;需要把字节送出去时调用 async_output_stream_flush。
async_transmit_some_bytes 一个方向的字节泵:从输入流读一轮,写到输出流。适合 echo 服务器、代理和文件到 socket 的拷贝。 这一轮移动的字节数;零表示输入结束。
async_transmit_all_bytes 同一个泵,但一直继续到输入结束或达到可选的上界。 整个传输完成时返回 void。
async_accept 在监听 socket 上等待 TCP 连接。 一个拥有的已接受 socket 句柄;用 socket 文件包起来。
async_connect 把 socket 连接到对端地址。 连接完成时返回 void。
async_output_stream_flush 把缓冲的输出排空到设备。 所有待处理字节到达句柄时返回 void。
async_pread_some_bytes / async_pwrite_some_bytes 低层字节窗口。只在协议需要精确字节数,或你正在构建 async_scan、async_print 和 async_transmit 下面的那一层时使用。 这一轮传输的字节数。

规则很简单:输入用 scan,输出用 print,字节移动用 transmit。这三个操作已经处理了 缓冲、部分传输和暂停/恢复协议。降到 pread/pwrite 会把这些都丢掉, 让你重新实现困难的部分。

4. 后端:一套 API,三个引擎

平台 io_async_scheduler 完成如何发生
Linux(非 Android) linux_io_uring io_uring 环。提交队列项携带读、写、accept、connect 和超时;完成队列项驱动回调。 先尝试 SQPOLL,如果内核或 seccomp 策略拒绝,就退回普通环。
Windows 持有 I/O 完成端口的 win32_file 每个异步流都用 open_mode::no_block 打开,它映射为 FILE_FLAG_OVERLAPPED 或 WSA_FLAG_OVERLAPPED。 完成事件到达端口;泵把它们分发给协程。
其他 POSIX posix_thread_pool 线程池在工作线程上执行阻塞系统调用,然后把完成事件投回事件循环。协程仍然暂停和恢复; 只是引擎不同。

源码在这些后端之间不变。这就是操作层的意义。变化的是对句柄的要求:在 Windows 上,用于异步层的 socket 或文件必须用 open_mode::no_block 打开,才是 overlapped 的。在 Linux 上, io_uring 不需要这个标志,但传它是无害的,并且让代码可移植。在线程池后端上,这个标志同样无害。

no_block 这个名字是历史遗留。它不表示“到处使用非阻塞系统调用”;它表示 “让这个句柄可被异步引擎使用”。把它读作异步能力。

5. 错误:异常与赫布异常

每个异步操作都通过与同步操作相同的错误通道报告失败。有两个 promise 钩子:

当协程被等待时,await_resume() 在 co_await 处重新抛出保存的错误。 当协程由 main 驱动时,rethrow_if_error() 在 done() 变为 true 后做同样的事。detached 协程没有人可以报告:promise 的析构函数释放未接收的错误负载, 失败是静默的,除非协程自己处理了它。对于已经断开的每连接会话,这是正确行为;对于需要重启监听器的 监督者,这是错误行为。

参数列表后的 throws 标记是本书的赫布异常语法。在没有赫布异常的编译器上,使用普通 异常语法即可;协程结构和异步调用不变。第3.16章 详细解释这个模型。

6. 任务所有权与 detach

io_async_task 拥有它的帧。如果任务对象仍然持有帧,析构函数会销毁它。这给出两种安全 模式和一种不安全模式:

模式 发生什么 何时使用
保留任务,驱动到 done() resume() 启动;事件循环恢复它;完成后 rethrow_if_error() 报告失败。任务析构函数释放帧。 main 里的顶层操作,或你需要等待的子任务。
task.detach() 任务释放所有权,把帧标记为 detached,并恢复它。帧在 final_suspend 处自行销毁。错误不会报告给调用者。 拥有自己的 socket、没有监督者的每连接会话。
丢弃一个暂停的任务 帧被销毁,而操作可能仍持有指向其 awaiter 的指针。 不要这样做。让任务活到结束,或者 detach 它。

协程参数被复制或移动进帧。把 socket 按值传给会话协程,就把所有权转移给帧:会话结束时 socket 关闭。 传引用会是 bug,因为被引用的对象可能在协程恢复前就死了。

7. 超时、阻塞与流控

每个操作第二个参数都是 posix_statx_timestamp_opt。{} 表示没有超时; 有值表示截止时间过去后操作以超时错误失败。超时是按操作的,不是按连接的。如果你想要空闲超时, 在每次读上设置;如果你想要总截止时间,在每次操作前计算剩余时间。

事件循环是协作式的。在协程里做 CPU 密集循环、同步文件读、等 mutex 或 sleep, 会停住同一调度器上的所有其他协程。把 CPU 工作移到工作线程,或把它切成会 co_await 让出的块。异步层负责调度 I/O;它不会抢占你的代码。

流控是显式的。async_print 格式化进缓冲区并提交写;如果对端很慢,写会慢或部分完成。 操作层处理部分写,但它不会为你发明无界队列。如果你从一个流读、写到更慢的流,用 async_transmit_*,让读侧被写侧限速,或者自己加有界队列。无界队列会把慢客户端 变成内存泄漏。

要点

下一页写服务器:监听器、accept 循环、每连接一个 detached 会话,以及一个能在监听器死亡后存活的 监督者。