Muduo 设计与实现之一:Buffer 类的设计

【开源访谈】Muduo 作者陈硕访谈实录

http://www.oschina.net/question/28_61182

开源访谈是开源中国推出的一系列针对国内优秀开源软件作者的访谈,以文字的方式记录并传播。我们希望开源访谈能全面的展现国内开源软件、开源软件作者的现状,着实推动国内开源软件的应用与发展。

【嘉宾简介】

陈硕 北京师范大学硕士,擅长
C++ 多线程网络编程和实时分布式系统架构。现任职于香港某跨国金融公司 IT 部门,从事实时外汇交易系统开发。编写了开源 C++ 网络库 muduo; 参与翻译了《代码大全(第二版)》和《C++ 编程规范(繁体版)》,整理了《C++ Primer 第4版评注版》;曾多次在各地技术大会演讲。

【软件简介】

muduo 是一个基于
Reactor 模式的现代 C++ 网络库,它采用非阻塞 IO 模型,基于事件驱动和回调,原生支持多核多线程,适合编写 Linux 服务端多线程网络应用程序。视频连接:http://v.youku.com/v_show/id_XNDIyNDc5MDMy.html

【访谈实录】

1.    你先介绍一下你自己吧,包括学习经历和工作经历

我2000年上大学,本科和硕士都是在北京师范大学,学的是电子信息专业(原来的无线电系)。2007年硕士毕业到上海工作,在摩根士坦利信息技术有限公司。到了2010年6月份,公司调我到香港工作,摩根士丹利亚洲有限公司。我第一份工作到现在已经做了五年,没有换过。我工作的内容一直是用C++开发实时外汇交易系统。

2.    你学的是无线电,跟编程还是有点差异

我们学院的课程设置中,计算机系和电子系重合的部分很大。电子系也讲授C语言、数据结构、汇编语言、计算机组成原理、计算机网络、计算机图形学等等偏CS的课。另外我自学了操作系统课,编译原理也学了一点皮毛。因此我算是半个科班出身。

3.    那你们电子方面是不是用 C 用的比较多?

如果从电子系传统来讲的话,是硬件描述语言(Verilog)和 C 用的比较多。但现在国外嵌入式中 C++ 应用也很多,从厂家提供的工具链和各大嵌入式会议的议题就能看出来。国内的话守旧观念势力比较大,大家觉得其他语言不可靠,摸不透,不愿意换其他生产力更高的工具。单片机上用 C 比较多,我写过8-bit单片机上的C程序。但是稍微大一点的32-bit单板机上就可以用 C++,只要能跑操作系统的就能跑 C++,有C++编译器就能跑。

4.    是什么促使你开发Muduo这个网络库的呢?

我的兴趣是分布式系统,但是搞分布式系统的前提是要有一个足够好的网络库。我有很多想法要写,比如说实现consensus 的Paxos算法,还有其他一些分布式的协议的实现。在写这个网络库之前,我看了一些别的C/C++网络库,觉得还是自己写一个比较靠谱。另外一个原因是2010年3月份,我写了一篇博客,关于 ACE 的,它是一个古老的 C++ 网络库,博客文章叫《学之者生,用之者死——ACE历史与简评》。我在文中描述了我对网络库功能需求的理解。因为2010年我已经工作三年,写了很多网络相关的程序。所以我决定按照我对网络库功能需求的认识,实现一个符合我价值观的网络库。另外,muduo不只是一个网络库,它还包括基本的线程库、日志库、日期时间库等部分,可以算作一个基础库。总之我认为编写C++多线程服务端网络应用程序所需要的功能在muduo里都有,muduo体现了我对这个领域的理解。

5.    为什么要叫Muduo这个名字,怎么念呢?

一般可以念成拼音,木铎(念:夺)。“木铎”是木舌金铃的意思,引申义是教育传播,摇铃铛以吸引行人注意。我选这个名字还有其他几点考虑。首先这个名字要用作 namespace(命名空间),就跟 boost 一样,所以不能太长,敲键盘要比较方便。Boost 是五个字母,C++ 标准库std是三个字母,我觉得还是不要超过五个字母为好。然后名字不能是英语单词,因为五六个字母的英语单词一定已经被用掉了,你搜这个单词就搜不到我。也不能都用首字母缩写,这样就只能按字母来念(就像HTTPS)。库的名字要能顺口念出来,一两个音节最好。中国人能读,老外也能读得八九不离十,所以不能用xue之类的拼音。综合一下这些条件,选择也就不多了。选这个名字我还是花了一点心思,最后想到了北师大的校徽,干脆就用muduo了。

6.    Muduo这个域名拿下了吗?

我拿了一个 muduo.info 的域名。muduo.net 是北师大的校园网,muduo.com 也是别人在用,muduo.org 是一个人博客。我2010年的时候没有想着去拿域名,当时这三个主流的域名就已经被注册了,最近想拿就只能拿一个 muduo.info 的。

7.    Muduo相对于别的网络库来讲,它的优势和特点是什么呢?

先说特点。Muduo有一个很明显的特点是不可移植的。一般的网络库会把跨平台可移植当做一个卖点。而我特意选择只在 Linux 上实现并优化,这个算是特点。因为大规模分布式系统通常都会在Linux上开发部署,支持其他平台没有多大意义,我个人精力与知识面也不够。还有一个特点是只支持 TCP。有的网络库会以支持TCP、UDP、ICMP、串口等各种协议为卖点,muduo则不然。Muduo的特点是只支持 TCP ,而且只支持 IPv4。因为我不认为开发公司内部使用的分布式系统会用到其他传输协议,更不会在内网用IPv6。muduo只支持one
event loop per thread这一种并发模型,只使用非阻塞IO,因为这是Linux下使用native语言编写高性能网络程序最成熟的模式,Muduo适合编写有较多并发TCP长连接的网络服务。甚至连 DNS 解析都只支持异步的解析,没有直接的一个函数调用就能从域名拿到 IP,因为这样会阻塞。总之,我认为在开发公司内部系统中用不到的东西我都没有支持,muduo是有明确的适用范围的,它不是那种大而全的网络库。减少选择,让你节省时间,少走弯路。

再说优势。优势之一是API设计。Muduo是一个现代的 C++ 网络库。现代和古代的API区别在于两方面。一个是事件回调,另外一个是资源管理。一般的网络库设计API的方式是定义一个接口(抽象基类),包含几种网络事件对应的处理函数。你的代码去继承这个接口,这个接口会定义收到消息是回调哪个虚函数,然后你覆盖一下这个虚函数。然后把你的对象注册到网络库中,发生事件的时候就回调你的虚函数。一般的 Framework 都这么搞,这就是传统的或者说古代的 C++ 网络库的做法,也是Java网络库的做法。这种做法在C++中面临的一个直接问题是对象的生命期管理,因为C++的动态绑定只能通过指针和引用来实现,你必须把基类指针传给framework,才能获得事件回调。那么这个派生类对象何时销毁就成了难点,它的所有权到底归谁?有的网络库甚至在事件处理函数中出现了delete
this;这种代码,让人捏一把汗。

我现在的回调方式是用boost::function,它在TR1时已经进入 C++ 标准库。Boost::function不对类型和函数名做限制,只对参数和返回类型做部分限制。如果你通过传统的继承来回调的话,你这个类型必须是framework里某个基类的派生类,函数的名字必须一样,参数列表必须一样,返回类型也基本肯定是一样。但是boost::function没有这些限制。Muduo网络库不是一个面向对象(object-oriented)的库,它是一个基于对象(object-based)的库。它在接口上没有表现出继承的特性,它用的是boost
function的注册/回调机制,网络事件的表示就用 boost function。所以对Muduo来讲,它不需要知道你写什么类,也不强迫继承,更不需要知道你的函数叫什么名字,你给它的就是一个 boost function对象,限制就很少。而且你没有把对象指针传给网络库,那么就可以按原有的方式管理对象的生命期。

还有一个优势就是资源管理,Muduo在一处最关键的地方用了引用计数(Reference Counting)型智能指针,当然我没有自己写,用的是标准库的shared_ptr。我只在表示 TCP 连接的class上使用了引用计数,是因为TCP连接是短命对象(short-lived)。但是当连接被动断开的时候,网络库不能立刻销毁对象,因为用户可能还持有它的引用,准备用来发消息。如果直接delete,有可能造成空悬指针。因此既然TCP对象是网络库和用户代码共同拥有,那就用引用计数好了。Muduo用引用计数是经过仔细考虑的,也没有用在其他长命的对象上,这些长命对象的生命期可以由用户代码直接管理。用Muduo你就不用担心指针失效的问题,可以避免一些古老的
C++ 程序中的一些内存错误。

这种用对象来封装文件描述符等系统资源的做法是C++独有的资源管理方式,称为RAII。通过把文件描述符的生命期与对象等同起来,我们还能有效地避免串话(cross talk)。比如说,操作系统给你一个新的TCP连接,文件描述符就是一个小整数,这个整数可能等于刚刚关闭的某个TCP连接的文件描述符。比如你现在有一个连接号是3,你把连接关了再打开有可能还是3,所以就带来连接管理方面的一些麻烦。如果你是用 C 写,不小心的话就会造成你这里关了3这个连接,但是程序其他地方还在往3这个连接发消息(考虑多线程的话更头疼),但其实3这个连接已经指向其他地方了,就跟使用野指针一样。用RAII就没有这个困扰,因为3这个连接的生命期和对象绑定,对象活着,连接就不会关闭,也就不会有其他对象同时使用了3这个文件描述符。

最后,Muduo的性能也是让人满意的。我在编写Muduo的时候没有以“高性能”为首要目标。在完成并开源之后,受网友启发,拿它和其他一些网络库做了性能对比,发现相比通用的跨平台网络库(libevent2、Boost.Asio),muduo有明显的性能优势。相比专用的网络程序(Nginx,ZeroMQ),muduo的性能也不落下风。

8.    也就是说在资源管理方面是比较可靠的,不会混淆和不会泄露

是的,首先是资源在不用的时候一定会释放,其次是通过对象来管理文件描述符可以有效地防止串话。

9.    Muduo目前的推广是怎么进行的?

我主要在博客上写一些文章,没有特别的推广。有些线下活动会参加,讲讲这个项目。

10.   那这个项目目前在实际产品中的应用如何?

我们公司不用muduo,公司有自己的网络库,在我加入之前就成熟了。从个人邮件来往看的话,有人在学,有人编译了试用。具体有没有哪个公司在用我也不清楚。Muduo的特点之一是它代码只有5000行,如果你认真读一遍,花一个星期理解透,然后自己写一个更好的也可以。不一定非要用我这个原装版的。关于 C++ 网络编程的技巧啊,陷阱啊都在代码里,而且写的很清楚。Muduo的代码是写出来给人看的。Muduo的目的之一也是放在那里让人学的,所以为什么叫木铎,这个名字也有相应的含义,刚才也说过了。

11.    根据你刚才的描述,那开发和维护Muduo的人员应该也只有你一个吧

是的,只有我一个,在工作之外的业余时间开发。

12.    有没有人提交过 bug fix 或者 pull request 之类的?

这个项目在 Google Code,源代码是在 Github 上管理的。有一个 Pull Request,但不是针对代码的,而是针对编译选项的。是关于 boost 库的一个警告,但是我查了一下,应该是 boost 的问题,我向 boost 提交了这个 bug,看他们修不修吧。

13.    那平均一周你花费多少精力在开发和维护Muduo这个项目上面呢?

要看我有没有新的想法。比如上周我就花了很多时间把多线程非阻塞日志库写好了,花了一整个周末。平时的话可能一周看都不看。去年花了挺多时间是要写博客,写各种例子。Muduo这个库的例子很丰富,编译出来大概有近百个可执行文件,各种网络编程常用的功能都有,例如聊天,文件下载,广播等等。而不像有些网络库只有一个 echo 的例子。

14.    你是2010年开始做的吗?

我是2010年三月份开始做,到八月份的时候就开源了。实际上我写了一两个月写完了但是没有立刻开源,因为那会儿正好从上海搬家到香港,杂事很多。在开源之后主要工作就放在写例子上。写到11年底,我那个博客系列写完了,没有更多简短的例子可以写了。然后我就开始做另外一个项目,是用Muduo和 Google Protocol Buffer RPC 做一个分布式系统中的多机服务管理软件,还没做完。

15.    我们一天按8小时算,你觉得到目前为止花费在Muduo上的时间有没有一个月?

一个月以上

16.    那差不多两个月的时间?

算两个月吧。其实学习思考的时间很多,我有时候看到一篇博客,影响了我的想法,就会把这个思路实现一下。

17.    目前看来你应该是没有从Muduo获得任何收入?

没有收入

18.    那你觉得你做这个事情和你的全职工作有冲突吗?

我觉得没有冲突,实际上能帮我更好地理解 TCP 网络编程。我们在公司用肯定是用写的很好的(现成的)网络库。正常情况下,网络库就是收发数据而已。那如果网络出问题,应用程序有哪些异常的表现的话要写过网络库才清楚,不然的话就只能看别的网络库的文档,查不出来问题的根在哪儿。对公司来讲,这是有正面意义的,如果公司的服务发现网络方面有问题,特别是在跨洲的网络环境里面,例如伦敦发到纽约这种,你就会有一些思路,可能是某某问题,应该如何确认,确认之后可以怎么调一下。

19.    你的老板知道你做了Muduo这样的一件事情,就是你业余也会做些开发,写写博客之类的?

我老板估计不知道,其他组有几个同事知道。我最近一年来没有写长篇博客,因为CSDN博客不再支持Live Writer发布,而我一般喜欢同时发到cnblogs和cppblog这几个地方。

20.    呵呵,那应该他知道了也不会在乎这个事情吧

应该是吧,我们公司对开源有比较明确的政策,不能涉及公司的信息。Muduo的编写也没有使用任何公司资源,我甚至从来没有在公司的机器上下载编译过源代码。

21.    上次我跟你聊的时候,我记得你对于通过Muduo获得收入是很谨慎的,因为你是全职在摩根士丹利工作,是吗?这是在你的劳动合同上注明的吗?

我们员工行为准则有一个叫做利益冲突条款,其中一条是不能通过摩根士丹利雇员的名头来牟利。因此我在私人活动中不能宣称自己是摩根的雇员,以免引起联想。敝公司对员工在工作之外的行为要求比一般的公司要严格。

22.    Muduo将来的发展方向你有过考虑吗?

我是2010年8月份推出0.1.0版,到2012年5月份是0.3.5,它是每0.0.1增加所以一共有25个版本。0.3.5及其以前的版本都是 alpha 版,我上个星期(2012年6月初)推出的0.5 beta 版。这个 beta 版的区别在于它有一个实际可用的日志库。以前的 alpha 版日志只能写到屏幕,现在加了文件日志那它就可以实际拿来用了,所以是 beta 版。

23.    那你对 1.0 有没有想法呢?

有,1.0的时候应该把网络库的单元测试做好。因为muduo网络库涉及到 IO和多线程,IO 的单元测试会比较麻烦。特别是各种出错的情况,你怎么让操作系统返回你想要的错误。这个我有一些想法,也已经写了博客,但还没有时间去真正的把它做出来。现在的测试是手工完成的,不是很好。到2.0的时候会用 C++ 11,或许会利用右值引用和移动语义提高一些内存复制方面的性能。但是目前主流的Linux发行版自带的GCC编译器版本都还没有支持 C++ 11,要等 GCC 4.6普及了以后才行。

24.    你觉得网络游戏服务器是不是你这个库比较好的应用场景?

如果你从处理并发长连接这个方面来讲是没有问题的。但是Muduo不支持 UDP,如果你的游戏需要用 UDP 通信的话,那你需要做一些改动。另外就是网络游戏可能会在安全性方面有所考虑,比如说抵御一些网络攻击,但是Muduo并没有在安全方面做特别的支持,因为它考虑的是公司内网的网络环境。

25.    也就是说Muduo是为公司内网的分布式系统设计的?

可以是公司内网的全球规模的分布式系统,但并不是为公网使用而设计的。但是你可以用一个比较抗暴的连接服务器放到公网上,然后用Muduo来完成连接服务器之后的那些业务处理工作。我个人在安全性方面并没有很多的研究,我只知道网络攻击的方式五花八门防不胜防,所以如果我说可以用(在公网用)的话那是在坑你。

26.    国外有很多成功的开源项目,但是国内似乎没有,你怎么看这个现象?

很多公司用了开源项目,并且修改了,多半是不愿意把修改回馈给上游的。比如说优化了一下性能,或者增加了一些功能我公司内部用就很好了。我觉得公司尚且如此的话,那么个人就只能凭借兴趣爱好参与了。

27.    也就是你认为国内开源做的不好是因为没有公司的推动?

也不能这么说。如果公司要用外面贡献的代码是要签协议的。比如我上次给 Google glog提一个 bug,我给他一个 diff 文件,他想用这个 diff 文件,他要我跟他签授权协议,要明确我愿意贡献出这段代码的知识产权。因为如果没有这个协议的话,我以后可以告 Google 说你用了我一行代码。所以如果开源项目的维护是公司的话,是会有这些法律方面的问题。所以法律方面也要跟上,让公司觉得做这个事情是安全的,否则他宁愿不接受外界的贡献。

28.    所以你觉得是因为国内法律方面的缺失,导致了公司感觉没有保障性?

应该说是没有这个先例吧,我不晓得有哪些律师在这方面很在行。我不知道是不是可以由知识产权方面的律师来拟定一个关于代码贡献的合同。只有这些都有了才能做双向的开源。否则就是单向的,我(公司)把代码放出来你们可以看,但是改只能我改。你可以提bug,但是只能我来改,我不会把你的 patch 拿进来,估计也没人提交 patch。而且在公司具有话语权的人,他不一定是搞技术的,他一定会考虑这个事情会不会影响公司形象,或者惹上官司等等,往往这种项目都不会通过。

29.    你有没有一些建议给刚入门的程序员,帮助他们成长?

程序员成长该看什么书之类的应该有很多人讲过,我就不多费口舌了。我认为在国内这个环境下新手不要上国内论坛问问题或参与讨论,你可以去看国外论坛别人问的问题与回复。但是国内论坛充满了口水战、抬杠、灌水,就算有人回答你的问题,你怎么知道回答的人是真懂,还是半瓶水晃荡,又或者是道听途说人云亦云呢?如果新手提问的话,要么就是问题很简单别人不乐意反复回答,或者糊弄你几句让你看书去;要么就是问题太复杂,没有人愿意花时间写几百字详尽解答。而且新手通常也问不出什么有新意的问题,基本上都是别人问过回答过的,多用搜索就能解决。所以我觉得少上论坛,浪费时间。

30.    你这个观点很独特,我第一次听到。你对开源中国有什么意见或者建议吗?

现在功能相近的开源库很多,开源中国是不是能做一些实测评比,用不同的库实现相同的功能,然后在相同的运行环境中比一比性能和使用的难易度。对于网络库而言,有一个不错的测试用例:http://blog.yufeng.info/archives/116中提到的hotwheel。

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice  t.sina.com.cn/giantchen

Muduo 全系列文章列表: http://blog.csdn.net/Solstice/category/779646.aspx

本文介绍 Muduo 中输入输出缓冲区的设计与实现。

本文中 buffer 指一般的应用层缓冲区、缓冲技术,Buffer 特指 muduo::net::Buffer class。

本文前两节的内容已事先发表在 muduo 英文博客http://muduo.chenshuo.com/2011/04/essentials-of-non-blocking-tcp-network.html 。

Muduo 的 IO 模型

UNPv1 第 6.2 节总结了 Unix/Linux 上的五种 IO 模型:阻塞(blocking)、非阻塞(non-blocking)、IO 复用(IO multiplexing)、信号驱动(signal-driven)、异步(asynchronous)。这些都是单线程下的 IO 模型。

C10k 问题的页面介绍了五种 IO 策略,把线程也纳入考量。(现在 C10k 已经不是什么问题,C100k 也不是大问题,C1000k 才算得上挑战)。

在这个多核时代,线程是不可避免的。那么服务端网络编程该如何选择线程模型呢?我赞同 libev 作者的观点:one loop per thread is usually a good model。之前我也不止一次表述过这个观点,见《多线程服务器的常用编程模型》《多线程服务器的适用场合》。

如果采用 one loop per thread 的模型,多线程服务端编程的问题就简化为如何设计一个高效且易于使用的 event loop,然后每个线程 run 一个 event loop 就行了(当然、同步和互斥是不可或缺的)。在“高效”这方面已经有了很多成熟的范例(libev、libevent、memcached、varnish、lighttpd、nginx),在“易于使用”方面我希望 muduo 能有所作为。(muduo 可算是用现代 C++ 实现了 Reactor 模式,比起原始的
Reactor 来说要好用得多。)

event loop 是 non-blocking 网络编程的核心,在现实生活中,non-blocking 几乎总是和 IO-multiplexing 一起使用,原因有两点:

  • 没有人真的会用轮询 (busy-pooling) 来检查某个 non-blocking IO 操作是否完成,这样太浪费 CPU cycles。
  • IO-multiplex 一般不能和 blocking IO 用在一起,因为 blocking IO 中 read()/write()/accept()/connect() 都有可能阻塞当前线程,这样线程就没办法处理其他 socket 上的 IO 事件了。见 UNPv1 第 16.6 节“nonblocking accept”的例子。

所以,当我提到 non-blocking 的时候,实际上指的是 non-blocking + IO-muleiplexing,单用其中任何一个是不现实的。另外,本文所有的“连接”均指 TCP 连接,socket 和 connection 在文中可互换使用。

当然,non-blocking 编程比 blocking 难得多,见陈硕在《Muduo 网络编程示例之零:前言》中“TCP 网络编程本质论”一节列举的难点。基于 event loop 的网络编程跟直接用 C/C++ 编写单线程
Windows 程序颇为相像:程序不能阻塞,否则窗口就失去响应了;在 event handler 中,程序要尽快交出控制权,返回窗口的事件循环。

为什么 non-blocking 网络编程中应用层 buffer 是必须的?

Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系统调用上,这样可以最大限度地复用 thread-of-control,让一个线程能服务于多个 socket 连接。IO 线程只能阻塞在 IO-multiplexing 函数上,如 select()/poll()/epoll_wait()。这样一来,应用层的缓冲是必须的,每个 TCP socket 都要有 stateful 的 input buffer 和 output buffer。

TcpConnection 必须要有 output buffer

考虑一个常见场景:程序想通过 TCP 连接发送 100k 字节的数据,但是在 write() 调用中,操作系统只接受了 80k 字节(受 TCP advertised window 的控制,细节见 TCPv1),你肯定不想在原地等待,因为不知道会等多久(取决于对方什么时候接受数据,然后滑动 TCP 窗口)。程序应该尽快交出控制权,返回 event loop。在这种情况下,剩余的 20k 字节数据怎么办?

对于应用程序而言,它只管生成数据,它不应该关心到底数据是一次性发送还是分成几次发送,这些应该由网络库来操心,程序只要调用 TcpConnection::send() 就行了,网络库会负责到底。网络库应该接管这剩余的
20k 字节数据,把它保存在该 TCP connection 的 output buffer 里,然后注册 POLLOUT 事件,一旦 socket 变得可写就立刻发送数据。当然,这第二次 write() 也不一定能完全写入 20k 字节,如果还有剩余,网络库应该继续关注 POLLOUT 事件;如果写完了 20k 字节,网络库应该停止关注 POLLOUT,以免造成 busy loop。(Muduo EventLoop 采用的是 epoll level trigger,这么做的具体原因我以后再说。)

如果程序又写入了 50k 字节,而这时候 output buffer 里还有待发送的 20k 数据,那么网络库不应该直接调用 write(),而应该把这 50k 数据 append 在那 20k 数据之后,等 socket 变得可写的时候再一并写入。

如果 output buffer 里还有待发送的数据,而程序又想关闭连接(对程序而言,调用 TcpConnection::send() 之后他就认为数据迟早会发出去),那么这时候网络库不能立刻关闭连接,而要等数据发送完毕,见我在《为什么
muduo 的 shutdown() 没有直接关闭 TCP 连接?
》一文中的讲解。

综上,要让程序在 write 操作上不阻塞,网络库必须要给每个 tcp connection 配置 output buffer。

TcpConnection 必须要有 input buffer

TCP 是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等等情况。一个常见的场景是,发送方 send 了两条 10k 字节的消息(共 20k),接收方收到数据的情况可能是:

  • 一次性收到 20k 数据
  • 分两次收到,第一次 5k,第二次 15k
  • 分两次收到,第一次 15k,第二次 5k
  • 分两次收到,第一次 10k,第二次 10k
  • 分三次收到,第一次 6k,第二次 8k,第三次 6k
  • 其他任何可能

网络库在处理“socket 可读”事件的时候,必须一次性把 socket 里的数据读完(从操作系统 buffer 搬到应用层 buffer),否则会反复触发 POLLIN 事件,造成 busy-loop。(Again, Muduo EventLoop 采用的是 epoll level trigger,这么做的具体原因我以后再说。)

那么网络库必然要应对“数据不完整”的情况,收到的数据先放到 input buffer 里,等构成一条完整的消息再通知程序的业务逻辑。这通常是 codec 的职责,见陈硕《Muduo 网络编程示例之二:Boost.Asio 的聊天服务器》一文中的“TCP
分包”的论述与代码。

所以,在 tcp 网络编程中,网络库必须要给每个 tcp connection 配置 input buffer。

所有 muduo 中的 IO 都是带缓冲的 IO (buffered IO),你不会自己去 read() 或 write() 某个 socket,只会操作 TcpConnection 的 input buffer 和 output buffer。更确切的说,是在 onMessage() 回调里读取 input buffer;调用 TcpConnection::send() 来间接操作 output buffer,一般不会直接操作 output buffer。

btw, muduo 的 onMessage() 的原型如下,它既可以是 free function,也可以是 member function,反正 muduo TcpConnection 只认 boost::function<>。

void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime);

对于网络程序来说,一个简单的验收测试是:输入数据每次收到一个字节(200 字节的输入数据会分 200 次收到,每次间隔 10 ms),程序的功能不受影响。对于 Muduo 程序,通常可以用 codec 来分离“消息接收”与“消息处理”,见陈硕《
muduo 中实现 protobuf 编解码器与消息分发器
》一文中对“编解码器 codec”的介绍。

如果某个网络库只提供相当于 char buf[8192] 的缓冲,或者根本不提供缓冲区,而仅仅通知程序“某 socket 可读/某 socket 可写”,要程序自己操心 IO buffering,这样的网络库用起来就很不方便了。(我有所指,你懂得。)

Buffer 的要求

http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.h

Muduo Buffer 的设计考虑了常见的网络编程需求,我试图在易用性和性能之间找一个平衡点,目前这个平衡点更偏向于易用性。

Muduo Buffer 的设计要点:

  • 对外表现为一块连续的内存(char*, len),以方便客户代码的编写。
  • 其 size() 可以自动增长,以适应不同大小的消息。它不是一个 fixed size array (即 char buf[8192])。
  • 内部以 vector of char 来保存数据,并提供相应的访问函数。

Buffer 其实像是一个 queue,从末尾写入数据,从头部读出数据。

谁会用 Buffer?谁写谁读?根据前文分析,TcpConnection 会有两个 Buffer 成员,input buffer 与 output buffer。

  • input buffer,TcpConnection 会从 socket 读取数据,然后写入 input buffer(其实这一步是用 Buffer::readFd() 完成的);客户代码从 input buffer 读取数据。
  • output buffer,客户代码会把数据写入 output buffer(其实这一步是用 TcpConnection::send() 完成的);TcpConnection 从 output buffer 读取数据并写入 socket。

其实,input 和 output 是针对客户代码而言,客户代码从 input 读,往 output 写。TcpConnection 的读写正好相反。

以下是 muduo::net::Buffer 的类图。请注意,为了后面画图方便,这个类图跟实际代码略有出入,但不影响我要表达的观点。

这里不介绍每个成员函数的作用,留给《Muduo 网络编程示例》系列。下文会仔细介绍 readIndex 和 writeIndex 的作用。

Buffer::readFd()

我在《Muduo 网络编程示例之零:前言》中写道

  • 在非阻塞网络编程中,如何设计并使用缓冲区?一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面,我们系统减少内存占用。如果有 10k 个连接,每个连接一建立就分配 64k 的读缓冲的话,将占用 640M 内存,而大多数时候这些缓冲区的使用率很低。muduo 用 readv
    结合栈上空间巧妙地解决了这个问题。

具体做法是,在栈上准备一个 65536 字节的 stackbuf,然后利用 readv() 来读取数据,iovec 有两块,第一块指向 muduo Buffer 中的 writable 字节,另一块指向栈上的 stackbuf。这样如果读入的数据不多,那么全部都读到 Buffer 中去了;如果长度超过 Buffer 的 writable 字节数,就会读到栈上的 stackbuf 里,然后程序再把 stackbuf 里的数据 append 到 Buffer 中。

代码见 http://code.google.com/p/muduo/source/browse/trunk/muduo/net/Buffer.cc#36

这么做利用了临时栈上空间,避免开巨大 Buffer 造成的内存浪费,也避免反复调用 read() 的系统开销(通常一次 readv() 系统调用就能读完全部数据)。

这算是一个小小的创新吧。

线程安全?

muduo::net::Buffer 不是线程安全的,这么做是有意的,原因如下:

  • 对于 input buffer,onMessage() 回调始终发生在该 TcpConnection 所属的那个 IO 线程,应用程序应该在 onMessage() 完成对 input buffer 的操作,并且不要把 input buffer 暴露给其他线程。这样所有对 input buffer 的操作都在同一个线程,Buffer class 不必是线程安全的。
  • 对于 output buffer,应用程序不会直接操作它,而是调用 TcpConnection::send() 来发送数据,后者是线程安全的。

如果 TcpConnection::send() 调用发生在该 TcpConnection 所属的那个 IO 线程,那么它会转而调用 TcpConnection::sendInLoop(),sendInLoop() 会在当前线程(也就是 IO 线程)操作 output buffer;如果 TcpConnection::send() 调用发生在别的线程,它不会在当前线程调用 sendInLoop() ,而是通过 EventLoop::runInLoop() 把 sendInLoop() 函数调用转移到 IO 线程(听上去颇为神奇?),这样
sendInLoop() 还是会在 IO 线程操作 output buffer,不会有线程安全问题。当然,跨线程的函数转移调用涉及函数参数的跨线程传递,一种简单的做法是把数据拷一份,绝对安全(不明白的同学请阅读代码)。

另一种更为高效做法是用 swap()。这就是为什么 TcpConnection::send() 的某个重载以 Buffer* 为参数,而不是 const Buffer&,这样可以避免拷贝,而用 Buffer::swap() 实现高效的线程间数据转移。(最后这点,仅为设想,暂未实现。目前仍然以数据拷贝方式在线程间传递,略微有些性能损失。)

Muduo Buffer 的数据结构

Buffer 的内部是一个 vector of char,它是一块连续的内存。此外,Buffer 有两个 data members,指向该 vector 中的元素。这两个 indices 的类型是 int,不是 char*,目的是应对迭代器失效。muduo Buffer 的设计参考了 Netty 的 ChannelBuffer 和 libevent 1.4.x 的 evbuffer。不过,其 prependable 可算是一点“微创新”。

Muduo Buffer 的数据结构如下:

图 1

两个 indices 把 vector 的内容分为三块:prependable、readable、writable,各块的大小是(公式一):

prependable = readIndex

readable = writeIndex - readIndex

writable = size() - writeIndex

(prependable 的作用留到后面讨论。)

readIndex 和 writeIndex 满足以下不变式(invariant):

0 ≤ readIndex ≤ writeIndex ≤ data.size()

Muduo Buffer 里有两个常数 kCheapPrepend 和 kInitialSize,定义了 prependable 的初始大小和 writable 的初始大小。(readable 的初始大小为 0。)在初始化之后,Buffer 的数据结构如下:括号里的数字是该变量或常量的值。

图 2

根据以上(公式一)可算出各块的大小,刚刚初始化的 Buffer 里没有 payload 数据,所以 readable == 0。

Muduo Buffer 的操作

1. 基本的 read-write cycle

Buffer 初始化后的情况见图 1,如果有人向 Buffer 写入了 200 字节,那么其布局是:

图 3

图 3 中 writeIndex 向后移动了 200 字节,readIndex 保持不变,readable 和 writable 的值也有变化。

如果有人从 Buffer read() & retrieve() (下称“读入”)了 50 字节,结果见图 4。与上图相比,readIndex 向后移动 50 字节,writeIndex 保持不变,readable 和 writable 的值也有变化(这句话往后从略)。

图 4

然后又写入了 200 字节,writeIndex 向后移动了 200 字节,readIndex 保持不变,见图 5。

图 5

接下来,一次性读入 350 字节,请注意,由于全部数据读完了,readIndex 和 writeIndex 返回原位以备新一轮使用,见图 6,这和图 2 是一样的。

图 6

以上过程可以看作是发送方发送了两条消息,长度分别为 50 字节和 350 字节,接收方分两次收到数据,每次 200 字节,然后进行分包,再分两次回调客户代码。

自动增长

Muduo Buffer 不是固定长度的,它可以自动增长,这是使用 vector 的直接好处。

假设当前的状态如图 7 所示。(这和前面图 5 是一样的。)

图 7

客户代码一次性写入 1000 字节,而当前可写的字节数只有 624,那么 buffer 会自动增长以容纳全部数据,得到的结果是图 8。注意 readIndex 返回到了前面,以保持 prependable 等于 kCheapPrependable。由于 vector 重新分配了内存,原来指向它元素的指针会失效,这就是为什么 readIndex 和 writeIndex 是整数下标而不是指针。

图 8

然后读入 350 字节,readIndex 前移,见图 9。

图 9

最后,读完剩下的 1000 字节,readIndex 和 writeIndex 返回 kCheapPrependable,见图 10。

图 10

注意 buffer 并没有缩小大小,下次写入 1350 字节就不会重新分配内存了。换句话说,Muduo Buffer 的 size() 是自适应的,它一开始的初始值是 1k,如果程序里边经常收发 10k 的数据,那么用几次之后它的 size() 会自动增长到 10k,然后就保持不变。这样一方面避免浪费内存(有的程序可能只需要 4k 的缓冲),另一方面避免反复分配内存。当然,客户代码可以手动 shrink() buffer size()。

size() 与 capacity()

使用 vector 的另一个好处是它的 capcity() 机制减少了内存分配的次数。比方说程序反复写入 1 字节,muduo Buffer 不会每次都分配内存,vector 的 capacity() 以指数方式增长,让 push_back() 的平均复杂度是常数。比方说经过第一次增长,size() 刚好满足写入的需求,如图 11。但这个时候 vector 的 capacity() 已经大于 size(),在接下来写入
capacity()-size() 字节的数据时,都不会重新分配内存,见图 12。

图 11

图 12

细心的读者可能会发现用 capacity() 也不是完美的,它有优化的余地。具体来说,vector::resize() 会初始化(memset/bzero)内存,而我们不需要它初始化,因为反正立刻就要填入数据。比如,在图 12 的基础上写入 200 字节,由于 capacity() 足够大,不会重新分配内存,这是好事;但是 vector::resize() 会先把那 200 字节设为 0 (图 13),然后 muduo buffer 再填入数据(图 14)。这么做稍微有点浪费,不过我不打算优化它,除非它确实造成了性能瓶颈。(精通
STL 的读者可能会说用 vector::append() 以避免浪费,但是 writeIndex 和 size() 不一定是对齐的,会有别的麻烦。)

图 13

图 14

google protobuf 中有一个 STLStringResizeUninitialized 函数,干的就是这个事情。

内部腾挪

有时候,经过若干次读写,readIndex 移到了比较靠后的位置,留下了巨大的 prependable 空间,见图 14。

图 14

这时候,如果我们想写入 300 字节,而 writable 只有 200 字节,怎么办?muduo Buffer 在这种情况下不会重新分配内存,而是先把已有的数据移到前面去,腾出 writable 空间,见图 15。

图 15

然后,就可以写入 300 字节了,见图 16。

图 16

这么做的原因是,如果重新分配内存,反正也是要把数据拷到新分配的内存区域,代价只会更大。

prepend

前面说 muduo Buffer 有个小小的创新(或许不是创新,我记得在哪儿看到过类似的做法,忘了出处),即提供 prependable 空间,让程序能以很低的代价在数据前面添加几个字节。

比方说,程序以固定的4个字节表示消息的长度(即《Muduo 网络编程示例之二:Boost.Asio 的聊天服务器》中的 LengthHeaderCodec),我要序列化一个消息,但是不知道它有多长,那么我可以一直 append()
直到序列化完成(图 17,写入了 200 字节),然后再在序列化数据的前面添加消息的长度(图 18,把 200 这个数 prepend 到首部)。

图 17

图 18

通过预留 kCheapPrependable 空间,可以简化客户代码,一个简单的空间换时间思路。

其他设计方案

这里简单谈谈其他可能的应用层 buffer 设计方案。

不用 vector<char>?

如果有 STL 洁癖,那么可以自己管理内存,以 4 个指针为 buffer 的成员,数据结构见图 19。

图 19

说实话我不觉得这种方案比 vector 好。代码变复杂,性能也未见得有 noticeable 的改观。

如果放弃“连续性”要求,可以用 circular buffer,这样可以减少一点内存拷贝(没有“内部腾挪”)。

Zero copy ?

如果对性能有极高的要求,受不了 copy() 与 resize(),那么可以考虑实现分段连续的 zero copy buffer 再配合 gather scatter IO,数据结构如图 20,这是 libevent 2.0.x 的设计方案。TCPv2介绍的 BSD
TCP/IP 实现中的 mbuf 也是类似的方案,Linux 的 sk_buff 估计也差不多。细节有出入,但基本思路都是不要求数据在内存中连续,而是用链表把数据块链接到一起。

图 20

当然,高性能的代价是代码变得晦涩难读,buffer 不再是连续的,parse 消息会稍微麻烦。如果你的程序只处理 protobuf Message,这不是问题,因为 protobuf 有 ZeroCopyInputStream 接口,只要实现这个接口,parsing 的事情就交给 protobuf Message 去操心了。

性能是不是问题?看跟谁比

看到这里,有的读者可能会嘀咕,muduo Buffer 有那么多可以优化的地方,其性能会不会太低?对此,我的回应是“可以优化,不一定值得优化。”

Muduo 的设计目标是用于开发公司内部的分布式程序。换句话说,它是用来写专用的 Sudoku server 或者游戏服务器,不是用来写通用的 httpd 或 ftpd 或 www proxy。前者通常有业务逻辑,后者更强调高并发与高吞吐。

以 Sudoku 为例,假设求解一个 Sudoku 问题需要 0.2ms,服务器有 8 个核,那么理想情况下每秒最多能求解 40,000 个问题。每次 Sudoku 请求的数据大小低于 100 字节(一个 9x9 的数独只要 81 字节,加上 header 也可以控制在 100 bytes 以下),就是说 100 x 40000 = 4 MB per second 的吞吐量就足以让服务器的 CPU 饱和。在这种情况下,去优化 Buffer 的内存拷贝次数似乎没有意义。

再举一个例子,目前最常用的千兆以太网的裸吞吐量是 125MB/s,扣除以太网 header、IP header、TCP header之后,应用层的吞吐率大约在 115 MB/s 上下。而现在服务器上最常用的 DDR2/DDR3 内存的带宽至少是 4GB/s,比千兆以太网高 40 倍以上。就是说,对于几 k 或几十 k 大小的数据,在内存里边拷几次根本不是问题,因为受以太网延迟和带宽的限制,跟这个程序通信的其他机器上的程序不会觉察到性能差异。

最后举一个例子,如果你实现的服务程序要跟数据库打交道,那么瓶颈常常在 DB 上,优化服务程序本身不见得能提高性能(从 DB 读一次数据往往就抵消了你做的全部 low-level 优化),这时不如把精力投入在 DB 调优上。

专用服务程序与通用服务程序的另外一点区别是 benchmark 的对象不同。如果你打算写一个 httpd,自然有人会拿来和目前最好的 nginx 对比,立马就能比出性能高低。然而,如果你写一个实现公司内部业务的服务程序(比如分布式存储或者搜索或者微博或者短网址),由于市面上没有同等功能的开源实现,你不需要在优化上投入全部精力,只要一版做得比一版好就行。先正确实现所需的功能,投入生产应用,然后再根据真实的负载情况来做优化,这恐怕比在编码阶段就盲目调优要更 effective 一些。

Muduo 的设计目标之一是吞吐量能让千兆以太网饱和,也就是每秒收发 120 兆字节的数据。这个很容易就达到,不用任何特别的努力。

如果确实在内存带宽方面遇到问题,说明你做的应用实在太 critical,或许应该考虑放到 Linux kernel 里边去,而不是在用户态尝试各种优化。毕竟只有把程序做到 kernel 里才能真正实现 zero copy,否则,核心态和用户态之间始终是有一次内存拷贝的。如果放到 kernel 里还不能满足需求,那么要么自己写新的 kernel,或者直接用 FPGA 或 ASIC 操作 network adapter 来实现你的高性能服务器。

(待续)

时间: 2024-11-25 08:48:08

Muduo 设计与实现之一:Buffer 类的设计的相关文章

muduo buffer类的设计与使用

Unix/Linux上的五种IO模型(UNP6.2) IO多路复用一般不能和blocking IO用在一起,因为blocking IO中read() write() accept() connect()都有可能阻塞当前线程,这样线程就没办法处理其他socket上的IO事件了 non-blocking IO的核心思想是避免阻塞在read()或write()或其他IO系统调用上,让一个线程能服务于多个socket连接,IO线程只能阻塞在IO多路复用函数上,如select.poll.epoll_wai

Buffer类的详解(转)

Buffer 类是 java.nio 的构造基础.一个 Buffer 对象是固定数量的数据的容器,其作用是一个存储器,或者分段运输区,在这里,数据可被存储并在之后用于检索.缓冲区可以被写满或释放.对于每个非布尔原始数据类型都有一个缓冲区类,即 Buffer 的子类有:ByteBuffer.CharBuffer.DoubleBuffer.FloatBuffer.IntBuffer.LongBuffer 和 ShortBuffer,是没有 BooleanBuffer 之说的.尽管缓冲区作用于它们存储

第11周项目1——点、圆、圆柱类的设计

<pre class="html" name="code"> /* *Copyright (c) 2016,烟台大学计算机学院 *All rights reserved. *文件名称 : *作 者 : 刘云 *完成日期 : 2016年5月8号 *版 本 号 : v6.0 * *问题描述 : 点.圆.圆柱类的设计 *输入描述 : 无 *程序输出 : */ /**************************************************

Node.js权威指南 (5) - 使用Buffer类处理二进制数据

5.1 创建Buffer对象 / 705.2 字符串的长度与缓存区的长度 / 725.3 Buffer对象与字符串对象之间的相互转换 / 74 5.3.1 Buffer对象的toString方法 / 74 5.3.2 Buffer对象的write方法 / 75 5.3.3 StringDecoder对象 / 755.4 Buffer对象与数值对象之间的相互转换 / 775.5 Buffer对象与JSON对象之间的相互转换 / 795.6 复制缓存数据 / 805.7 Buffer类的类方法 /

类的设计

1001: 类的设计(1) Time Limit: 1 Sec  Memory Limit: 65535 MB   64bit IO Format: %lldSubmitted: 9  Accepted: 7[Submit][Status][Web Board] Description 设计clock类,成员数据包含时(hour)分(minute)秒(second),都是int类型,根据给定的main函数设计必要的成员函数. main函数已给定,提交时只需要提交main函数外的代码部分. int

表达式计算器类的设计5(面向对象的表达式计算器8)

计算器的github下载地址:https://github.com/ljian1992/calculator 概述 表达式计算器的类基本已经设计完成了,由于在程序运行的时候总会有这样那样的异常,例如:a +2, a没有初始化,对于异常的管理一般而言是需要自定义异常类.这个自定义异常类也是在继承了系统已经定义好的exception类,然后再重新定义内容. 异常的种类 语法异常---->SyntaxError类 赋值时左操作数不是变量:1 = 2; 缺少括号:1 + (2*2 不认识的函数: 函数缺

表达式计算器类的设计4(面向对象的表达式计算器7)

概述 把符号表和变量表中的内容保存到一个文件中,通过IO文件流,来把符号表和变量表存储到文件中.在这之前需要弄明白什么是序列化和反序列化 对象的序列化 序列化:把对象转换为字节序列的过程 反序列化:把字节序列恢复为对象的过程 我们要把SymbolTable类的对象(符号表)和Storage类的对象(变量表)转换成字节序列保存到文件中,这时就可以设置Serializer类来完成这样的功能,同样的设置一个DeSerializer类来完成把保存到文件当中的字节序列恢复为对象的功能.这里要注意的是,所有

表达式计算器类的设计3(面向对象的表达式计算器6)

概述 有了构建语法的类,存储符号的类,现在就可以对表达式进行扫描,解析了.扫描可以抽象出一个Scanner类来完成这一个功能,而解析可以抽象出一个Parser类来完成这一个功能.这两个类存在一定的关系,扫描与解析的互动是这样子的:扫描到一个标识符,然后解析它是什么标识符.由于该表达式计算器是要支持一些命令的,命令的解析和表达式的解析过程完全不一样,所有呢,又要设置一个CommandParser类,来解析命令. Scanner类,Parser类,CommandParser类的设计 Scanner类

MFC的窗口分割的设计与实现以及CSplitterWnd 类分析

1 引言 在Microsoft VC++ 6.0 中,基于MFC 的应用程序一般分为以下几种:多文档界面(MDI). 单文档界面(SDI)以及基于对话框的应用程序.其中单文档又可分为单视图的和多视图的, 一般情况下,单文档仅需要单视图就够了,如Windows 自带的记事本.画图程序等等,但 在一些情况下,单文档需要多视图支持,比如同时观察文档的不同部分,同时从不同的角度 观察同一文档等. 在MFC 的框架下,文档对象(CDocument)有一个保存其所有视图的列表,并提供了 增加视图(AddVi