第23.3章:异步 TCP echo 服务器

English

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。

要点

下一页保持同样的架构,但换协议:HTTP 请求、格式化响应、body 传输,以及 scan/print/transmit 取代裸字节窗口的地方。