第23.3章:异步 TCP echo 服务器
1. 先看同步版本
仓库里的同步 echo 服务器几乎不值一提:
fast_io::net_service service;
fast_io::native_socket_file listener{fast_io::tcp_listen(2000)};
for (;;)
{
fast_io::iobuf_socket_file client{fast_io::tcp_accept(listener)};
fast_io::println(client, "hello");
}
一个线程接受一个连接,写入,关闭,然后循环。它一次只能服务一个客户端。如果客户端读得慢,
服务器会阻塞在 println 里,停止接受新连接。解决办法不是“每个客户端加一个线程”,
而是让连接暂停,而不是阻塞线程。
异步服务器保持同样的形状——监听器、accept、会话——但每个等待都变成
co_await。accept 循环不阻塞,会话不阻塞,一个线程可以拥有成千上万个暂停的会话。
2. 完整的服务器
这是 examples/0017.network/async/tcp_echo_server.cc。先读一遍,不要纠结细节;
后面的小节会拆开它。
#include <fast_io.h>
#include <fast_io_device.h>
namespace fi = ::fast_io;
namespace fop = ::fast_io::operations;
static fi::io_async_task<> echo_session(
fi::io_async_observer sched, fi::native_socket_file sock) throws
{
co_await fop::async_transmit_some_bytes(sched, {}, sock, {}, sock, {}, {});
}
static fi::io_async_task<> accept_loop(
fi::io_async_observer sched, fi::native_socket_file listener) throws
{
for (;;)
{
echo_session(
sched,
co_await fop::async_accept(
sched, {}, listener, fi::open_mode::no_block))
.detach();
}
}
int main()
{
fi::net_service service;
fi::io_async_scheduler scheduler{fi::io_async};
for (;;)
try
{
auto acceptor{accept_loop(
scheduler,
fi::native_socket_file{
fi::tcp_listen(2000, fi::open_mode::no_block)})};
acceptor.resume();
while (!acceptor.done())
{
fi::io_async_wait(scheduler);
}
acceptor.rethrow_if_error();
}
catch throws(::std::error e)
{
fi::perrln(e);
}
}
恰好有两个协程:accept_loop 和 echo_session。accept 循环活到监听器
的生命周期结束。每个会话活到一个连接的生命周期结束。main 是监督者和事件泵。
3. 会话:一次 transmit,没有缓冲区
static fi::io_async_task<> echo_session(
fi::io_async_observer sched, fi::native_socket_file sock) throws
{
co_await fop::async_transmit_some_bytes(sched, {}, sock, {}, sock, {}, {});
}
async_transmit_some_bytes 的参数是:
| 参数 | 这里的值 | 含义 |
|---|---|---|
| 调度器 | sched |
将恢复这个协程的事件循环。 |
| 超时 | {} |
没有截止时间。 |
| 输出流 | sock |
字节写到哪里。 |
| 输出偏移 | {} |
没有显式文件位置;socket 自己的位置无关紧要。 |
| 输入流 | sock |
从哪里读。同一个 socket。 |
| 输入偏移 | {} |
没有显式位置。 |
| 上界 | {} |
没有字节上限;读到一轮完成或 EOF。 |
async_transmit_some_bytes 是一个字节泵。它从输入读一块,把这块写到输出,
返回移动的字节数。transmit 引擎拥有自己的临时缓冲区,所以这个协程没有缓冲区要分配,
没有缓冲区大小要定,也没有部分写的循环要写。这正是 echo 服务器需要的。
上游例子每个连接只做一轮。真正的 echo 服务应该一直泵到对端关闭:
static fi::io_async_task<> echo_session(
fi::io_async_observer sched, fi::native_socket_file sock) throws
{
for (;;)
{
std::size_t moved{
co_await fop::async_transmit_some_bytes(
sched, {}, sock, {}, sock, {}, {})};
if (moved == 0)
{
co_return; // EOF: the peer closed its side
}
}
}
循环仍然只有一行 I/O。async_transmit_some_bytes 执行一轮读/写;循环重复到输入返回
零字节。应用代码里没有 pread,也没有 pwrite。高层操作拥有这些机制。
4. Accept 循环:一个协程,很多连接
static fi::io_async_task<> accept_loop(
fi::io_async_observer sched, fi::native_socket_file listener) throws
{
for (;;)
{
echo_session(
sched,
co_await fop::async_accept(
sched, {}, listener, fi::open_mode::no_block))
.detach();
}
}
async_accept 暂停 accept 循环,直到连接到达。恢复时,它产出一个拥有的原生 socket
句柄。句柄被包进 native_socket_file,按值传给 echo_session。
会话协程的帧拥有 socket;会话结束时 socket 关闭。
detach() 是关键调用。它释放任务对象,把帧标记为 detached,并启动它。会话现在独立运行:
accept 循环不等待它,会话在 final_suspend 处销毁自己的帧。这就是一个 accept 协程
能拥有任意多连接、而不必为每个连接保存任务对象的原因。
接受的 socket 用 open_mode::no_block 打开。在 Windows 上,这让 socket 成为
overlapped,从而能用于 IOCP。在 POSIX 上它无害;io_uring 不需要它。只要 socket 会被异步使用,
就传它,这样代码可移植。
5. 监督者:在监听器死亡后存活
main 在协程调用内部构造监听器,所以监听器被移动进 accept_loop 的帧。
帧在 accept 循环的生命周期内拥有它。如果 async_accept 失败,错误逃出协程体,
由 unhandled_herbception 保存,accept 循环变为 done。监督者调用
rethrow_if_error(),捕获错误,记录它,然后用新的监听器启动新的 accept 循环。
for (;;)
try
{
auto acceptor{accept_loop(
scheduler,
fi::native_socket_file{
fi::tcp_listen(2000, fi::open_mode::no_block)})};
acceptor.resume();
while (!acceptor.done())
{
fi::io_async_wait(scheduler);
}
acceptor.rethrow_if_error();
}
catch throws(::std::error e)
{
fi::perrln(e);
}
内层 while 是事件循环。io_async_wait 阻塞到一个完成事件就绪,分发它,
然后返回。循环在每次分发后检查 accept 协程是否结束。监听器死亡时,循环退出,监督者重启。
这刻意不是“任何错误都永远重启”的策略。生产服务器会区分暂时性 accept 错误和永久错误,加延迟, 也许在连续失败后停止。结构才是重点:监听器是协程拥有的资源,监督者决定协程结束时做什么。
6. 构建与运行
这个例子是普通的 fast_io 程序。使用第1章的编译器配置;
throws 标记需要本书的赫布异常工具链。标准编译器可以用普通异常语法代替。
$ g++ -std=c++26 -O2 tcp_echo_server.cc -o tcp_echo_server
$ ./tcp_echo_server
在 Linux 上,内核允许时程序使用 io_uring,否则退回线程池。在 Windows 上,它使用 IOCP。 例子本身没有条件编译;调度器别名选择后端。
从另一个终端测试:
$ nc 127.0.0.1 2000
hello
hello
world
world
上游的一轮版本可能在第一次 echo 后关闭连接。用上面的循环版本,每一行都会回显,直到你关闭连接。 如果你看到第一行回显后连接关闭,那是单轮例子,不是调度器的 bug。
想观察事件循环,就同时运行多个客户端。每个客户端得到自己的 detached 会话。服务器仍然使用一个线程。 当一个客户端空闲时,它的会话暂停,不消耗 CPU;其他客户端继续。
7. 为什么没有缓冲区管理
同步服务器使用 iobuf_socket_file,因为 println 需要格式化输出缓冲区。
异步 echo 服务器使用裸的 native_socket_file,因为它不格式化任何东西;它移动字节。
async_transmit_some_bytes 内部拥有一个 bounce buffer,并在每轮复用。
这是字节泵的正确默认。不要为了 echo 而给每个连接分配一个
std::vector<char>。不要读进栈缓冲区再写出去。transmit 操作已经知道如何暂存部分读、
完成部分写。应用代码停留在“把字节从这里移到那里”的层次。
一旦协议有结构——行、头、整数、帧——就切换到输入用 async_scan、输出用
async_print。那是下一页。边界很简单:字节用 transmit,结构化数据用 scan/print。
要点
- 两个协程:一个 accept 循环,每个连接一个会话。
-
async_accept返回拥有的 socket;按值传递把所有权转移进会话帧。 -
detach()把会话变成自拥有的帧;accept 循环从不等待它。 -
async_transmit_some_bytes是高层字节泵;真正的 echo 要循环到它返回零。 -
open_mode::no_block标记 socket 具备异步能力,尤其是在 Windows 上。 - 监督者拥有监听器的生命周期,并在 accept 循环死亡时重启它。
下一页保持同样的架构,但换协议:HTTP 请求、格式化响应、body 传输,以及 scan/print/transmit 取代裸字节窗口的地方。