第23.2章:fast_io 的异步模型
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 钩子:
-
unhandled_exception()接收逃出协程体的普通 C++ 异常。它不能正常返回。 -
unhandled_herbception(std::cxx_std_error)接收 fast_io 的赫布异常。它把错误句柄 存进 promise,而不是通过运行时展开。参数是原始两字形式{domain, code}, 在第19.1章介绍;catch throws (::std::error e)里的公开::std::error句柄在 第3.16章介绍。它们描述的是不同层上的 同一个错误。
当协程被等待时,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_*,让读侧被写侧限速,或者自己加有界队列。无界队列会把慢客户端
变成内存泄漏。
要点
- 一个调度器、一个观察者、一个任务类型、一个事件泵。整个模型就这些。
-
async_scan、async_print和async_transmit是默认工具。 只有在你构建它们下面那一层时,才用pread/pwrite。 open_mode::no_block表示异步能力,不是“永不阻塞”。-
unhandled_herbception保存错误;co_await或rethrow_if_error()交付它。detached 任务是静默的。 - 让拥有任务活到结束,或者 detach 它。绝不要丢弃一个还有操作在途的暂停任务。
- 循环是单线程、协作式的。阻塞工作属于别处。
下一页写服务器:监听器、accept 循环、每连接一个 detached 会话,以及一个能在监听器死亡后存活的 监督者。