« libuv 初窥 | 返回首页 | 开发笔记 (10) :内存数据库 »

Ring Buffer 的应用

这是一篇命题作文,源于今天在微薄上的一系列讨(好吧,也可以说是吵架)。其实方案没有太多好坏,就看你信不信这样做能好一些或坏一些。那么,整理成 blog 写出,也就是供大家开拓思路了。

我理解的需求来源于网络服务提供程序的一个普遍场景:一个服务器程序可能会收到多个客户端的网络数据流,在每个数据流上实际上有多个独立的数据包,只有一个数据包接收完整了才能做进一步的处理。如果在一个网络连接上数据包并不完整,就需要暂时缓存住尚未接收完的数据包。

问题是:如何管理这些缓冲区比较简洁明了,且性能高效。

其实这个有许多解决方案,比如为每个网络连接开一个单独的固定长度的 buffer 。或是用 memory pool 等改善内存使用率以及动态内存分配释放,等等。今天在微薄上吵架也正是在于这些方案细节上,到底好与不好,性能到底如何。既然单开一篇 blog 了,我不像再谈任何有争议的细节,仅仅说说,用 Ring Buffer 如何解决这个问题。


具体点说,我倾向于用 C 语言来做这种偏底层、业务简单的模块。为了减低工作量,可以使用一些成熟的库,比如 libev 等。

类似的库多半提供的是一种回调机制的框架,设置好对应的 IO 异步请求的 callback 函数,然后启动框架的主循环,每个 socket (或别的句柄)可读写时,回调注册好的函数。

把这件事情做的干净漂亮的关键点之一在于数据缓冲区的管理。

拿到需求后,我们应该适当估算我们的程序需要解决多大的数据吞吐量。比如,我们可以假设,一个逻辑完整的数据包在 TCP 连接上,可能最长大约会经过一两秒时间,通过 1 到 10 个包发送过来。整个系统每秒会处理 100M 字节(千兆网)的数据流,那么大约在 10 秒内,处理的数据量大约就在 1G 。

根据对实际业务的估算,这个值可能不到 1G ,128M 就够,也可能多达几个 G 。没关系。我们只是估算出,大致在这个范围内,一个独立的逻辑包一定存在于整个数据流的截段中。我指的数据流是服务程序从网卡上读到的所有数据。

就以 1G 为例,那么这个服务程序只需要开单个这么大的 buffer 就足够了,不必再有任何的动态内存管理。

我们把所有的数据,不论它来至哪个 TCP 连接,都以循环队列的方式,无差别的循序置入这个 buffer。放置的时候,以每次 IO 可读时可以读入的最大字节长度为限。一旦放不下,就折返到 buffer 头部。

buffer 里大概的数据结构是这样的:

[数据长度 连接号 下一块的位置 数据] [数据长度 连接号 下一块的位置 数据] [数据长度 连接号 下一块的位置 数据] ...

另外,内存里开一张 hash 表,记下连接号到数据块的映射关系。如果不想用 hash 表的话,也可以在 buffer 中直接记下连接对象在内存中的地址。

每当一个连接可读的时候,无论读到多少字节,都向这个 buffer 后面追加。并且用链表将其和历史上曾经读过的数据连起来。

同时,可以分析一个逻辑包是否完成。如果没有完成,则继续下面的工作。完成了的话,则利用已有的链表,将分离的数据块拼合在一块连续的内存上。之后,如何处理这个逻辑包,就不在是这个层次上的工作了。

对于处理掉的数据块,可以做一个记号表示废弃即可。我的做法是对数据长度段取反,这样 buffer 在循环使用时,可以判断出下面的内存空间是否可以安全使用。

处理完一个逻辑包后,有可能最后一块数据被切分出去。我的做法时调整这个块,前半块标记为废弃数据块,后半块为待处理数据块。

理论上,如果你的估算没有错且留有余量的话,每次新到来的数据包都能在 buffer 中找到储存它们的空间。因为根据估算,消费速度是要大于生产速度的。不然整个系统都跑不下去。

但如果碰到例外怎么办? 比如有个客户端半个逻辑包发来以后,迟迟不发下半个包。最简单的做法是,碰到 ring buffer 回卷后,碰到那些未废弃的数据块(尚未处理掉),索引到对应的连接,直接 close 掉连接,把没有处理的数据扔掉即可。因为在互联网上,连接本来就是不稳定的。你的协议原本就要处理主动断开连接的情况。无非是根据 ring buffer 的大小和当时的负载情况,设置了一个超时而已。

有兴趣的同学,可以用这个思路实现一下几年前我提到的连接服务器 。代码量应该不大。


另,在更高层的应用上,同样可以使用类似的策略。即循环使用一个 ring buffer 。当 buffer 回转时碰到有对象占用 buffer 拦路时,杀掉对象。对于一些对象比较复杂占用的数据段不固定,对象生命期很短的应用,ring buffer 都有参考价值。例如 3d engine 中的粒子系统。对于要个别需要长期生存的对象,还可以定期复制自己,重新压入 ring buffer 的方式来延长生命期。


使用 ring buffer 的优势是内存使用率很高,不会造成内存碎片,几乎没有浪费(比如传统动态内存分配需要的 cookie)。业务处理的同一时间,访问的内存数据段集中。可以更好的适应不同系统,取得较高的性能。内存的物理布局简单单一,不太容易发生内存越界、悬空指针等 bug ,出了问题也容易在内存级别分析调试。做出来的系统容易保持健壮。


2 月 3 日 补充:

读了几位同学的反馈,发现在几个地方没讲清楚,造成许多疑惑。

一、从 ring buffer 里切分出去的数据块是可以任意合并或再切分的。数据块头上的数据块长度正是为了找到下一块的位置,可以把邻接的两个被标记(废弃)的块合成一个;为了可以任意切分,这里数值上制定的长度和实际占用的长度可能略微不等。实际占用的长度是 4 字节对齐的,而记录的长度则是真实值。比如,记录长度为 6 ,那么在推算下一块位置的时候,需要先圆整到 8 。这样做的话,可以让数据块长度至少为 4 。我们用 4 字节记录长度信息的话,就足够了。更细节的位置还有,我们需要利用连接号域做一个标记,比如空出一个不存在的连接号,表示这个块废弃且无关联连接。实际上,ring buffer 初始化时,就是把整个空间初始化为一个块,并打上可用(废弃)标记。使用的时候就从这个块中一分为二,取其一而用。

经过网友提醒, 这里还需要多设置一个单字节的 offset 域, 记录未处理的数据块头部偏移。

二、ring buffer 中切分出去的块的分配过程是顺序依次进行的,回收的次序则是随机的。初看起来这会导致使用一段时间后,buffer 里有很多空洞。但是, ring buffer 不是一个 memory pool ,并不做复杂的内存块管理工作。如果你想那样做的话,不如直接使用 malloc/free 。也不用为预测的数据量留大一个数量级的空间。ring buffer 在使用时,留足了空间和时间,当 buffer 回转,头指针继续从低地址位移动时,那些先前分配出去的数据块应该都被打上废弃标记了。我们要做的只是根据需要,把这些连续废弃块合成我们需要的程度。万一碰到中间有个别继续被占用的数据块怎么办?那就是上文中提到的,根据连接号强行回收的工作了。

Comments

ring buffer里面可以放packet对象指针,这样就避免了内存拷贝。这个方案有个陷阱就是packet对象复用的时候内部buffer的realloc的问题,处理不好会消耗太多内存。微软的ADO就很痛快的踩到这个陷阱了。

如有的同学说说,服务器这面最大的性能杀手是内存拷贝次数.你这个方案的问题,是没有照顾好网络数据接收层/逻辑处理层是如何利用这些内存的?按照我这面的使用方法:网络接受是为数几个线程管理的,逻辑处理都在主线程,那么,逻辑处理部分得提前把数据从Ring Buffer里拷贝出来,否则,多个线程操作同一个Ring Buffer带来的线程同步问题也很难严重.
经过多年的实践,我比较趋向于采用链表的方案来实现.每次接受的数据都是一块新分配的内存,并且,这块内存的大小是根据系统支持的最大数据接受大小/操作系统分页大小等因素确定的.因为很多网络底层在系统级别锁定内存的时候,需要按照操作系统分页大小获得物品内存地址,并锁定之,如果一个分页跨多个socket,会导致系统底层互锁降低效率.
在我的观察中,一段很小的数据,如20字节以下,几乎很少发生一次接受不全的问题,也就是说,其实很少发生一个数据包接受不全的问题.基本上都是几个完整的数据包累计在一起后,才被网络层接受.网络层接受到数据后,根据包协议分析完整数据包的长度,将完整的包保留,不完整的包拷贝到下一个接受缓冲里(很少发生).完整的包投入到已接受数据链表中,等待逻辑层一次全部处理.
逻辑层从链表里获取数据仅仅是一次LockedExchange操作(32位下利用Exchange64完成,64位下,利用自己写的Exchange128完成) ,将链表头尾给置换出来.无锁链表的节点添加利用CompareExchange完成(分别对应DCAS64,DCAS128).

有个简单的方法,内存池管理器,可以存储不同大小的内存块。。。。。比如一个链都是16K的块,另一个都是64K的块。。。。。大的数据,用大块的链,小的用小块。。。。。。

通常网游业务处理性能的重要衡量指标是响应时间(它直接影响吞吐能力),这其中业务处理耗时占绝大部分比例,尤其是在有数据库访问或其他IO操作的应用中。
网络数据接收缓存机制,应该采用简单模型,并尽可能的减少内存拷贝次数就够了,拷贝数据近似FOR循环操作,多一次拷贝,复杂的优化就白费力气了,另外,更值得考虑的是,怎样让自己的设计随着硬件性能的提升表现出良好的线性关系。

@老突发

如果有的网速快,数据高速发送,消费能力肯定很强能够消化的,越快越好,应该不会影响低速用户。

处理完一个逻辑包后,有可能最后一块数据被切分出去。我的做法时调整这个块,前半块标记为废弃数据块,后半块为待处理数据块。

大哥,你的意思是说, 最后一块包含了该连接上下个逻辑包的一部分,所以要隔开该块吗? 就这个看不明白什么意思。 麻烦云风大哥回答下

有必要使用一大块内存缓冲所有链接的数据吗?一般来说,网游单个连接的下行流量在2KB/s左右,按照16秒的缓冲标准,就是32KB,按照每个服务进程处理4K个连接,大概是32MB。这个数量级上,似乎没必要为了提高内存利用率,而把所有数据放在一个大块里。

将分离的数据块拼合在一块连续的内存上.

抱歉再问个细节。 这个动作是在ring buff 里面做,还是把这些数据copy 到另一块内存里?

私以为会copy 走,因为这个消息应该进逻辑层。如果在ring buff 里面做
就会有移动内存的要求了,而且什么时候释放又是大问题,逻辑层不知道什么时候能用完这块内存。

如果是copy 走又有一个新问题,新开辟的内存的大小应该是和这个消息差不多大。当逻辑层用完再释放。 但是为了提高性能 这块新开内存 应该又要走上开内存池,反复使用的老路了。
不知是否如我所说呢?

还有一个问题, 目前方案buff大小 影响了系统最长的处理时间,比如有一个高速用户,他有高速的数据传输,很可能造成不少普通的低速用户被断开。因为它高速的数据 迅速的将buff 进行了回环。(可以是攻击,也可以是大数据) 只要发生了回环,前面那些还在连接的socket 都会被断掉。

比较偏激,但是 如果是大规模用户可能会有些低速用户的,这个方案 还应该在每个buff 上加一个本消息接收的初始时间比较好,免得错杀。

[数据长度 连接号 下一块的位置 数据]
有个冲突的问题 是怎么解决的
第一个块 读取了100 个字节
连接号 1
下块地址 应该是 112 ( 块本身用了12 个字节 + 100 字节数据)

那么112 这块 在 第一次读的时候 就会写 上么? 还是空
否则 下次读的时候 112 这个块可能已经被另一个连接用掉了
而且 不知道分配多少字节给112 这个块。

所以 下个位置这个字段 在此次读完后 应该是空的,由下次读取的时候 再来找空块 再来填写。


这和我现在写的服务器想法一样,只是ring buffer做成链表,设置检测时间,定期清理死连

还有几个不明白
假如一个逻辑包的后半部分到来以后,怎么样找到上半部分的逻辑包并且把相应的域指到自己,如果是用连接号hash的话那这个key对应的value是指什么呢,是上半个逻辑包的位置吗?但是这样的话变成每次都要都要更新value.假如value是数据包头部的话那么每次插入数据还要线性遍历到尾部?

很简单直白的做法,很容易想明白……

刚开始我还想,云风怎么把东西设计复杂了?看完了才明白:好简洁高效啊!

从整个环境着手得出的特定解决方案,很好参考价值!!

@grep

不连续的问题,看文末的补充。

链接可以实现成双向的,也可以如你所说,在第一块保存头尾块。

建议客户端配合,如果请求被close了,重新请求

两处不是很明白:
1)如何管理废弃的slot?“对于处理掉的数据块,可以做一个记号表示废弃即可。我的做法是对数据长度段取反,这样 buffer 在循环使用时,可以判断出下面的内存空间是否可以安全使用...", 怎么做会在buffer中留下许多不连续的“空闲slot“
2) hash的数据结构, “另外,内存里开一张 hash 表,记下连接号到数据块的映射关系。如果不想用 hash 表的话,也可以在 buffer 中直接记下连接对象在内存中的地址...”, 感觉hash表中一个连接号需要指向一个未完成包的开始和结束段

ps:关于服务器设计的常见问题。大牛在很多业务场景中都应该已经碰到,将你的解决办法总结下来,嘿嘿。造福我们啊

看来这个问题,很多人的解决办法都不相同。能不能多总结些这种常见问题但是没有大牛吐槽的问题啊?也能供我们多学习学习。

memcached 里的evbuffer 就是个ring buffer

开源得好像没有这种实现吧,memcache得内存管理借鉴得是内核得slab内存分配,你这个还是只适用于特定场景; 你这个管理貌似也不简单 还复杂些;

ring buffer在网络编程各种语言很多场景下都有不同的应用和用法,一次性开块大内存是生产环境下的常用做法,还有例如算好数据然后查表等等各种以空间换效率的做法。
这些经验型的做法,几乎成为某些特定场景下的“模式”了,可惜书里很少能见到

Post a comment

非这个主题相关的留言请到:留言本