Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

网络库常见的糟糕设计有哪些 #312

Open
microcai opened this issue Dec 9, 2024 · 0 comments
Open

网络库常见的糟糕设计有哪些 #312

microcai opened this issue Dec 9, 2024 · 0 comments

Comments

@microcai
Copy link

microcai commented Dec 9, 2024

原文见 https://www.jackarain.org/2024/06/07/network-library-design.html

今天来批判一下 c++ 网络库常见的糟糕设计, 因为很多库作者往往是学而不思, 对于网络库的设计不得要领, 这种现象由其是在国内以 muduo 为代表的糟糕设计的模仿者众多, 废话不多说, 直接进入主题.

网络库不应该 copy 用户要发送的数据.

第一, 复制用户数据点, 它会增加内存消耗和 CPU 负担, 影响性能.

第二, 它会导致网络库的内存使用量控制是个麻烦, 在发送频繁的程序中, 可能导致内存不受控制(或者控制十分麻烦)的增长, 这时用户体验会非常糟糕, copy 用户数据这种设计是将库的使用假定在用户不会频繁发送数据的前提下, 可以无脑调用发送, 这本质上是给于用户错误的网络编程的机会.

第三, 若网络库不 copy 用户数据, 而是让用户直接传递数据内存地址,这使得零拷贝技术成为可能, 这样会极大的提高传输效率.

网络库的 tcp 数据收发应该充分利用它本身的窗口特性.

利用 TCP 的滑动窗口机制, 可以十分优雅的控制数据发送速率与网络速率一至, 也就是说, 当对端接收到了数据时, 它会通知发送端更新发送窗口大小, 此时的应用层程序从而才能可以继续往 TCP 协议栈写入数据.

利用这个机制也可以获得内存使用上的十分自然的控制, 生产者与消费者达到平衡状态.

这一点与上面第1点有十分紧密的关系, 当网络库 copy 用户要发送的数据时, 实际上等于基本放弃了利用 TCP 的滑动窗口机制.

有人告诉我, 很多网络库不太会利用这个状态, 是因为受到来自网络名人陈硕的一个论调(TCP网络编程处理的三个半事件)的影响, 原话是:

"消息发送完毕:这算半个。对于低流量的服务,可不必关心这个事件;另外,这里的“发送完毕”是指数据写入操作系统缓冲区(内核缓冲区),将由TCP协议栈负责数据的发送与重传,不代表对方已经接收到数据。"

这简直是误人子弟, “消息发送完毕” 虽然不代表对方已经接收到数据, 但是忽略 “消息发送完毕” 就等于说放弃了利用 TCP 本身的窗口特性来感知数据发送的状态, 以及放弃利用 TCP 这个状态用于内存等用户相关资源管理, 熟悉 asio 异步编程的用户应该非常了解, 往往一些业务逻辑是需要放在异步发送完成后面去做, 这就是最经典的对发送完成状态的利用, 如:

 while (!abort) {
     [...]

     // 异步发送一个请求
     co_await async_send(socket, request);

     [...]

     // 接收 response 这个逻辑依赖在上面异步发送完成后面
     // 去做,这就是对上面发送完成状态的利用
     auto reponse = co_await async_recv(socket);
 }

再比如发送大文件:

 while (!abort) {
     [...]

     auto file_piece = read_file(file);

     // 异步发送一个文件片段并等待发送完成
     co_await async_send(socket, file_piece);

     // 程序运行到这里,file_piece 虽然并不代表对方已经
     // 接收,但是肯定代表对方的 tcp 协议栈此时是有能力
     // 接收 file_piece 的.

     // 因为实际上就是利用了 tcp 本身的窗口特性,从而达
     // 到了生产者与消费者达到平衡状态,所以这个循环永远
     // 都不会让本端机器内存上涨.

     // 如果没有对 tcp 的发送完毕的状态的利用,那么首先
     // 这种方式的协程就写不出来(协程得通过 i/o 完成状
     // 态来唤醒),那么这个循环就可能会导致内存暴涨或者
     // 导致其它错误.
 }

网络库应该做到内部队列数据结构等等本身的内存分配能由用户指定分配器.

优秀的网络库应该做到内部设计尽可能的减少堆内存的使用, 在不得不使用堆内存的时候, 应该可以由用户指定内存分配器.

有了这个选择的好处是, 可以使的网络库本身对内存的分配只有栈上内存, 而堆上内存则可由用户自己的静态内存或其它更高效的内存池来完成分配.

在 boost.beast 的一个叫作 fast 的示例程序中有具体展示, 它在所有网络连接上的内存分配仅只有一次, 整个过程便再无其它内存分配的动作, 从而获得了极高的性能所以被称之为 fast.

网络库应该支持多种 os 底层接口.

不同操作系统提供的网络接口(如 Windows 的 IOCP、Linux 的 epoll)各有优劣, 由其是 Linux 新出的 io_uring, 对于它的支持可以使得编写的网络服务性能极大的提高, 如果支持多种 os 底层接口, 这将对用户而言是0成本就能获得更高性能网络服务.

另外, 有时在写一些简单的 tcp 程序时, 并不需要使用高效的异步机制, 那么同步阻塞就可以了, 甚至不需要运行事件循环.

总而言之, 一个设计良好的网络库应该具备这种基本的能力.

网络库的最基础的异步接口应该使用 one op/one callback 机制以适应协程扩展.

one op/one callback 机制是异步编程的核心, 但是能自由修改 callback 的网络库将能提供更多的灵活性, 甚至是轻松接入协程接口.

在上古时代, 很多网络库喜欢在启动网络 io 之前设定死一个回调函数用于处理数据收发, 在整个 io 过程中不能修改, 如下模拟代码:

 void start() {
  start_read(socket, bufs, on_read);
 }
 void on_read() {
  // 数据处理...
  if (st == header_status) {
   // 按 header 接收到了处理业务...
   st = body_status;
  } else if (st == body_status) {
   // 按 body 接收到了处理业务...

   // 更新状态, 继续下一次任务.
   st = header_status;
  }
 }

上面 start_read (有些网络库的表现形式并不一定是 start_read 这样, 比如 ACE 里是通过继承一类实现它相应的数据接收的虚函数, 本质上也是设置一次不再能修改) 仅在程序启动时执行一次, 后面不再允许修改, 并认为一次设定后始终回调在一个回调函数会有效率上的优势, 但实际上在网络库中, 重新指定回调函数并没有什么开销(即使有也就相当于一个函数指针赋值的开销).

上面这种上古风格我在之前的文章里有详细论述过愚蠢的 on(‘data’, cb) 接口设计

如果每次能设定回调函数, 那么回调就能形成一个跟实际业务相关的执行流程, 而不需要在 on_read 中写状态判断, 如模拟代码:

 void start() {
  async_read(socket, header_handler);
 }

 void header_handler() {
  // 处理 header 相关的数据业务...

  async_read(socket, body_handler);
 }

 void body_handler() {
  // 处理 body 相关的数据业务...

  // 继续下一次任务
  start();
 }

上面这个模拟代码的业务很简单, 每次读取一个任务, 每个任务需要先读取 header, 再读取 body, 这自然就形成了一个程序流程, 而不需要使用状态机来表示当前是读取状态是 header 还是 body.

因为提供了每次读取 op 可以指定 callback, 才能实现上面这种 callback 程序执行流程, 只有在这个基础, 才能进一步更优雅的使用协程来表达:

 awaitable<void> start() {
  while (!abort) {
   auto header = co_await async_read(socket);
   // 处理 header 部分业务...
   auto body = co_await async_read(socket);
   // 处理 body 部分业务...
  }
 }

协程的使用, 将代码结构变得更清晰, 逻辑更直观.

不跨平台和不提供异步接口的网络库没有意义.

现代软件开发情况十分复杂, 代码总是有可能需要运行在各种各样的设备上, 比如 android, 比如 windows 或 MacOS, 你很难想象如果一个网络库只支持 windows 系统(只支持 linux 亦然) 有什么意义? 如果你千真万确只写跑一个平台的网络服务, 那么直接使用平台相关的 API 接口来编程可能会更具性价比, 因为你不必为此去学习一个不通用半吊子网络库, 学习 OS 的接口肯定会更有价值, 无论你是否认同我说的这一点, 但这一定是个事实.

不支持异步编程, 首先就可以确定为玩具. 在现在异步编程已经是家常便饭的今天, 无法想象一个网络库居然不支持异步编程, 首先不说意味着无法享受异步享受协程带来开发上的便利, 写这种网络库的实现目标定位就很有问题, 只有造玩具才能解释得通.

网络库不应该忽略或隐藏 os 返回的错误代码.

在网络编程中, 错误处理是至关重要的一环, 操作系统返回的错误代码提供了丰富的信息, 有助于用户定位和解决问题. 如果网络库忽略或隐藏这些错误代码,用户将很难诊断和解决问题, 从而影响应用程序的稳定性和可靠性.

还有, 可能需要根据不同的错误代码做不同流程, 这通常用来处理不同的情况, 比如:

tcp 连接失败原因有可能是被拒绝, 有可能是网络不可达, 这就有可能需要区分情况来决定是否尝试再次重连.

再比如:

tcp 连接意外断开是因为 FIN 还是 RST 以此来决定是否让用户检查网络环境, 或者用于判定是否是网络服务程序的 bug, 这些都十分有效且有用.

作为网络库不应该自作聪明的隐瞒这些状态, 应该真实的让用户知道 OS 报告的状态, 如果有必要, 网络库可以将此类错误代码做一定的抽象, 例如可以使用 error_code 来扩展系统的错误代码.

socket 抽象应该使用分层设计(layer)

基于分层设计可以按不同业务来组合不同的传输对象, 比如可以将 tcp socket 作为最底层, 在其上面抽象出 websocket 或 ssl_stream, 甚至可以将 ssl_stream 作为 websocket 的下一层, 通过这样简单的组合就实现支持 wss 了.

这一点在 asio/beast 中有非常良好的实现. 通过这种分层组合, 可以抽象出任何形式的传输类, 比如实现在 socket 上支持传输时压缩/解压, 支持加密/解密, 或其它传输协议的抽象等等…

网络库不应该被设计成应用程序框架

为什么不应该设计为应用程序框架,这是因为网络库必须要考虑在尽可能多的其它程序框架下运行,比如 MFC,Qt,如果网络库本身也是一个框架,这将会导致极大的麻烦,比如像 ACE 那样设计成应用程序框架是非常糟糕的,它甚至是将 main 函数都被侵入了(ACE_Main),这样的话,如果要在 Qt 中使用 ACE 这种网络库,麻烦就可想而知了

c++ 网络库应使用现代 c++ 编程而不是 c with class.

这一点就不展开说了…

关于网络库有哪些糟糕的设计, 暂时就到此, 以后有空再更新吧…

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant