« 开发笔记(16) : Timer 和异步事件 | 返回首页 | 如何更准确的网络对时 »

Ringbuffer 范例

前段时间谈到了 ringbuffer 在网络通讯中的应用 。有不少朋友写 email 和我探讨其实现细节。

清明节放假,在家闲着无聊,就实现了一个试试。虽然写起来还是挺繁杂的,好在复杂度还在我的可控范围内,基本上也算是完成了。

设想这样一个需求:程序 bind 并listen 一个端口,然后需要处理连接到这个端口上的所有 TCP 连接。当每个连接上要数据过来时,收取这些数据,识别出封包,发送给对应的逻辑层处理。如果数据不完整,则暂时挂起这些数据,直到数据收取完整再行处理。

我写的这个小模块实现了这样一组特性,因为使用了唯一的 ringbuffer 缓存所有的连接,可以保证在程序运行过程中,完全没有额外的内存分配操作。

在编写时,一开始考虑到可能跨平台,想使用 libev, libuv, 或 libevent 来实现。可是仔细思考后,觉得这些库的 callback 模式简直是反人类,完全不符合自然的数据处理流程。使用起来体验非常糟糕。如果考虑到自己做 buffer 管理,想和它们原有的处理框架结合在一起,那实现过程绝对是一个噩梦。

在阅读 redis 的源代码过程中,我发现它并没有实现第三方的连接库,而是自己分别实现了 epoll kqueue select 的处理逻辑。简单清晰。而 epoll 这类 api 原本就相当的简洁,何苦去链接一个近万行的框架库把问题弄复杂呢?

所以,最终我自己定义了一组 API 接口完成需求:

struct mread_pool * mread_create(int port , int max , int buffer);
void mread_close(struct mread_pool *m);

int mread_poll(struct mread_pool *m , int timeout);
void * mread_pull(struct mread_pool *m , int size);
void mread_yield(struct mread_pool *m);
int mread_closed(struct mread_pool *m);
int mread_socket(struct mread_pool *m , int index);

暂时我不处理数据发送,让用户自己用系统的 send api 来完成,只关注 recv 的处理。这组 api 中,使用 create 来监听一个端口,设置最大同时连接处理数,以及希望分配的 buffer 大小就可以了。

库会为这个端口上接入的连接分配一个子连接号,而没有直接用系统的 socket 句柄。这可以方便应用层的处理。如果你设置了最大连接数为 1024, 那么这个库给你的编号就一定是 [0,1023] 。你可以直接开一个数组来做分发。

poll 函数可以返回当前可以接收的连接编号。可以设置 timeout ,所以有可能返回 -1 表示没有连接可读。

在 poll 之后,pull 函数可以用来收取当前激活的连接上的数据。你可以指定收多少字节。这个函数是原子的,要么返回你要的所有字节,要么一个字节都不给你。

由于库内部管理了接收 buffer ,所以不需要外部分配 buffer 传入。库的内部会识别,如果内部数据是连续的,那么直接返回内部指针;如果不连续,会在 ringbuffer 上重新开一个足够大的空间,拼接好数据返回。

buffer 的有效期一直会到下一次 poll 调用或是 yield 调用。

yield 函数可以帮助你正确的处理逻辑包。这是因为库还帮你做了一件事,如果你不调用 yield ,那么如果你 pull 了多少数据,再下一次 poll 调用后,同一个连接上,会重新 pull 到相同的数据。

举例说,如果你的逻辑包有两个字节的包头表示逻辑长度。你完全可以先 pull 两个字节,根据两个字节的内容做下一次 pull 调用。如果实际数据没有全部收到,你不必理会即可。如果手续收齐,那么调用一次 yield 通知库抛弃之前收取的数据即可。

当 pull 返回空指针,有可能是数据还没有收全,也有可能是连接断开。这时用 closed 这个 api 检测一下即可。


写了这么多,一定有同学想找我要源代码了。懒虫们,没问题。我已经提交到 github 上了。你可以从这里拿到 https://github.com/cloudwu/mread

不过,这个只是我的节日娱乐之作。没有经过仔细测试,用在生产环境请务必小心,它几乎是肯定有 bug 的。另外,我只实现了 epoll 的部分,虽然扩展到 kqueue 或是 select / iocp 并不会太困难,不过假日过完了,也没空完善它了。

我由衷的希望有同学有兴趣有能力可以帮我完善这个库,让它更具有实用价值。写 email 给我,我会尽量配合。


这个东西的特点是什么呢?

我相信它足够高效,至少从 api 设计上说,可以实现的很高效。

因为它几乎就是直接调用 epoll 这些系统 api 了。而且尽量少调用了系统 api 。从实现上,每次 pull 调用,库都尽量多的读取数据并缓存下来,而不是按用户需求去收数据。

空间占用上,它也一点都不浪费,不会为每个独立的连接单独分配缓存。你大概可以根据你的网卡吞吐量和应用程序能处理的带宽,估算出一个合理的 ringbuffer 总量,那么这个库就应该可以正常工作。

就上一篇谈 ringbuffer 的 blog 我说过,当 ringbuffer 用满,它仅需要踢掉最早残留在系统里的挂起的连接。如果 client 是友好的(不发半个逻辑包),它几乎不会被踢掉。

libevent 这类库设计了一个 callback 框架,让你在每个连接可读时,采用 callback 函数来处理即将收到的数据。和这种用法相比,这个库的处理逻辑更加自然,也不需要你定制这定制那。复杂度被藏在里模块内部。外部接口和 socket api 一样简单易用,甚至更易用。因为它可以帮你保证逻辑包的原子性。

Comments

请亮出你们的性能数据好吗。我12年写的网络并发多写单读缓冲区,到应用程序的流量大概在80M字节每秒(用了加密和压缩),解压后成40万个业务包每秒。这还是业务部分cpu满负荷影响了业务处理速度的情况。
我倒要看看这个ringbuf到底有什么厉害的

@Cloud 多谢

@yu

没有问题, 因为这个是个随手写的,所以没有列 license.

如果你要使用修改,就按 MIT license 来.

另外,MSG_DONTWAIT在_POSIX_SOURCE模式下是undefined的,不知是否有比较优雅的解决方法.

代码非常有趣,最近看完了然后自己写了个简易版的,代码只有一个头文件,丢到了 https://github.com/argcv/argcv/blob/master/include/argcv/net/cl/colacus.h

主要是这个api的想法很有意思,两个fd的系统也超级棒,但是ringbuffer什么的还没有实现.

留下这段话是因为
1. 您没有给License,我不知道怎么用它比较好.
2. 虽然我代码很屎,但是您这代码的衍生物,而且因为想要立刻拿到个原型,所以有点违背您的本意,所以觉得告知下比较合理

这个只是一个范例。
如果你有读分割符的需求,应增加一个 API 去读 ringbuffer

“举例说,如果你的逻辑包有两个字节的包头表示逻辑长度。你完全可以先 pull 两个字节,根据两个字节的内容做下一次 pull 调用。如果实际数据没有全部收到,你不必理会即可。如果手续收齐,那么调用一次 yield 通知库抛弃之前收取的数据即可。"

请问如何处理基于分隔符分割的数据包呢

ALIGNMENT 为什么是 4 不是 8 ?

libevent主要是为了多平台移植性。自己写一个多平台的不见得比libevent好,而且还要花很多人力物力维护。如果确定系统只用一个平台的话,直接最原始的调用完全可以。
callback确实是个垃圾玩意儿。在callback上再封一个协程的实现吧。这样就不用面对无穷无尽的callbackb了。

云风提倡用epoll,但是epoll只有Linux有啊。专用软件的开发设计不一定适合带有普边性的软件。假如一个软件公司的老板希望扩大市场,程序需要编译到另外的平台怎么办?用第三方库的价值有些方面就在于此。如果不使用第三方库,实际上间接否认别人存在的价值。这也是程序员喜欢自我欣赏的毛病。就算最牛逼的人都不能避免,见的多了。

”epoll 这类 api 原本就相当的简洁,何苦去链接一个近万行的框架库把问题弄复杂呢“,说得非常好,我不喜欢动不动就用第三方库来开发,最近一个项目,上面硬要用libevent,郁闷!

如果移植到iocp,那么每个连接都要投递一个buf , 这样ringbuffer就失去了优势。每个连接都要一个buf暂用内存多了。

开源的大多数服务器一般都是每个连接开一个固定的buffer,和云风大侠的这个ringbuffer比起来,能够处理的连接数量受内存限制肯定要少很多,不过我有两点疑问想请教下:
1.有些后端服务器没有处理大量请求的需求,是不是这么做就没什么优势了呢。
2.如果把这个ringbuffer放到多线程模型的服务器上,同步的开销肯定会降低性能,那么是不是就限制了服务器模型基本只能是单线程处理所有网络IO请求,逻辑包收全后再交给后面的异步线程池之类的东西呢

这个comments从新到旧的顺序看着很是不爽,不能倒过来么?

我怎么才能达到您的高度啊?

今天仔细研究了一下, 发现 hash map 实现的有 bug , 干脆重新写了.

@技术爱好者

我不喜欢 libevent 是因为我不喜欢 callback 机制的 framework .

另外, 这个东西并没有用在我的项目中. 清明节休息写着玩.

libevent最初的版本,buffer管理是个软肋,效率低下.Google接手后的libevent2这方面有了很大的改观,而且,直接支持openssl,何不直接使用呢?我觉得这些事情都是不需要花时间去做了,有人已经做好了;做为曾经的同事,我相信云风你的实力,但是,这些事情真的没有必要花精力去做,有人已经做了,而且做得还不错,直接用就好了,不是么?

@haxixi_keli

你得进一步说明, 你构造的序列在实际情况下, 何种先决条件中会成立. socket fd 在大多数系统上都是顺序分配的.关闭的 socket 的 fd 会被再利用. 如果 slot 数量大于需要的数量, 几乎不可能碰撞.

你不能独立的看待 hash map 的问题, 这并没有作为一个通用库来实现. 而仅给这一个环境使用, 保持代码简单好维护才是最重要的.

调整 mainposition 的工作我顺手改一下好了.

恩,其实一个主要的心病是:不同hash值的元素,链在了一起,感觉有些浪费……

我之前说的map.c效率问题也不是大问题,主要是一个锱铢必较的c程序员心病哈:
0、先假设我新建了一个大小为8的map(map = map_new(8),实际会分配16个空位);
1、插入fd = 2的元素A,这时候,A的mainposition = 2 % (16-1) = 2;
2、插入fd = 17的元素B,B的mainposition = 17 % 15 = 2,已知位置2上有一个元素A了,而且A的mainposition也是2,说明2这个位置的碰撞是无法避免的,于是只好将B插入在空闲位置4(2 * 2 = 4);
3、插入fd = 4的元素C,这时候位置4上已经有元素B了,但是B的mainposition不是4,也就是说*位置4上的碰撞是不必须的,是可以避免的*,此时的C插入在了空闲位置8(4 * 2 = 8);
4、其实可以在插入C的时候,发现位置4上的元素B,mainposition不是4,可以将B移到一个空闲位置,将C放入位置4(lua的实现);
-------------------------
另外:
1、hash值不同的元素A、B和C,链在了一起,一个极端的情况(可以构造出来),所有的元素都通过一个单链表链在了一起;
2、当所有的元素都通过一个单链表链在一起,并且所有元素都不在它的mainposition上(构造如下:通过insert,用元素占用其他元素的mainposition,然后再将在mainposition上的元素erase掉,再insert其他元素在其位置上);
3、在上述情况下,效率就是不可预知的了,不论search/insert/erase。

太伟大了 , 看代码去

这个方案适合在多核上跑吗,线程安全的ring buffer不知道overhead怎么样。

@loki

node 存放了一个链表,是不同时期收到的数据流.

如果外部请求的数据块不连续, 就重新申请一块连续空间 temp ,复制过去返回.

节日娱乐之作

云风大哥真的是中国程序员的最佳榜样!

http://blog.csdn.net/archimedes_zht/article/details/6909074
Redis为什么不使用Libevent或者Libev

Redis作者和爱好者们的讨论。:D

问问云大侠,struct ringbuffer_block * node;
struct ringbuffer_block * temp;分别是用来干嘛的啊?

@Cloud
我是直接做的大量随机测试,当表中元素数大于表容量的一半时就比较慢了(估计是连锁碰撞导致后面的插入都要查找空位)。又测试了现在的版本,不存在这个问题了

现在默认分配 1.5 倍于最大连接数的 hash map , 减少碰撞.

@WXY

什么时候碰撞比较严重? 我改一下吧.

测试了下那个map,大量insert erase后性能不会降低,但是大量insert时很慢……因为不停的查找空位……不知道云风大神有没有改进的打算……

ringbuffer有点类似于libevent里的evbuffer。
最近一个项目正好吧redis里的ae.c和evbuffer结合在一块。fd结构体包含一个read,一个write的evbuffer。

楼下的说经过多次 insert erase 性能降低有证据证明吗?

看了一下,
1、map.c的实现很随意哎,经过多次insert/erase后,效率会变很低哎,至少要保证每一个元素都能在它的mainposition上吧(和lua的table实现相似),这样多次insert/erase操作不会降低效率

这个 bug 已经改掉了, 谢谢.

这一句有点疑问:“如果你不调用 yield ,那么如果你 pull 了多少数据,再下一次 poll 调用后,同一个连接上,会重新 pull 到相同的数据”。根据代码实现,如果没有调用 yield 则全局数据 skip 不会被初始化为 0, 再次 poll 应该得不到相同的数据。应该在 poll 返回前将 skip 初始化为 0.

在mint下面编译运行正常

Post a comment

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