网络缓冲区随笔

在服务器处理网络连接(本文中仅讨论TCP连接的情况)时,常常会构建一块 buffer,来存储收到的网络数据,直到确认收到的数据已经处理完,才会从 buffer 中清除。

从功能来看,就是开一块内存,满足不断的读写数据需求。这里不考虑恶意连接或者恶意数据包.

为了提高 buffer 效率,需要考虑的几点:

  • 多连接的情况,例如网关,会有频繁的建立、断开 TCP 连接,要考虑 buffer 内存碎片的问题.
  • 减少内存拷贝的开销.

之前常见的做法(简称实现一):为每一个 TCP 连接分配一块 buffer 大小=size,读网络数据时加到 buffer 尾部,上层取数据时从 buffer 头上取,当 buffer 之前的留白超过一定阈值(例如 size/2)时,整体做一个 memory move 的排干处理。

实现一的优劣:

  • 多连接的情况,可以通过内存池来构建 buffer,每一个 buffer 都是固定大小的,也很方便;
  • 在从 TCP 缓冲区中读取消息时,不可避免的会做一次 memcpy,不过后续处理可以只传递 buffer 的指针,不必再做 memcpy.
  • buffer 需要做排干处理,这里会增加 memcpy 的消耗。但是实际上这中情况发生的概率并不高(阈值决定了),而且 memcpy 的量也不会很大。

这两天看云风关于 ringbuffer 的一篇 blog,文中提出了另一个解决问题的方法(简称实现二):

为所有的连接分配一块 ringbuffer,将所有读取到的数据循环插入到 buffer,并以链表的方式串联起来自同一连接的数据片。 当上层处理应用时,如果数据在 buffer 中是独立的一块,直接返回内部指针. 如果需要拼接,则需要单独开一块内存拼接之后返回给上层。 代码在这里

实现二确实有它的独到之处,在我看来:

  • 内存管理方便,很明显,综合效率比每一个连接开一块 buffer 要来的高;
  • 实际情况中,收到需要重新拼接的包的情况会比较少,也就是说实际上需要多做 memcpy 的情况也很少;
  • 内部实现相对复杂,因为要维护部分连接数据,相当于上层连接对象的数据,与缓冲区的数据有一定程度的耦合;

对比上述两种 buffer 的实现,我自己的感觉,这两种实现方式可以市场经济和计划经济来类比:

  • 实现一是计划经济,固定分配,每个连接只要管好自己的一某三分地就好了;
  • 实现二是市场经济,大家分享市场,各自竞争,对于恶意的捣蛋分子,直接干掉(原文:“碰到 ring buffer 回卷后,碰到那些未废弃的数据块(尚未处理掉),索引到对应的连接,直接 close 掉连接,把没有处理的数据扔掉即可”)。需要宏观政策的调控,也意味着策略会更复杂一些。

今天突然想到针对实现一,可以做一点优化,改进成一个 circle-buffer,而不需要做排干处理。

常见的 circle-buffer 是在 buffer 到尾部时,做回绕。当碰到不连续的数据(分布在 circle-buffer 前后各一块),最大的问题就是无法保证给上层提供连续的内部指针,不可避免的需要做内存拷贝。所以实现一中采用了简单的排干策略,放弃一部分性能,为上层接口调用提供方便。

  • 假设原有的缓冲区=size,最大的协议包长度为 n,这里将缓冲区调整为 size+n.
  • 缓冲区内有读写指针,分别代表上层读取数据的下标,和读取网络数据的缓冲区下标。
  • 如果当前缓冲区的写指针在[0, size)区间,收到了可读通知,读取数据并且写指针移动到了[size, size+n)区间,此时将写指针回绕到 buffer 头部n的位置。
  • 同时触发回调,回调如果处理掉部分数据,读指针也到了[size, size+m)的位置(m <= n),则可以将[m, n)区间内的数据拷贝到buffer头部[m, n)处,并将读指针跳转到m处。

具体的步骤应该是如下图所示:

这样可以减少了 memcpy 的使用场景,但是与实现一相比,能不能带来性能提升,还有待实验考证。