编程 Sender/Receiver 模型的一个硬伤:读到数据的同时也读到了 error

2026-09-23 00:03:29

Sender/Receiver 模型的一个硬伤:读到数据的同时也读到了 error

原文链接:Sender/Receiver 模式的设计缺陷 – Jackarain 的 blog(2025-07-17)

模型由四个概念搭起来

Sender/Receiver 的骨架很小,只有四样东西:Sender、Receiver、operation state、connect。

流程是固定的:connect 把 Sender 和 Receiver 组合起来,返回一个 operation state;operation state 才是真正持有这次异步操作状态的对象,调用它的 start 开始驱动异步流程,结果通过 Receiver 的回调交付。

其中 Receiver 被设计成一个只有三个回调的接口对象:

  • set_value(...):被调用即表示执行成功,实参是结果;
  • set_error(...):被调用即表示发生了错误;
  • set_stopped(...):没有错误也没有成功,主要指工作被取消。

三个回调互斥,一次异步操作结束时只会命中其中一个。这条约定写在 P2300(std::execution)的 completion functions 定义里,也是整个模型的前提:任何一次异步操作的结果,都能归进成功、失败、取消这三个桶之一。

asio 中的条件读:数据和错误可以一起返回

作者在开发自己的 proxy 程序时需要实现数据接收,用到的很多是 async_read_until 这类条件读:

error_code ec;
bytes = co_await net::async_read_until(socket, buf, '\0', net_awaitable[ec]);

这段代码在 asio 网络编程里很常见:以 '\0' 作为条件收数据,一直读到碰上 '\0' 才返回已接收的数据。

问题出在另一端:对方可能在发完一段数据之后关闭 socket,所以并不总能读到 '\0'。此时异步读操作照常返回,ec 里通常是 eofreset 之类的错误码,而 buf 里就是 eof 之前陆续收到的那段数据,一个字节都没少。

也就是说,这次操作既收到了数据,又收到了一个 error 标识。在 asio 的接口形状下这毫无问题——ecbuf 是两个独立的输出,可以同时有效,怎么处理由调用方决定。回调风格的同名操作也是同样的形状:

socket.async_read_until(buf, '\0',
    [](const error_code& ec, std::size_t n) {
        // ec 可能是 eof,buf 里仍有已经读到的内容
    });

handler 一次拿到 ecn,缓冲区内容也还在,信息是完整的。

把同一个场景放进 Receiver

操作结束,只能在三个回调里选一个:

  • set_value:缓冲区的数据交出去了,「对端已关闭」这个事实没有地方放;
  • set_error:错误交出去了,已经收到的数据没有地方放;
  • set_stopped:语义对不上,这既不是取消,也不是正常中止。

所以这个场景用 Receiver 根本无法正常表达。想绕也不是不行:把数据塞进 error 对象里,或者把错误码塞进 value 里——两个回调的签名是各自协商的,类型上完全做得到。但那已经不是模型在表达这个结果,而是调用双方私下达成的约定,模型给出的三个桶本身分不出来。绕过之后,下游任何一段通用代码(组合、重试、超时包装、调度适配)看到的仍然只是一个「失败」或一个「成功」,它无从知道另一半信息也在。

这不是边界情况

EOF 携带部分数据在 TCP 上是常态,任何带分帧的协议都躲不开:

  • 长度前缀:先读 4 字节长度,再读 N 字节 body。对端在 body 中途断开,已经读到的那些字节要不要交出去?
  • 分隔符:就是上面 '\0' 的例子,读到分隔符算完成,读到 EOF 算部分完成。
  • HTTP/1.1 的 chunked、TLS 的 close_notify、SSE、WebSocket 的 close frame——半截数据配上结束原因,都是正常的线上行为,不是异常分支。

异步网络库的基本形状应当能同时带回这两样东西。asio 那套基于 error_code 的接口天然支持,因为它不给结果分类,只把每个输出参数各自填好。Receiver 把结果收敛成三个互斥回调,反而在最需要同时表态的地方少了一条路。

模型的另一条约束也在这里起作用:operation state 一旦 start,就必须活到某个 completion function 被调用为止,而 Receiver 只被调用一次。想在中途先把已经收到的数据吐给上层、再等下一次 completion,在模型里没有直接写法——要么等全部结束一次性交付,要么这次交付就不发生。

结论

Sender/Receiver 并不合适作为异步网络编程的基础模型。它在「数据与错误同时成立」这一点上缺一条路,而这条路径在真实网络里出现得相当频繁。这个模型提出之后被放在很高的位置上,标准层面 P2300 也推进了很久,但至今仍然少见一个基于它实现的、能完整覆盖网络编程需求的完善异步库,表达能力上的这个缺口也许就是原因之一。

参考

推荐文章

程序员茄子在线接单