2010年3月作者写了一篇《学之者生,用之者死——ACE历史与简评》(http://blog.csdn.net/Solstice/archive/2010/03/10/5364096.aspx,ACE是(Adaptive Communication Environment)是一个C++编写的开源框架,用于开发高性能、可伸缩、分布式系统的网络应用),其中提到了作者心目中理想的网络库的样子:
1.线程安全,原生支持多核多线程。
2.不考虑可移植性,不跨平台,只支持Linux,不支持Windows。
3.主要支持x86-64(也称为x64或AMD64,是一种64位处理器架构,它是x86架构的扩展,由AMD公司推出并普及),兼顾IA32(Intel Architecture 32,是Intel x86架构的技术名称,是一种32位的处理器架构,包含了所有x86处理器中的通用指令集,x86-64架构是IA32架构的扩展,虽然现在的处理器多数都采用了x86-64(64位)处理器架构,但仍然有很多32位的应用程序仅支持IA32架构)。(实际上muduo也可以运行在ARM上,ARM全称为Advanced RISC Machines,是一种低功耗、高性能的处理器架构,广泛应用于嵌入式系统、智能手机、平板电脑、可穿戴设备、智能家居、工业控制等领域)
4.不支持UDP,只支持TCP。
5.不支持IPv6,只支持IPv4。
6.不考虑广域网应用,只考虑局域网。(实际上muduo也可以用在广域网上)
7.不考虑公网,只考虑内网。不为安全性做特别的增强。
8.只支持一种使用模式:非阻塞IO+one event loop per thread,不支持阻塞IO。
9.API简单易用,只暴露具体类和标准库里的类。API不使用non-trivial templates(指在C++中使用模板进行泛型编程时,涉及到的较为复杂的类型转换、逻辑判断和编译时计算等操作,而不仅仅是简单的类型参数替换),也不使用虚函数。
10.只满足常用需求的90%,不面面俱到,必要的时候以app来适应lib。(为什么?)
11.只做library,不做成framework。
12.争取全部代码在5000行以内(不含测试)。
13.在不增加复杂度的前提下可以支持FreeBSD/Darwin,方便将来用Mac作为开发用机,但不为它做性能优化。也就是说,IO multiplexing使用poll(2)和epoll(4)。
14.以上条件都满足时,可以考虑搭配Google Protocol Buffers RPC。
在想清楚以上目标后,作者开始第三次尝试编写自己的C++网络库。与前两次不同,这次作者开始就想好了名字,叫muduo(木铎),并在Google code(Google Code是谷歌提供的一个免费的开发者社区和代码托管平台,为软件开发者提供了一个共享代码、协作开发、主持项目、版本控制、问题跟踪、文档管理和发布软件等功能。该平台于2016年1月25日停止运营,现已不再提供服务)上创建了项目:http://code.google.com/p/muduo。muduo以git为版本管理工具,托管于https://github.com/chenshuo/muduo。muduo的主体内容在2010年5月底已经基本完成,8月底发布0.1.0版,现在(2012年11月)的最新版本是0.8.2。
使用Sockets API进行网络编程是很容易上手的一项技术,花半天时间读完一两篇网上教程,相信不难写出能相互连通的网络程序。例如下面这个网络服务端和客户端程序,它用Python实现了一个简单的“Hello”协议,客户端发来姓名,服务端返回问候语和服务器的当前时间,以下是服务端程序:
#!/usr/bin/python
import socket, time
serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serversocket.bind(('', 8888))
serversocket.listen(5)
while True:
(clientsocket, address) = serversocket.accept() # 等待客户端连接
data = clientsocket.recv(4096) # 接收姓名
datetime = time.asctime() + '\n'
clientsocket.send('Hello ' + data) # 发回问候
clientsocket.send('My time is ' + datetime) # 发送服务器当前时间
clientsocket.close() # 关闭连接
以下是客户端程序:
# 省略import等
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((sys.argv[1], 8888)) # 服务器地址由命令行指定
sock.send(os.getlogin() + '\n') # 发送姓名
message = sock.recv(4096) # 接收响应
print message # 打印结果
sock.close() # 关闭连接
上面两个程序使用了全部主要的Sockets API,包括socket(2)、bind(2)、listen(2)、accept(2)、connect(2)、recv(2)、send(2)、close(2)、gethostbyname(3)(代码中没有显式调用,而是在connect时隐式调用)等,似乎网络编程一点也不难。在同一台机器上运行上面的服务端和客户端,结果不出所料:
但是连接同一局域网的另一台服务器时,收到的数据是不完整的。错在哪里?
出现这种情况的原因是高级语言(Java、Python等)的Sockets库并没有对Sockets API提供更高层的封装,直接用它编写网络程序很容易掉到陷阱里,因此我们需要一个好的网络库来降低开发难度。网络库的价值还在于能方便地处理并发连接。
源文件tar(Tape Archive,用于将多个文件和文件夹打包成一个单一的文件)包的下载地址:http://code.google.com/p/muduo/downloads/list,下面以muduo-0.8.2-beta.tar.gz为例说明如何安装它。
muduo使用了Linux较新的系统调用(主要是timerfd(它提供了一种定时器机制,可以用来设置定时器以在指定时间后触发事件)和eventfd(它是一种用于线程间通信的轻量级方式,实现了线程阻塞和非阻塞的notify/wait机制)系统调用),要求Linux的内核版本大于2.6.28。作者用Debian 6.9 Squeeze/Ubuntu 10.04 LTS作为主要开发环境(内核版本2.6.32),以g++ 4.4为主要编译版本,在32-bit和64-bit x86系统都编译测试通过。muduo在Fedora 13和CentOS 6上也能正常编译运行,还有热心网友为Arch Linux编写了AUR文件。
注:
1.Debian是一种基于自由软件的操作系统,为了保证软件的自由性,Debian通过自己的社区进行开发和维护,而不是通过一家公司或组织来进行管理。Debian 6.0 Squeeze是Debian项目发布的第六个稳定版,于2011年发布,支持的架构包括i386、amd64、PowerPC等。i386架构是一种基于英特尔8086处理器的32位架构,主要用于个人计算机中,包括Intel Pentium、AMD Athlon等CPU,它也是最常见的架构之一。PowerPC是一种由IBM、Apple和摩托罗拉公司共同开发的基于RISC架构的处理器。它最初是为桌面和服务器计算机设计的,现在也被广泛应用于嵌入式系统中。
2.Ubuntu 10.04 LTS(Long-Term Support)是Ubuntu操作系统系列中的一款长期支持版本,它于2010年4月发布,其支持期限为5年,直到2015年4月。
3.Fedora 13是Linux发行版Fedora系列的一个版本。它于2010年5月发布,是该系列的第13个版本。
4.Arch Linux是一种基于x86-64架构的自由、开源和类UNIX的操作系统。AUR指的是Arch User Repository(Arch用户仓库),它是Arch Linux操作系统的一个社区驱动软件仓库,允许用户共享和下载Arch Linux软件包的构建脚本。
如果要在较旧的Linux 2.6内核上使用muduo,可以参考backport.diff(backport.diff是一个Linux内核补丁,用于将较新版本的Linux内核的新特性或修复应用到较旧版本的内核中)来修改代码。不过这些系统上没有充分测试,仅仅是编译和冒烟测试(一种简单而快速的测试方法,用于验证应用程序中的主要功能是否正常工作,这种测试的目的是确认系统的主要功能是否可以正常工作,而不是测试每个细节或每个功能)通过。另外muduo也可以运行在嵌入式系统中,作者在Samsung S3C2440开发板(ARM9)和Raspberry Pi(ARM11)上成功运行了muduo的多个示例。代码只需略作改动,可参考armlinux.diff(armlinux.diff是一个针对ARM架构的Linux内核的补丁文件,它包含了一系列针对ARM硬件的优化和改进,以确保Linux内核能够更好地运行在ARM架构的设备上)。
muduo采用CMAKE为build system,安装方法如下:
sudo apt-get install cmake
muduo依赖于Boost(核心库只依赖TR1,TR1库是C++11之前标准C++库中的一个扩展库,全称是Technical Report 1,技术报告1,这些功能都在C++11中成为了标准C++库的一部分,示例代码用到了其他Boost库),也很容易安装:
sudo apt-get install libboost-dev libboost-test-dev
muduo有三个非必需的依赖库:curl、c-ares DNS、Google Protobuf,如果安装了这三个库,cmake会自动多编译一些示例。安装方法如下:
sudo apt-get install libcurl4-openssl-dev libc-ares-dev
sudo apt-get install protobuf-compiler libprotobuf-dev
注:
1.Curl是一个免费的开源库,它被用来在各种平台上进行数据传输。Curl支持HTTP、HTTPS、FTP、FTPS、SMTP、SMTPS、TELNET、HTTP POST、HTTP PUT、FTP等协议。Curl库是C语言编写的,因此它是高效的,可以用于各种编程语言和操作系统。
2.c-ares是一个C语言编写的异步DNS解析库,它能够在多个请求之间共享DNS解析结果,提高了网络应用程序的性能。
3.Google Protobuf库(Protocol Buffers)是一个用于序列化结构化数据的开源库,由Google公司开发,支持多种编程语言,包括C++、Java、Python、Go和C#等。它提供了一种轻量级、高效和可扩展的数据交换格式,为分布式应用程序提供了一种快速、可扩展、语言无关且简单易用的数据交换方式。
muduo的编译方法很简单:
tar zxf muduo-0.8.2-beta.tar.gz
cd muduo
./build.sh -j2 # 编译muduo库和它自带的例子生成的可执行文件和静态库文件分别位于../build/debug/{bin,lib}
./build.sh install # 将muduo头文件和库文件安装到../build/debug-install/{include,lib}
# 以便muduo-protorpc和muduo-udns等库使用
如果要编译release版(以-O2优化),可执行:
# 此处设置的环境变量仅在执行./build.sh -j2时生效
# 一般-j选项的作用应该是设置并发编译线程数,尤其是在使用make工具的时候,make的-j命令指定并行编译的线程数
BUILD_TYPE=release ./build.sh -j2 # 编译muduo库和它自带的例子,生成的可执行文件和静态库文件
# 分别位于../build/release/{bin,lib}
BUILD_TYPE=release ./build.sh install # 将muduo头文件和库文件安装到../build/release-install/{include,lib}
# 以便muduo-protorpc和muduo-udns等库使用
在muduo 1.0正式发布之后,BUILD_TYPE的默认值会改成release。
编译完成之后可试运行其中的例子,如/bin/inspector_test,然后通过浏览器访问http://10.0.0.10:12345或http://10.0.0.10:12345/proc/status,其中10.0.0.10替换为你的Linux box(指运行Linux操作系统的计算机或服务器)的IP。
muduo是静态链接(原因是在分布式系统中正确安全地发布动态库的成本很高,见第十一章)的C++程序库,使用muduo库的时候,只需要设置好头文件路径(例如…/build/debug-install/include)和库文件路径(例如…/build/debug-install/lib)并链接相应的静态库文件(-lmuduo_net -lmuduo_base)即可。以下示范项目展示了如何使用CMake和普通makefile编译基于muduo的程序:https://github.com/chenshuo/muduo-tutorial。
muduo的目录结构如下:
muduo的源代码文件名与class名相同,例如ThreadPool class的定义是muduo/base/ThreadPool.h,其实现位于muduo/base/ThreadPool.cc。
muduo/base目录是一些基础库,都是用户可见的类,内容包括:
muduo是基于Reactor模式的网络库,其核心是个事件循环EventLoop,用于响应计时器和IO事件。muduo采用基于对象(object-based)而非面向对象(object-oriented)的设计风格,其事件回调接口多以boost::function+boost::bind表达,用户在使用muduo的时候不需要继承其中的class。
网络库核心位于nuduo/net和muduo/net/poller,一共不到4300行代码,以下灰底表示用户不可见的内部类:
网络库有一些附属模块,它们不是核心内容,在使用的时候需要链接相应的库,例如-lmuduo_http、-lmuduo_inspect等。HttpServer和Inspector暴露出一个http界面,用户监控进程的状态,类似Java JMX(第九章)。JMX是Java管理扩展,是Java平台上一种标准化的管理架构,用于监控和管理应用程序、设备、系统等各种资源。
附属模块位于muduo/net/{http,inspect,protorpc}等处:
muduo的头文件明确分为客户可见和客户不可见两类。以下是安装之后暴露的头文件和库文件。对于使用muduo库而言,只需要掌握5个关键类:Buffer、EventLoop、TcpConnection、TcpClient、TcpServer。
以下是muduo的网络核心库的头文件包含关系,用户可见的为白底,用户不可见的为灰底:
muduo头文件中使用了前向声明(forward declaration),大大简化了头文件之间的依赖关系。例如Acceptor.h、Channel.h、Connector.h、TcpConnection.h都前向声明了EventLoop class,从而避免包含EventLoop.h。(意思是在一个头文件中声明某些类或函数,但是不需要包含这些类或函数的详细定义,只需要声明它们的存在即可,这种方式可以避免重复包含头文件的问题,提高编译效率)另外,TcpClient.h前向声明了Connector class,从而避免将内部类暴露给用户,类似的做法还有TcpServer.h用到的Acceptor和EventLoopThreadPool、EventLoop.h用到的Poller和TimerQueue、TcpConnection.h用到的Channel和Socket等。
公开接口:
1.Buffer是仿Netty ChannelBuffer(它是Netty框架中用于操作字节数据的核心类,它封装了一个字节数组,并提供了方便的方法来访问和操作该数组中的数据)的buffer class,数据的读写通过buffer进行。用户代码不需要调用read(2)/write(2),只需要处理收到的数据和准备好要发送的数据(第七章)。
2.InetAddress封装IPv4地址(end point),注意,它不能解析域名,只认IP地址。因为直接用gethostbyname(3)解析域名会阻塞IO线程。
3.EventLoop事件循环(反应器Reactor),每个线程只能有一个EventLoop实体,它负责IO和定时器事件的分派。它用eventfd(2)来异步唤醒,这有别于传统的用一对pipe(2)的办法。它用TimerQueue作为计时器管理,用Poller作为IO multiplexing。
4.EventLoopThread启动一个线程,在其中运行EventLoop::loop()。
5.TcpConnection是整个网络库的核心,封装一次TCP连接,注意它不能发起连接。
6.TcpClient用于编写网络客户端,能发起连接,并且有重试功能。
7.TcpServer用于编写网络服务器,接受客户的连接。
这些类中,TcpConnection的生命期依靠shared_ptr管理(即用户和库共同控制)。Buffer的生命期由TcpConnection控制。其余类的生命期由用户控制。Buffer和InetAddress具有值语义,可以拷贝;其他class都是对象语义,不可以拷贝。
内部实现:
1.Channel是selectable IO channel(指可以被监测并且允许程序进行读写操作的IO通道,例如socket、文件和管道等,这些通道可以通过使用操作系统提供的select、poll、epoll等系统调用来检测是否有数据可以读取或写入),负责注册与响应IO事件,注意它不拥有file descriptor,它是Acceptor、Connector、EventLoop、TimerQueue、TcpConnection的成员,生命期由后者控制。
2.Socket是一个RAII handle,封装一个file descriptor,并在析构时关闭fd。它是Acceptor、TcpConnection的成员,生命期由后者控制。EventLoop、TimerQueue也拥有fd,但是不封装为Socket class。
3.SocketsOps封装各种Sockets系统调用。
4.Poller是PollPoller和EPollPoller的基类,采用“电平触发”的语意。它是EventLoop的成员,生命期由后者控制。
5.PollPoller和EPollPoller封装poll(2)和epoll(4)两种IO multiplexing后端。poll的存在价值是便于测试,因为poll(2)调用时上下文无关的,用strace(1)命令很容易知道库的行为是否正确。
6.Connector用于发起TCP连接,它是TcpClient的成员,生命期由后者控制。
7.Acceptor用于接受TCP连接,它是TcpServer的成员,生命期由后者控制。
8.TimerQueue用timerfd实现定时,这有别于传统的设置poll/epoll_wait的等待时长的办法。TimerQueue用std::map来管理Timer,常用操作的复杂度是O(logN),N是定时器数目。它是EventLoop的成员,生命期由后者控制。
9.EventLoopThreadPool用于创建IO线程池,用于把TcpConnection分派到某个EventLoop线程上。它是TcpServer的成员,生命期由后者控制。
以下是muduo的简化类图,Buffer是TcpConnection的成员:
解释类图:
1.具体类用矩形框表示,矩形框分三层,第一层是类名,第二层是类的成员变量,第三层是类的方法。成员变量以及方法前的+表示public,-表示private,#表示protected,不带符号表示default。表示抽象类时,抽象类和抽象方法用斜体表示。
2.泛化关系(Generalization):指对象与对象之间的继承关系,用空心三角和实线组成的箭头表示,从子类指向父类。
3.聚合关系(Aggregation):用空心菱形表示,表示“整体-部分”关系(菱形在整体那边),但被包含对象的生命周期不一定依赖于包含对象,是一种弱关联。
4.组合关系(Composition):用实心菱形表示,表示“整体-部分”关系(菱形在整体那边),且被包含对象的生命周期依赖于包含对象,是一种强关联。
5.关联关系(Association):用实线表示,表示两个对象之间有某种关联,但它们之间没有包含或依赖的关系。
muduo附带了十几个示例程序,编译出来有近百个可执行文件。这些例子位于examples目录,其中包括从Boost.Asio、Java Netty(一款高性能、事件驱动的异步网络编程框架)、Python Twisted(一个基于事件驱动的网络编程框架,提供了异步I/O、网络通信、安全传输、Web服务等多种网络编程组件和工具,适用于高并发、高性能的分布式系统开发)等处移植过来的例子。这些例子基本覆盖了常见的服务端网络编程功能点,从这些例子可以充分学习非阻塞网络编程。
另外还有几个基于muduo的示例项目,由于License等原因没有放到muduo发行版中,可以单独下载:
1.http://github.com/chenshuo/muduo-udns:基于UDNS的异步DNS解析。
2.http://github.com/chenshuo/muduo-protorpc:新的RPC实现,自动管理对象生命期。
muduo的线程模型符合作者主张的one loop per thread+thread pool模型。每个线程最多有一个EventLoop,每个TcpConnection必须归某个EventLoop管理,所有的IO都会转移到这个线程。换句话说,一个file descriptor只能由一个线程读写。TcpConnection所在的线程由其所属的EventLoop决定,这样我们很方便地把不同的TCP连接放到不同的线程去,也可以把一些TCP连接放到一个线程里。TcpConnection和EventLoop是线程安全的,可以跨线程调用。
TcpServer直接支持多线程,它有两种模式:
1.单线程,accept(2)与TcpConnection用同一个线程做IO。
2.多线程,accept(2)与EventLoop在同一个线程,另外创建一个EventLoopThreadPool,新到的连接会按round-robin方式分配到线程池汇总。
muduo是作者对常见网络编程任务的总结,用它作者能很容易地编写多线程的TCP服务器和客户端。muduo是作者业余时间的作品,代码估计还有一些bug,功能也不完善(例如不支持signal处理),待日后慢慢改进。
muduo只支持Linux 2.6.x下的并发非阻塞TCP网络编程,它的核心是每个IO线程一个事件循环,把IO事件分发到回调函数上。
作者编写muduo库的目的之一是简化日常的TCP网络编程,让程序员能把精力集中在业务逻辑的实现上,而不要天天和Sockets API较劲。借用Brooks的话说(https://www.cgl.ucsf.edu/Outreach/pc204/NoSilverBullet.html),作者希望muduo能减少网络编程中的偶发复杂性(accidental complexity,指软件或系统中存在的,与问题本身无关的复杂性。这种复杂性通常是由软件设计或实现中的不必要的技术细节、编程语言的限制、不必要的优化或决策造成的)。
基于事件的非阻塞网络编程是编写高性能并发网络服务程序的主流模式,头一次使用这种方式编程通常需要转换思维模式。把原来“主动调用recv(2)来接收数据,主动调用accept(2)来接受新连接,主动调用send(2)来发送数据”的思路换成“注册一个收数据的回调,网络库收到数据会调用我,直接把数据提供给我,供我消费。注册一个接受连接的回调,网络库接受了新连接会回调我,直接把新的连接对象传给我,供我使用。需要发送数据的时候,只管往连接中写,网络库会负责无阻塞地发送”。这种编程方式有点像Win32(Win32是微软公司开发的一套API,用于开发和运行在Windows操作系统上的应用程序)的消息循环,消息循环中的代码应该避免阻塞,否则会让整个窗口失去响应,同理,事件处理函数也应该避免阻塞,否则会让网络服务失去响应。
作者认为,TCP网络编程最本质的是处理三个半事件:
1.连接的建立,包括服务端接受(accept)新连接和客户端成功发起(connect)连接。TCP连接一旦建立,客户端和服务端是平等的,可以各自收发数据。
2.连接的断开,包括主动断开(close、shutdown)和被动断开(read(2)返回0)。
3.消息到达,文件描述符可读。这是最为重要的一个事件,对它的处理方式决定了网络编程的风格(阻塞还是非阻塞,如何处理分包,应用层的缓冲如何设计等)。
3.5.消息发送完毕,这算半个。对于低流量的服务,可以不必关心这个事件;另外,这里的“发送完毕”是指将数据写入操作系统的缓冲区,将由TCP协议栈负责数据的发送与重传,不代表对方已经收到数据。
这其中有很多难点,也有很多细节需要注意,比方说:
1.如果要主动关闭连接,如何保证对方已经收到全部数据?如果应用层有缓冲(这在非阻塞网络编程中是必需的),那么如何保证先发送完缓冲区中的数据,然后再断开连接?直接调用close(2)恐怕是不行的。
2.如果主动发起连接,但是对方主动拒绝,如果定期(带back-off地)重试?
3.非阻塞网络编程该用边沿触发(edge trigger)还是电平触发(level trigger)?如果是电平触发,那么什么时候关注EPOLLOUT事件?会不会造成busy-loop?如果是边沿触发,如何防止漏读造成的饥饿?epoll(4)一定比poll(2)快吗?
4.在非阻塞网络编程中,为什么要使用应用层发送缓冲区?假设应用程序需要发送40kB数据,但是操作系统的TCP发送缓冲区只有25kB剩余空间,那么剩下的15kB数据怎么办?如果等待OS缓冲区可用,会阻塞当前线程,因为不知道对方什么时候收到并读取数据。因此网络库应该把这15KB数据缓存起来,放到这个TCP链接的应用层发送缓冲区中,等socket变得可写的时候立刻发送数据,这样“发送”操作不会阻塞。如果应用程序随后又要发送50kB数据,而此时发送缓冲区中尚有未发送的数据(若干kB),那么网络库应该将这50kB数据追加到发送缓冲区的末尾,而不能立刻尝试write(),因为这样有可能打乱数据的顺序。
5.在非阻塞网络编程中,为什么要使用应用层接收缓冲区?假设一次读到的数据不够一个完整的数据包,那么这些已经读到的数据是不是应该先暂存在某个地方,等剩余的数据收到之后再一并处理?见lighttpd关于\r\n\r\n分包的bug(https://redmine.lighttpd.net/issues/2105)。假如数据是一个字节一个字节地到达,间隔10ms,每个字节触发一次文件描述符可读(readable)事件,程序是否还能正常工作?lighttpd在这个问题上出过安全漏洞(http://download.lighttpd.net/lighttpd/security/lighttpd_sa_2010_01.txt)。
6.在非阻塞网络编程中,如何设计并使用缓冲区?一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面,我们希望减少内存占用。如果有10000个并发连接,每个连接一建立就分配各50kB的读写缓冲区的话,将占用1GB内存,而大多数时候这些缓冲区的使用率很低。muduo用readv(2)结合栈上空间巧妙地解决了这个问题。
7.如果使用发送缓冲区,万一接收方处理缓慢,数据会不会一直堆积在发送方,造成内存暴涨?如何做应用层的流量控制?
8.如何设计并实现定时器?并使之与网络IO共用一个线程,以避免锁。
这些问题在muduo的代码中可以找到答案。
muduo的使用非常简单,不需要从指定的类派生,也不用覆写虚函数,只需要注册几个回调函数去处理前面提到的三个半事件就行了。
下面以经典的echo回显服务为例:
1.定义EchoServer class,不需要派生自任何基类:
#include <muduo/net/TcpServer.h>
// RFC 862
class EchoServer
{
public:
EchoServer(muduo::net::EventLoop *loop, const muduo::net::InetAddress &listenAddr);
void start(); // calls server_.start();
private:
void onConnection(const muduo::net::TcpConnectionPtr &conn);
void onMessage(const muduo::net::TcpConnectionPtr &conn, muduo::net::Buffer *buf,
muduo::Timestamp time);
muduo::net::EventLoop *loop_;
muduo::net::TcpServer server_;
};
在构造函数里注册回调函数:
EchoServer::EchoServer(muduo::net::EventLoop *loop, const muduo::net::InetAddress &listenAddr)
: loop_(loop), server_(loop, listenAddr, "EchoServer")
{
server_.setConnectionCallback(boost::bind(&EchoServer::onConnection, this, _1));
server_.setMessageCallback(boost::bind(&EchoServer::onMessage, this, _1, _2, _3));
}
实现EchoServer::onConnection()和Echo::onMessage():
void EchoServer::onConnection(const muduo::net::TcpConnectionPtr &conn)
{
LOG_INFO << "EchoServer - " << conn->peerAddress().toIpPort() << " -> "
<< conn->localAddress().toIpPort() << " is "
<< (conn->connected() ? "UP" : "DOWN");
}
void EchoServer::onMessage(const muduo::net::TcpConnectionPtr &conn, muduo::net::Buffer *buf,
muduo::Timestamp time)
{
muduo::string msg(buf->retrieveAllAsString()); /* 1 */
LOG_INFO << conn->name() << " echo " << msg.size() << " bytes, "
<< "data received at " << time.toString();
conn->send(msg); /* 2 */
}
以上代码中注释1和2是echo服务的“业务逻辑”:把收到的数据原封不动地发回客户端。注意我们不用担心注释2是否完整地发送了数据,因为muduo网络库会帮我们管理发送缓冲区。
以上两个函数体现了“基于事件编程”的典型做法,即程序主体是被动等待事件发生,事件发生之后网络库会调用(回调)事先注册的事件处理函数(event handler)。
在onConnection()中,conn参数TcpConnection对象的shared_ptr,TcpConnection::connected()返回一个bool值,表明目前连接是建立还是断开,TcpConnection的peerAddress()和localAddress()成员函数分别返回对方和本地的地址(以InetAddress对象表示的IP和port)。
在onMessage()中,conn参数是收到数据的那个TCP连接;buf参数是已经收到的数据,buf的数据会累积,直到用户从中取走(retrieve)数据。注意buf参数是指针,表明用户代码可以修改(消费)buffer;time参数是收到数据的确切时间,即epoll_wait(2)返回的时间,注意这个时间通常比read(2)发生的时间略早,可以用于正确测量程序的消息处理延迟。另外,Timestamp对象采用pass-by-value,而不是pass-by-(const)reference,这是有意的,因为在x86-64上可以直接通过寄存器传参(函数参数会直接通过寄存器传递,而不是通过栈传递,这有助于提高函数调用的性能,因为减少了栈操作的开销)。
在main()里用EventLoop让整个程序跑起来:
#include "echo.h"
#include <muduo/base/Logging.h>
#include <muduo/net/EventLoop.h>
// using namespace muduo;
// using namespace muduo::net;
int main()
{
LOG_INFO << "pid= " << getpid();
muduo::net::EventLoop loop;
muduo::net::InetAddress listenAddr(2007);
EchoServer server(&loop, listenAddr);
server.start();
loop.loop();
}
完整的代码见muduo/examples/simple/echo。这个几十行的小程序实现了一个单线程并发的echo服务程序,可以同时处理多个连接。
这个程序用到了TcpServer、EventLoop、TcpConnection、Buffer这几个class,也大致反映了这几个class的典型用法。以后的代码大多会省略namespace。
Python Twisted是一款非常好的网络库,它也采用Reactor作为网络编程的基本模型,所以从使用上与muduo有相似之处(当然,muduo没有deferreds,deferred是Twisted框架的核心之一,用于处理异步编程和回调,deferred代表了一个尚未完成的操作的结果,它可以在操作完成时触发相关的回调函数,或者在操作失败时触发错误处理函数)。
finger是Twisted文档的一个经典例子,下面展示如何用muduo来实现最简单的finger服务端(限于篇幅,只实现finger01~finger07,代码位于/examples/twisted/finger):
1.拒绝连接。什么都不做,程序空等:
#include <muduo/net/EventLoop.h>
using namespace muduo;
using namespace muduo::net;
int main()
{
EventLoop loop;
loop.loop();
}
2.接受新连接。在1079端口侦听新连接,接受连接之后什么都不做,程序空等。muduo会自动丢弃收到的数据:
#include <muduo/net/EventLoop.h>
#include <muduo/net/TcpServer.h>
using namespace muduo;
using namespace muduo::net;
int main()
{
EventLoop loop;
TcpServer server(&loop, InetAddress(1079), "Finger");
server.start();
loop.loop();
}
3.主动断开连接。接受新连接之后主动断开。以下省略头文件和namespace:
void onConnection(const TcpConnectionPtr &conn)
{
if (conn->connected())
{
conn->shutdown();
}
}
int main()
{
EventLoop loop;
TcpServer server(&loop, InetAddress(1079), "Finger");
server.setConnectionCallback(onConnection);
server.start();
loop.loop();
}
4.读取用户名,然后断开连接。如果读到一行以\r\n结尾的消息,就断开连接。注意这段代码有安全问题,如果恶意客户端不断发送数据而不换行,会撑爆服务端的内存。另外,Buffer::findCRLF()是线性查找,如果客户端每次发一个字节,服务端的时间复杂度为O(N²),会消耗CPU资源:
void onMessage(const TcpConnectionPtr &conn, Buffer *buf, Timestamp receiveTime)
{
if (buf->findCRLF())
{
conn->shutdown();
}
}
int main()
{
EventLoop loop;
TcpServer server(&loop, InetAddress(1079), "Finger");
server.setMessageCallback(onMessage);
server.start();
loop.loop();
}
5.读取用户名、输出错误信息,然后断开连接。如果读到一行以\r\n结尾的消息,就发送一条出错信息,然后断开连接。安全问题同上:
6.从空的UserMap里查找用户。从一行消息中拿到用户名,在UserMap里查找,然后返回结果。安全问题同上:
typedef std::map<string, string> UserMap;
UserMap users;
string getUser(const string &user)
{
string result = "No such user";
UserMap::iterator it = users.find(user);
if (it != users.end())
{
result = it->second;
}
return result;
}
void onMessage(const TcpConnectionPtr &conn, Buffer *buf, Timestamp receiveTime)
{
const char *crlf = buf->findCRLF();
if (crlf)
{
string user(buf->peek(), crlf);
conn->send(getUser(user) + "\r\n");
buf->retrieveUntil(crlf + 2);
conn->shutdown();
}
}
int main()
{
EventLoop loop;
TcpServer server(&loop, InetAddreess(1079), "Finger");
server.setMessageCallback(onMessage);
server.start();
loop.loop();
}
7.往UserMap里添加一个用户。与6几乎一样:
可以用telnet(1)命令扮演客户端来测试我们的简单finger服务端,在一个命令行窗口运行:
./bin/twisted_finger07
另一个命令行运行:
再试一次:
冒烟测试过关。
作者一开始编写muduo的时候并没有以高性能为首要目标。在2010年8月发布之后,有网友询问其性能与其他常见网络库相比如何,因此才加入了一些性能对比的示例代码。作者很惊奇地发现,在muduo擅长的领域(TCP长连接),其性能不比任何开源网络库差。
性能对比原则:采用对方的性能测试方案,用muduo实现功能相同或类似的程序,然后放到相同的软硬件环境中对比。
这里的测试只是简单地比较了平均值;其实在严肃的性能对比中至少还应该考虑分布和百分位数(percentile)的值。
作者在编写muduo的时候并没有以高并发、高吞吐为主要目标。但出乎作者的意料,ping pong测试(通常用于验证两个或多个系统之间的通信或数据交换是否正常工作,这种测试方法的名称来源于乒乓球比赛,其中一个球被来回击打,类似于信息或数据在系统之间来回传递的方式)表明,muduo的吞吐量比Boost.Asio高15%以上;比libevent2高18%以上,个别情况甚至达到70%。
测试对象:
1.boost 1.40中的asio 1.4.3。
2.asio 1.4.5(http://think-async.com/Asio/Download)。
3.libevent 2.0.6-rc(http://monkey.org/~provos/libevent-2.0.6-rc.tar.gz)。
4.muduo 0.1.1。
测试代码:
1.asio的测试代码取自http://asio.cvs.sourceforge.net/viewvc/asio/asio/src/tests/performance/,未做更改。
2.作者自己编写了libevent2的ping pong测试代码,路径是recipes/pingpong/libevent。由于这个测试代码没有使用多线程,所以只对比muduo和libevent2在单线程下的性能。
3.muduo的测试代码位于examples/pingpong/,代码如gist(gist是GitHub上的一个功能,用于创建和分享代码片段或小型代码片段的在线存储库)所示(http://gist.github.com/564985)。
muduo和asio的优化编译参数均为-O2 -finline-limit=1000(中等程度的代码优化,如函数内联、循环展开、常量传播(一种编译器优化技术,它的目标是在编译阶段识别和替换程序中的变量,将它们替换为其在编译时已知的常量值,这个过程可以大幅度提高程序的执行效率,因为在运行时不需要再进行变量的计算,而是直接使用已知的常量值)和死代码删除;当函数的代码行数小于或等于1000时,编译器会尝试将函数内联)。
测试环境:
1.硬件:DELL 490工作站,双路Intel四核Xeon E5320 CPU,共8核,主频1.86GHz,内存16GiB。
2.软件:操作系统为Ubuntu Linux Server 10.04.1 LTS x86_64,编译器是g++4.4.3。
依据asio性能测试的办法,用ping pong来测试muduo、asio、libevent2在单机上的吞吐量。
简单地说,测试中客户端和服务器都实现echo协议。TCP连接建立时,客户端向服务器发送一些数据,服务器会echo回这些数据,然后客户端再echo回服务器。这些数据就会像乒乓球一样在客户端和服务器之间来回传送,直到有一方断开连接为止。这是用来测试吞吐量的常用办法。注意数据是无格式的,双方都是收到多少数据就反射回去多少数据,并不拆包,这与后面的ZeroMQ(一个开源的、高性能的消息传递库,用于构建分布式和并行计算应用程序。它提供了各种消息传递模式,包括点对点通信、发布-订阅、请求-回复等,以帮助开发者构建高效的分布式系统)延迟测试不同。
作者主要做了两项测试:
1.单线程测试。客户端与服务器运行在同一台机器,均为单线程,测试并发连接数为1/10/100/1000/10000时的吞吐量。
2.多线程测试。并发连接数为100或1000,服务器和客户端的线程数同时设为1/2/3/4。(由于作者家里只有一台8核机器,且服务器和客户端运行在同一台机器上,线程数大于4没有意义)
在所有测试中,ping pong消息的大小均为16KiB。测试用的shell脚本可从http://gist.github.com/564985下载。
在同一台机器测试吞吐量的原因如下:
1.现在的CPU很快,即便是单线程单TCP连接也能把千兆以太网的带宽跑满。如果用两台机器,所有的吞吐量测试结果都将是110MiB/s,失去了对比的意义。(用Python也能跑出同样的吞吐量,或许可以对比哪个库占的CPU少)
2.在同一台机器上测试,可以在CPU资源相同的情况下,单纯对比网络库的效率。也就是说在单线程下,服务端和客户端各占满1个CPU,比较哪个库吞吐量高。
单线程测试的结果见下图,数字越大越好:
以上结果让人大跌眼镜,muduo居然比libevent2快70%!跟踪libevent2的源代码发现,它每次最多从socket读取4096字节的数据(证据在buffer.c的evbuffer_read函数),怪不得吞吐量比muduo小很多。因为这一项测试中,muduo每次读取16384字节,系统调用的性价比较高。
为了公平起见,再测一次,这回两个库都发送4096字节的消息:
测试结果表明muduo的吞吐量平均比libevent2高18%以上。
多线程测试的结果见下图,数字越大越好:
测试结果表明muduo的吞吐量平均比asio高15%以上。
muduo出乎意料地比asio性能优越,作者认为主要得益于其简单地设计和简洁的代码。asio在多线程测试中表现不佳,作者猜测其主要原因是测试代码只使用了一个io_service,如果改用“io_server per CPU”的话,其性能应该有所提高。
由于libevent2每次最多从网络读取4096字节,这大大限制了它的吞吐量。
前面比较了muduo和libevent2的吞吐量,得到的结论是muduo比libevent2快18%。有人会说,libevent2并不是为高吞吐量的应用场景而设计的,这样比较不公平,胜之不武。为了公平起见,这次用libevent2自带的性能测试程序(击鼓传花)来对比muduo和libevent2在高并发情况下的IO事件处理效率。
测试用的软硬件环境与前一小节相同,另外作者还在自己的DELL E6400笔记本电脑上运行了测试,结果也附在后面。
测试的场景是:有1000个人围成一圈,玩击鼓传花的游戏,一开始第一个人手里有花,他把花传给右手边的人,那个人再继续把花传给右手边的人,当花转手100次之后游戏停止,记录从开始到结束的时间。
用程序表达是,有1000个网络连接(socketpair(2)或pipe(2)),数据在这些连接中顺次传递,一开始往第一个连接里写1字节,然后从这个连接的另一头读出这1字节,在写入第2个连接,然后读出来继续写到第3个连接,直到一共写了100次之后停止,记录所用的时间。
以上是只有一个活动连接的场景,我们实际测试的是100个或1000个活动连接(即100朵花或1000朵花,均匀分散在人群手中),而连接总数(即并发数)从100~100000(10万)。注意每个连接是两个文件描述符,为了运行测试,需要调高每个进程能打开的文件数,比如设为256000。
libevent2的测试代码位于test/bench.c。作者修复了2.0.6-rc版里一个小bug。修正后的代码已经提交给libevent2作者,现在下载的最新版本是正确的。
muduo的测试代码位于examples/pingpong/bench.cc。
第一轮,分别用100个活动连接和1000个活动连接,无超时,读写100次,测试一次游戏的总时间(包含初始化)和事件处理的时间(不包含注册event watcher)随连接数(并发数)变化的情况。具体解释见libev的性能测试文档(http://libev.schmorp.de/bench.html),不同之处在于我们不比较timer event的性能,只比较IO event的性能。对每个并发数,程序循环25次,刨去第一次的热身数据,后24次算平均值。测试用的脚本(recipes/pingpong/libevent/run_bench.sh)是libev的作者Marc Lehmann写的,作者略作改用,用于测试muduo和libevent2。
第一轮的结果见下图,请先只看“+”线(实线)和“x”线(粗虚线)。“x”线是libevent2用的时间,“+”线是muduo用的时间。数字越小越好。注意这个图的横坐标是对数的,每一个数量级的取值点为1,2,3,4,5,6,7.5,10:
从两条线的对比可以看出:
1.libevent2在初始化event watcher方面比muduo快20%(左边的两个图)。
2.在事件处理方面(右边的两个图):
(1)在100个活动连接的情况下,当总连接数(并发数)小于1000或大于30000时,二者性能差不多;在总连接数大于1000或小于30000时,libevent2明显领先。
(2)在1000个活动连接的情况下,当并发数小于10000时,libevent2和muduo得分接近;当并发数大于10000时,muduo明显占优。
这里有两个问题值得探讨:
1.为什么muduo花在初始化上的时间比较多?
2.为什么在一些情况下它比libevent2慢很多?
作者仔细分析了其中的原因,并参考了libev的作者Marc Lehmann的观点(http://lists.schmorp.de/pipermail/libev/2010q2/001041.html),结论是:在第一轮初始化时,libevent2和muduo都是用epoll_ctl(fd, EPOLL_CTL_ADD, …)来添加文件描述符的event watcher。不同之处在于,在后面24轮中,muduo使用了epoll_ctl(fd, EPOLL_CTL_MOD, …)来更新已有的event watcher;然而libevent2继续使用epoll_ctl(fd, EPOLL_CTL_ADD, …)来重复添加fd,并忽略返回的错误码EEXIST(File exists)。在这种重复添加的情况下,EPOLL_CTL_ADD将会快速地返回错误,而EPOLL_CTL_MOD会做更多工作,花的时间也更长。于是libevent2捡了个便宜。
为了验证这个结论,作者改动了muduo,让它每次都用EPOLL_CTL_ADD方式初始化和更新event watcher,并忽略返回的错误。
第二轮测试的结果见上图的细虚线,课件改动之后的muduo的初始化性能比libevent2更好,事件处理的耗时也有所降低(作者推测是kernel内部的原因)。
这个改动只是为了验证想法,并没有把它放到muduo最终的代码中去,这或许可以留作日后优化的余地。
同样的测试在双核笔记本上运行了一次,结果见下图(作者笔记本的CPU主频是2.4GHz,高于台式机的1.86GHz,所以用时较少):
结论:在事件处理效率方面,muduo与libevent2总体比较接近,各擅胜场。在并发量特别大的情况下(大于10000),muduo略微占优。
下面对比Nginx 1.0.12和muduo 0.3.1内置的简陋HTTP服务器的长连接性能。其中muduo的HTTP实现和测试代码位于muduo/net/http/。
测试环境:
1.服务端运行HTTP server,8核DELL 490工作站,Xeon E5320 CPU。
2.客户端运行ab(ApacheBench,是Apache HTTP服务器的一个工具,用于进行基准测试和性能测试,特别是针对 HTTP 服务器)和weighttp(开源的轻量级 HTTP 性能测试工具,用于进行 HTTP 服务器的基准测试),4核i5-2500 CPU。
3.网络:普通家用千兆网。
测试方法:为了公平起见,Nginx和muduo都没有访问文件,而是直接返回内存中的数据。毕竟我们想比较的是程序的网络性能,而不是机器的磁盘性能。另外,这里客户机的性能优于服务机,因为我们要给服务端HTTP server施压,试图使其饱和,而不是测试HTTP client的性能。
muduo HTTP服务器的主要代码:
void onRequest(const HttpRequest &req, HttpResponse *resp)
{
if (req.path() == "/")
{
// ...
}
else if (req.path() == "/hello")
{
resp->setStatusCode(HttpResponse::k200OK);
resp->setStatusMessage("OK");
resp->setContentType("text/plain");
resp->addHeader("Server", "Muduo");
resp->setBody("hello, world!\n");
}
else
{
resp->setStatusCode(HttpResponse::k404NotFound);
resp->setStatusMessage("Not Found");
resp->setCloseConnection(true);
}
}
int main(int argc, char *argv[])
{
int numThreads = 0;
if (argc > 1)
{
benchmark = true;
Logger::setLogLevel(Logger::WARN);
numThreads = atoi(argv[1]);
}
EventLoop loop;
HttpServer server(&loop, InetAddress(8000), "dummy");
server.setHttpCallback(onRequest);
server.setThreadNum(numThreads);
server.start();
loop.loop();
}
Nginx使用了张亦春的HTTP echo模块(http://wiki.nginx.org/HttpEchoModule)来实现直接返回数据。配置文件如下:
#user nobody;
# 启动4个工作进程
worker_processes 4;
# 配置Nginx事件模块的部分
events {
# 指定每个工作进程允许的最大并发连接数为10240
worker_connections 10240;
}
# HTTP模块的配置部分,用于配置HTTP服务器的行为
http {
# mime.types文件用于定义文件扩展名与相应的MIME类型之间的映射关系
include mime.types;
# 指定默认的MIME类型,当请求的文件类型未知时将使用application/octet-stream
default_type application/octet-stream;
# 禁用访问日志,可以减少磁盘I/O
access_log off;
# 启用sendfile指令,这允许Nginx在发送文件时使用零拷贝技术,提高文件传输效率
sendfile on;
# 启用TCP_NOPUSH指令,它允许Nginx在响应较小的HTTP请求时尽早发送响应数据,减小延迟
tcp_nopush on;
# 客户端可以在65秒内重新使用相同的连接进行多次请求
keepalive_timeout 65;
# 配置一个HTTP服务器块的部分
server{
# 服务器监听8080端口
listen 8080;
# 指定服务器的域名或IP地址为localhost
server_name localhost;
# 定义一个位置块,用于处理URL路径为"/"的请求
location / {
# 指定服务器的根目录,即请求的文件将从"html"目录中查找
root html;
# 配置服务器默认的索引文件,当客户端请求一个目录(如"http://example.com/")而不指定特定文件时
# 服务器会尝试查找和返回这些列出的索引文件中的第一个存在的文件
index index.html index.htm;
}
# 定义另一个位置块,用于处理URL路径为"/hello"的请求
location /hello {
# 指定默认的MIME类型为text/plain
default_type text/plain;
# 返回一个简单的文本响应,即"hello, world!"
echo "hello, world!";
}
}
}
客户端运行以下命令来获取/hello的内容,服务端返回字符串"hello, world!":
其中,-n选项指定要执行的总请求数;-k选项表示启用HTTP keep-alive,客户端可以在单个TCP连接上发送多个请求,而不必每次都建立新的连接;-r选项表示不等待响应,即忽略响应;-c选项,用于指定并发连接数;Apache Benchmark将发送HTTP请求到10.0.0.9上的端口8080,并请求路径为/hello的资源。
先测试单线程的性能,见下图,横轴是并发连接数,纵轴为每秒完成的HTTP请求响应数目。在测试期间,ab的CPU使用率低于70%,客户端游刃有余:
再对比muduo 4线程和Nginx 4工作进程的性能,见下图。当连接数大于20时,top(1)命令显示ab的CPU使用率达到85%,已经饱和,因此换用weighttp(双线程)作为测试客户端来完成其余测试:
CPU使用率对比(百分比是top(1)命令显示的数值):
1.10000并发连接,4 workers/threads,muduo是4×83%,Nginx是4×75%。
2.1000并发连接,4workers/threads,muduo是4×85%,Nginx是4×78%。
初看起来Nginx的CPU使用率略低,但是实际上二者都已经把CPU资源耗尽了。与CPU benchmark不同,涉及IO的benchmar在满负载下的CPU使用率不会达到100%,因为内核要占用一部分时间处理IO。这里的数值差异说明muduo和Nginx在满负荷的情况下,用户态和内核态的比重略有区别。
测试结果显示muduo多数情况下略快,Nginx和muduo在合适的条件下qps(每秒请求数)都能超过10万。值得说明的是,muduo没有实现完整的HTTP服务器,而只是实现了满足最基本要求的HTTP协议,因此这个测试结果并不是说明muduo比Nginx更适合用作httpd,而是说明muduo在性能方面没有犯低级错误。
下面我们用ZeroMQ自带的延迟和吞吐量测试与muduo做对比,muduo代码位于examples/zeromq/。测试的内容很简单,可以认为是上面ping pong测试的翻版,不同之处在于这里的消息的长度是固定的(上面的不也是固定的16KiB?含义可能是这里以不同的固定消息长度为变量来测试延迟),收到完整的消息再echo回发送方,如此往复。测试结果如下图所示,横轴为消息的长度,纵轴为单程延迟(微秒)。可见在消息长度小于16KiB时,muduo的延迟稳定地低于ZeroMQ:
上图中的GbE表示千兆以太网(Gigabit Ethernet)。
下面以一个Sudoku Solver为例,回顾并发网络程序设计的多种设计方案,并介绍使用muduo网络库编写多线程服务器的两种最常用手法。代码参见examples/sudoku/。
假设有这么一个网络编程任务:写一个求解数独的程序(Sudoku Solver),并把它做成一个网络服务。
Sudoku Solver是作者最喜爱的网络编程例子,它曾经出现在“分布式系统部署、监控与进程管理的几重境界(第九章)”、“muduo Buffer类的设计与使用(第七章)”、“‘多线程服务器的适用场合’,示例与答疑(第三章)”等处(作者用曾经一词,但很多东西还没讲到,可能作者是最后才写的这块内容),它也可以看成是echo服务的一个变种。
写这么一个程序在网络编程方面的难度不高,跟写echo服务差不多(从网络连接读入一个Sudoku题目,算出答案,再发回给客户),挑战在于怎样做才能发挥现在多核硬件的能力?在谈这个问题之前,让我们先写一个基本的单线程版。
协议:一个简单的以\r\n分隔的文本行协议,使用TCP长连接,客户端在不需要服务时主动断开连接。
请求:[id:]<81digits>\r\n
。
响应:[id:]<81digits>\r\n
或者[id:]NoSolution\r\n。
其中[id:]表示可选的id,用于区分先后的请求,以支持Parallel Popelining(并行流水线),响应中会回显请求中的id。Parallel Pipelining的意义见赖勇浩的《以小见大——那些基于Protobuf的五花八门的RPC》(http://blog.csdn.net/lanphaday/archive/2011/04/11/6316099.aspx),或者见作者写的《分布式系统的工程化开发方法》(http://blog.csdn.net/solstice/article/details/5950190)第54页关于out-of-order RPC的介绍。
<81digits>
是Sudoku的棋盘,9×9个数字,从左上角到右下角按行扫描,未知数字以0表示。如果Sudoku有解,那么响应是填满数字的棋盘;如果无解,则返回NoSolution。
例子1:
1.请求:
2.响应:
例子2:
1.请求:
2.响应:
例子3:
1.请求:
2.响应:
基于这个文本协议,我们可以用telnet模拟客户端来测试Sudoku Solver,不需要单独编写Sudoku Client。Sudoku Solver的默认端口号是9981,因为它有9×9=81个格子。
Sudoku的求解算法见《谈谈数独(Sudoku)》(http://blog.csdn.net/solstice/archive/2008/02/15/2096209.aspx)一文,这不是本文的重点。假设我们已经有一个函数能求解Sudoku,它的原型如下:
函数的输入是上文的<81digits>
,输出是<81digits>
或“NoSolution”。这个函数是个pure function,同时也是线程安全的。
有了这个函数,我们以上文中“echo服务的实现”中出现的EchoServer为蓝本,稍加修改就能得到Sudoku Server。这里只列出最关键的onMessage函数,完整代码见examples/sudoku/server_basic.cc。onMessage函数的主要功能是处理协议格式,并调用solveSudoku求解问题。这个函数应该能正确处理TCP分包:
const int kCells = 81; // 81个格子
void onMessage(const TcpConnectionPtr &conn, Buffer *buf, Timestamp)
{
LOG_DEBUG << conn->name();
size_t len = buf->readableBytes();
// 反复读取数据,2为回车换行字符
while (len >= kCells + 2)
{
const char *crlf = buf->findCRLF();
// 如果找到了一条完整的请求
if (crlf)
{
// 取出请求
string request(buf->peek(), crlf);
string id;
// retrieve已读取的数据
buf->retrieveUntil(crlf + 2);
string::iterator colon = find(request.begin(), request.end(), ':');
// 如果找到了id部分
if (colon != request.end())
{
// assign方法用于将一个字符串赋值给std::string对象
id.assign(request.begin(), colon);
request.erase(request.begin(), colon + 1);
}
// 请求的长度合法
// implicit_cast<T>并不是标准库提供的功能,而是通常是用户自定义的一个类型转换方法或函数
if (request.size() == implicit_cast<size_t>(kCells))
{
// 求解数独,然后发回响应
string result = solveSudoku(request);
if (id.empty())
{
conn->send(result + "\r\n");
}
else
{
conn->send(id + ":" + result + "\r\n");
}
}
// 非法请求,断开连接
else
{
conn->send("Bad Request!/r/n");
conn->shutdown();
}
}
// 请求不完整,退出消息处理函数
{
break;
}
}
}
server_basic.cc是一个并发服务器,可以同时服务多个客户连接。但是它是单线程的,无法发挥多核硬件的能力。
Sudoku是一个计算密集型的任务,其瓶颈在CPU。为了让这个单线程server_basic程序充分利用CPU资源,一个简单的办法是在同一台机器上部署多个server_basic进程,让每个进程占用不同的端口,比如在一个台8核机器上部署8个server_basic进程,分别占用9981,9982,…,9988端口。这样做其实是把难题推给了客户端,因为客户端要自己做负载均衡。再想得远一点,在8个server_basic面前部署一个load balancer?似乎小题大做了。
能不能在一个端口上提供服务,且又能发挥多核处理器的计算能力呢?当然可以,办法不止一种。
W. Richard Stevens的《UNIX网络编程(第2版)》第27章“Client-Server Design Alternatives”介绍了十来种当时(20世纪90年代末)流行的编写并发网络程序的方案。[UNP]第3版第30章,内容未变,还是这几种。以下简称 UNP CSDA方案。[UNP]这本书主要讲解阻塞式网络编程,在非阻塞方面着墨不多,仅有一章。正确使用non-blocking IO需要考虑的问题很多,不适宜直接调用Sockets API,而需要一个功能完善的网络库支撑。
随着2000年前后第一次互联网浪潮的兴起,业界对高并发HTTP服务器的强烈需求大大推动了这一领域的研究,目前高性能httpd普遍采用的是单线程Reactor方式。另外一个说法是IBM Lotus(IBM公司开发和推出的一系列协作和办公软件产品的品牌,这些产品旨在支持企业和组织的协作、通信和办公需求)使用TCP长连接协议,而把Lotus服务端移植到Linux的过程中IBM的工程师们大大提高了Linux内核在处理并发连接方面的可伸缩性,因为一个公司可能有上万人同时上线,连接到同一台跑着Lotus Server的Linux服务器。
可伸缩网络编程这个领域其实近十年来没什么新东西,POSA2已经进行了相当全面的总结,另外以下几篇文章也值得参考:
1.http://bulk.fefe.de/scalable-networking.pdf。
2.http://www.kegel.com/c10k.html。
3.http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf。
下表是作者总结的12种常见方案。其中“互通”指的是如果开发chat服务,多个客户连接之间能否方便地交换数据(chat也是附录A中举的三大TCP网络编程案例之一)。对于echo/httpd/Sudoku这类“连接相互独立”的服务程序,这个功能无足轻重,但是对于chat类服务却至关重要。“顺序性”指的是在httpd/Sudoku这类请求响应服务中,如果客户连接顺序发送多个请求,那么计算得到的多个响应是否按相同的顺序发还给客户(这里指的是在自然条件下,不含可以同步):
UNP CSDA方案归入0~5。方案5也是目前用得很多的单线程Reactor方案,muduo对此提供了很好的支持。方案6和方案7其实不是实用的方案,只是作为过渡品。方案8和方案9是我们重点介绍的方案,其实这两个方案已经在第三章“多线程服务器的常用编程模型”中提到过,只不过当时没有用具体的代码示例来说明。
在对比各方案之前,我们先看看基本的micro benchmark(它关注在程序内部的小范围代码,通常是某个函数、操作、算法或数据结构的性能评估)数据(前两项由Thread_bench.cc测得,第三项由BlockingQueue_bench.cc测得,硬件为E5320,内核Linux 2.6.32):
1.fork()+exit():534.7us。
2.pthread_create()+pthread_join():42.5us,其中创建线程用了26.1us。
3.push/pop a blocking queue:11.5us。
4.Sudoku resolve:100us(根据题目难度不同,浮动范围20~200us)。
方案0:这其实不是并发服务器,而是iterative服务器,因为它一次只能服务一个客户。代码见[UNP]中的图1.9,[UNP]以此为对比其他方案的基准点。这个方案不适合长连接,倒是很适合daytime这种write-only短连接服务。以下Python代码展示用方案0实现echo server的大致做法(本章的Python代码均没有考虑错误处理):
import socket
def handle(client_socket, client_address):
while True: # 1
data = client_socket.recv(4096)
if data:
send = client_socket.send(data) # 5 sendall?
else:
print "disconnect", client_address
client_socket.close()
break # 2
if __name__ == "__name__":
listen_address = ("0.0.0.0", 2007)
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(listen_address)
server_socket.listen(5)
while True: # 3
(client_socket, client_address) = server_socket.accept()
print "got connection from", client_address
handle(client_socket, client_address) # 4
以上代码中注释1到2之间是echo服务的“业务逻辑循环”,从注释3到4可以看出它一次只能服务一个客户连接。后面列举的方案都是在保持这个循环的功能不变的情况下,设法能高效地同时服务多个客户端。注释5所在行的代码值得商榷,或许应该用sendall函数,以确保完整地发回数据(send函数不能保证全部数据都会一次性发送;sendall函数会持续发送数据,直到所有数据都已成功发送为止,或者发生错误,它会自动处理数据发送的循环,以确保全部数据都被发送)。
方案1:这是传统Unix并发网络编程方案,[UNP]称之为child-per-client或fork()-per-client,另外也俗称process-per-connection。这种方案适合并发连接数不大的情况。至今仍有一些网络服务程序用这种方式实现,比如PostgreSQL和Perforce(Perforce是一个广泛用于软件开发的版本控制和协作工具,它允许开发团队协同工作、管理和跟踪代码更改,并提供了对源代码的版本控制功能)的服务端。这种方案适合“计算响应的工作量远大于fork函数的开销”这种情况,比如数据库服务器。这种方案适合长连接,但不太适合短连接,因为fork函数开销大于求解Sudoku的用时。
Python示例如下:
#!/usr/bin/python
from SocketServer import BaseRequestHandler, TcpServer
from SocketServer import ForkingTCPServer, ThreadingTCPServer
# 继承自BaseRequestHandler类
class EchoHandler(BaseRequestHandler):
# 重写BaseRequestHandler基类的handle方法,以定义如何处理客户端请求
def handle(self):
print "got connection from", self.client_address
while True: # 1
data = self.request.recv(4096)
if data:
sent = self.request.send(data) # sendall?
else:
print "disconnect", self.client_address
self.request.close()
break # 2
if __name__ == "__main__":
listen_address = ("0.0.0.0", 2007)
server = ForkingTCPServer(listen_address, EchoHandler)
server.serve_forever()
注意,注释1到2正式前面的业务逻辑循环,self.request代替了前面的client_socket(实际上,self.request就是client_socket,它们都是从server_socket.accept函数返回的)。ForkingTCPServer会对每个客户连接新建一个子进程,在子进程中调用EchoHandler.handle,从而同时服务多个客户端。在这种编程方式中,业务逻辑已经初步从网络框架分离出来,但是仍然和IO紧密结合。
方案2:这是传统的Java网络编程方案thread-per-connection,在Java 1.4引入NIO之前,Java网络服务多采用这种方案。它的初始化开销比方案1小很多,但与求解Sudoku的用时差不多,仍然不适合短连接服务。这种方案的伸缩性受到线程数的限制,一两百个还行,几千个的话对操作系统的scheduler恐怕是个不小的负担。
Python示例如下,只改动了一行代码。ThreadingTCPServer会对每个客户连接新建一个线程,在该线程中调用EchoHandler.handle:
这里再次体现了将“并发策略”与业务逻辑(EchoHandler.handle())分离的思路。用同样的思路重写方案0的代码,可得到:
方案3:这是针对方案1的优化,[UNP]详细分析了几种变化,包括对accept(2)“惊群”问题(thundering herd)的考虑。
方案4:这是对方案2的优化,[UNP]详细分析了它的几种变化。方案3和方案4都是Apache httpd长期使用的方案。
以上几种方案都是阻塞式网络编程,程序流程(thread of control)通常阻塞在read函数上,等待数据到达。但是TCP是个全双工协议,同时支持read和write操作,当一个线程/进程阻塞在read函数上,但程序又想给这个TCP连接发数据,那该怎么办?比如说echo client,既要从stdin读,又要从网络读,当程序正在阻塞地读网络的时候,如何处理键盘输入?
又比如proxy,既要把连接a收到的数据发给连接b,又要把从b收到的数据发给a,那么到底读哪个?(proxy是附录A讲的三大TCP网络编程案例之一)
一种方法是用两个线程/进程,一个负责读,一个负责写。[UNP]也在实现echo client时介绍了这种方案。第七章举了一个Python双线程TCP relay(TCP中继,它接收来自一个节点(通常是客户端)的数据,然后将数据转发到另一个节点(通常是服务器),同时也将来自服务器的响应数据返回给客户端)的例子,另外见Python Pinhole(将一个端口的流量转发到指定的主机,还可以使用可选的新端口参数将流量重定向到目标主机上的不同端口)的代码:http://code.activestate.com/recipes/114642/。
另一种方法是使用IO multiplexing,也就是select/poll/epoll/kqueue这一系列的“多路选择器”,让一个thread of control能处理多个连接。“IO复用”其实服用的不是IO连接,而是复用线程。使用select/poll几乎肯定要配合non-blocking IO,而使用non-blocking IO肯定要使用应用层buffer,原因见第七章。这就不是一件轻松的事儿了,如果每个程序都去搞一套自己的IO multiplexing机制(本质是event-driven事件驱动),这是一种很大的浪费。感谢Doug Schmidt为我们总结出了Reactor模式,让event-driven网络编程有章可循。继而出现了一些通用的Reactor框架/库,比如libevent、muduo、Netty、twisted、POE(Perl Object Environment,是一个用于编写事件驱动的网络和多任务应用程序的Perl框架)等。有了这些库,基本不用去编写阻塞式的网络程序了(特殊情况除外,如proxy流量控制)。
这里先用一小段Python代码简要回顾“以IO multiplexing方式实现并发echo server”的基本做法。为了简单起见,以下代码没有开启non-blocking,也没有考虑数据发送不完整的情况(注释1所在行):
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('', 2007))
server_socket.listen(5)
# server_socket.setblocking(0)
poll = select.poll() # epoll() should work the same
poll.register(server_socket.fileno(), select.POLLIN)
connections = {} # 2
while True: # 3
events = poll.poll(10000) # 5 10 seconds
for fileno, event in events: # 6
if fileno == server_socket.fileno(): # 7
(client_socket, client_address) = server_socket.accept()
print "got connection from", client_address
# client_socket.setblocking(0)
poll.register(client_socket.fileno(), select.POLLIN)
connections[client_socket.fileno()] = client_socket # 8
elif event & select.POLLIN: # 9
client_socket = connections[fileno]
data = client_socket.recv(4096)
if data:
client_socket.send(data) # 1 sendall() partial?
else:
poll.unregister(fileno)
client_socket.close()
del connections[fileno] # 4
以上代码首先定义一个从文件描述符到socket对象的映射(注释2所在行),程序的主体是一个事件循环(注释3到4),每当有IO事件发生时,就针对不同的文件描述符(fileno)执行不同的操作(注释5到6)。对于listening fd,接受(accept)新连接,并注册到IO事件关注列表(watch list),然后把连接添加到connection字典中(注释7到8)。对于客户连接,则读取并回显数据,并处理连接的关闭(注释9到4)。对于echo服务而言,真正的业务逻辑只有注释1所在行:将收到的数据原样发回客户端。
注意以上代码不是功能完善的IO multiplexing范本,它没有考虑错误处理,也没有实现定时功能,而且只适合侦听(listen)一个端口的网络服务程序。如果需要侦听多个端口,或者要同时扮演客户端,那么代码的结构需要推倒重来。
以上代码骨架可用于实现多种TCP服务器。例如写一个聊天服务只需改动3行代码,如下所示:
但是这种把业务逻辑隐藏在一个大循环中的做法其实不利于将来功能的扩展,我们能否设法把业务逻辑抽取出来,与网络基础代码分离呢?
Doug Schmidt指出,其实网络编程中有很多事务性(routine)的工作,可以提取为公用的框架或库,而用户只需要填上关键的业务逻辑代码,并将回调注册到框架中,就可以实现完整的网络服务,这正是Reactor模式的主要思想。
如果用传统Windows GUI消息循环(Windows操作系统中用于处理图形用户界面(GUI,Graphical User Interface)应用程序事件和消息的核心概念,它是一个无限循环,负责监听和分发各种消息和事件,例如鼠标点击、键盘输入、窗口尺寸变化、鼠标移动等)来做一个类比,那么我们前面展示IO multiplexing的做法相当于把程序的全部逻辑都放到了窗口过程(WndProc,窗口回调函数,当操作系统或用户与窗口进行交互时,窗口过程被调用以响应这些事件和消息)的一个巨大的switch-case语句中,这种做法无疑是不利于扩展的(各种GUI框架在此各显神通):
// hwnd参数是与窗口过程相关联的窗口句柄,即哪个窗口在调用窗口过程
// message参数是一个无符号整数,表示传递给窗口过程的消息类型
// wParam和lParam参数是消息的附加参数,具体的含义取决于消息类型,它们可以包含有关消息的额外信息
// 如鼠标坐标、按键状态等
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
// 接收到窗口销毁消息
case WM_DESTORY:
// PostQuitMessage是Windows API,用于向消息队列中发送一个退出消息,通知应用程序退出
PostQuitMessage(0);
return 0;
// many more cases
}
// DefWindowProc是Windows API,用于处理窗口消息的默认行为
// 当窗口过程没有处理某个特定的窗口消息时,通常会调用DefWindowProc函数来执行消息的默认处理逻辑
// 以确保窗口的基本行为和外观
return DefWindowProc(hwnd, message, wParam, lParam);
}
而Reactor的意义在于将消息(IO事件)分发到用户提供的处理函数,并保持网络部分的通用代码不变,独立于用户的业务逻辑。
单线程Reactor的程序执行顺序如下图中左图所示。在没有事件的时候,线程等待在select/poll/epoll_wait等函数上。事件到达后由网络库处理IO,再把消息通知(回调)客户端代码。Reactor事件循环所在的线程通常叫IO线程。通常由网络库负责读写socket,用户代码负责解码、计算、编码:
注意由于只有一个线程,因此事件是顺序处理的,一个线程同时只能做一件事情。在这种协作式多任务中,事件的优先级得不到保证,因为从“poll返回之后”到“下一次调用poll进入等待之前”这段时间内,线程不会被其他连接上的数据或事件抢占(见上图中的右图)。如果我们想要延迟计算(把compute函数推迟100ms),那么也不能用sleep之类的阻塞调用,而应该注册超时回调,以避免阻塞当前IO线程。
方案5:基本的单线程Reactor方案(图6-11),即前面的server_basic.cc程序。我们以它作为对比其他方案的基准点。这种方案的优点是由网络库搞定数据收发,程序只关心业务逻辑;缺点前面已经谈了:适合IO密集的应用,不太适合CPU密集的应用,因此较难发挥多核的威力。另外,与方案2相比,本方案处理网络消息的延迟可能要略大一些,因为方案2直接一次read(2)系统调用就能拿到请求数据,而本方案要先poll(2)在read(2),多了一次系统调用。
这里用一小段Python代码展示Reactor模式的雏形。为了节省篇幅,这里直接使用了全局变量,也没有处理异常:
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_socket.bind(('', 2007))
server_socket.listen(5)
# serversocket.setblocking(0)
poll = select.poll() # epoll() should work the same
connections = {}
handlers = {}
def handle_input(socket, data):
socket.send(data) # sendall() partial?
def handle_request(fileno, event):
if event & select.POLLIN:
client_socket = connections[fileno]
data = client_socket.recv(4096)
if data:
handle_input(client_socket, data)
else:
poll.unregister(fileno)
client_socket.close()
del connections[fileno]
del handlers[fileno]
def handle_accept(fileno, event):
(client_socket, client_address) = server_socket.accept()
print "got connection from", client_address
# client_socket.setblocking(0)
poll.register(client_socket.fileno(), select.POLLIN)
connections[client_socket.fileno()] = client_socket
handlers[client_socket.fileno()] = handle_request
poll.register(server_socket.fileno(), select.POLLIN)
handlers[server_socket.fileno()] = handle_accept
while True: # 1
events = poll.poll(10000) # 10 seconds
for fileno, event in events:
handler = handlers[fileno]
handler(fileno, event) # 2
以上代码中,程序的核心依然是事件循环(注释1到注释2),与前面不同的是,时间的处理通过handlers转发到各个函数中,不在集中在一坨。例如listening fd的处理函数是handle_accept,它会注册客户连接的handler。普通客户连接的处理函数是handle_request,其中又把连接断开和数据到达这两个事件分开,后者由handle_input函数处理。业务逻辑位于单独的handle_input函数,实现了分离。
如果要改成聊天服务,重新定义handle_input即可,程序的其余部分保持不变:
上图中,diff命令的-U1选项的含义是以合并的方式(合并的方式指的是相同的行前面没有字符,而差异项前面显示-
或+
)显示差异,且指定在生成差异报告时要显示的上下文行数。对于默认的diff命令输出格式,如果我们有以下两个不同文件:
默认的diff命令输出格式为:
如上图,第一个不同的地方为前两行,第二个不同的地方为第五行。如果使用-U选项,则后面必须要跟差异部分前后的行数。
必须说明的是,完善的非阻塞IO网络库远比上面的玩具代码复杂,需要考虑各种错误场景。特别是要真正接管数据的收发,而不是像上面的示例那样直接在事件处理回调函数中发送网络数据。
注意在使用非阻塞IO+事件驱动方式编程的时候,一定要注意避免在事件回调中执行非常耗时的操作,包括阻塞IO等,否则会影响程序的响应。这和Windows GUI消息循环非常类似。
方案6:这是一个过渡方案,收到Sudoku请求之后,不在Reactor线程计算,而是创建一个新线程去计算,以充分利用多核CPU。这是非常初级的多线程应用,因为它为每个请求(而不是每个连接)创建了一个新线程。这个开销可以用线程池来避免,即方案8。这个方案还有一个特点是out-of-order,即同时创建多个线程去计算同一个连接上收到的多个请求,那么算出结果的次序是不确定的,可能第2个Sudoku比较简单,比第1个先算出结果。这也是我们在一开始设计协议的时候使用了id的原因,以便客户端区分response对应的是哪个request。
方案7:为了让返回结果的顺序确定,我们可以为每个连接创建一个计算线程,每个连接上的请求固定发给同一个线程去算,先到先得。这也是一个过渡方案,因为并发连接数受限于线程数目,这个方案或许还不如直接使用阻塞IO的thread-per-connection方案2(方案2中,有几个连接就有几个线程)。
方案7与方案6的另外一个区别是单个client的最大CPU占用率。在方案6中,一个TCP连接上发来的一长串突发请求(burst requests)可以占满全部8个core;而在方案7中,由于每个连接上的请求固定由同一个线程处理,那么它最多占用12.5%的CPU资源。这两种方案各有优劣,取决于应用场景的需要(到底是公平性重要还是突发性能重要)。这个区别在方案8和方案9中同样存在,需要根据应用来取舍。
方案8:为了弥补方案6中为每个请求创建线程的缺陷,我们使用固定大小线程池,程序结构如下图所示:
全部的IO工作都在一个Reactor线程完成,而计算任务交给thread pool。如果计算任务彼此独立,而且IO的压力不大,那么这种方案是非常适用的。Sudoku Solver正好符合。代码参见:examples/sudoku/server_threadpool.cc。
方案8使用线程池的代码与单线程Reactor的方案5相比变化不大,只是把原来onMessage函数中涉及计算和发回响应的部分抽出来做成一个函数,然后交给ThreadPool去计算。方案8有乱序返回的可能,客户端要根据id来匹配响应:
如上图,diff命令的-u选项与-U选项一样,也是以合并的方式显示差异,但-u后面不能指定差异部分上下文的行数,而是默认只显示3行。
线程池的另外一个作用是执行阻塞操作。比如有的数据库的客户端只提供同步访问,那么可以把数据查询放到线程池中,可以避免阻塞IO线程,不会影响其他客户连接,就像Java Servlet 2.x的做法一样。另外也可以用线程池来调用一些阻塞的IO函数,例如fsync(2)(将指定文件的数据和元数据(即文件修改时间、所有权和权限)从内存缓冲区刷新到磁盘上的存储设备中)/fdatasync(2)(datasync函数只刷新文件的数据,而不包括文件的元数据)函数,这两个函数没有非阻塞的版本。
如果IO的压力比较大,一个Reactor处理不过来,可以试试方案9,它采用多个Reactor来分担负载。
方案9:这是muduo内置的多线程方案,也是Netty内置的多线程方案。这种方案的特点是one loop per thread,有一个main Reactor负责accept(2)连接,然后把连接挂在某个sub Reactor中(muduo采用round-robin的方式来选择sub Reactor),这样该连接的所有操作都在那个sub Reactor所处的线程中完成。多个连接可能被分派到多个线程中,以充分利用CPU。
muduo采用的是固定大小的Reactor pool,池子的大小通常根据CPU数目确定,也就是说线程数是固定的,这样程序的总体处理能力不会随连接数增加而下降。另外,由于一个连接完全由一个线程管理,那么请求的顺序性有保证,突发请求也不会占满全部8个核(如果需要优化突发请求,可以考虑方案11)。这种方案把IO分派给多个线程,防止出现一个Reactor的处理能力饱和。
与方案8的线程池相比,方案9减少了进出thread poll的两次向下文切换,在把多个连接分散到多个Reactor线程之后,小规模计算可以在当前IO线程完成并发回结果,从而降低响应的延迟。作者认为这是一个适应性很强的多线程IO模型,因此将它作为muduo的默认线程模型:
方案9代码见:examples/sudoku/server_multiloop.cc。它与server_basic.cc的区别很小,最关键的只有一行代码:server_.setThreadNum(numThreads):
方案10:这是Nginx的内置方案。如果连接之间无交互,这种方案也是很好的选择。工作进程之间相互独立,可以热升级。
方案11:把方案8和方案9混合,既使用多个Reactor来处理IO,又使用线程池来处理计算。这种方案适合既有突发IO(利用多线程处理多个连接上的IO),又有突发计算的应用(利用线程池把一个连接上的计算任务分配给多个线程去做):
这种方案看起来复杂,其实写起来很简单,只要把方案8的代码加一行server_.setThreadNum(numThreads);
就行。
一个程序到底是使用一个event loop还是使用多个event loops呢?ZeroMQ的手册给出的建议是,按照每千兆比特每秒的吞吐量配一个event loop的比例来设置event loop的数目,即muduo::TcpServer::setThreadNum()的参数。依据这条经验规则,在编写运行于千兆以太网上的网络程序时,用一个event loop就足以应付网络IO。如果程序本身没有多少计算量,而主要瓶颈在网络带宽,那么可以按这条规则来办,只用一个event loop。另一方面,如果程序的IO带宽较小,计算量较大,而且对延迟不敏感,那么可以把计算放到thread pool中,也可以只用一个event loop。
值得指出的是,以上假定了TCP连接是同质的,没有优先级之分,我们看重的是服务程序的总吞吐量。但是如果TCP连接有优先级之分,那么单个event loop可能不适合,正确做法是把高优先级的连接单独用event loop来处理。
在muduo中,属于同一个event loop的连接之间没有事件优先级的差别。作者这么设计的原因是为了防止优先级反转。比方说一个服务程序有10个心跳连接,有10个数据请求连接,都归属同一个event loop,我们认为心跳连接有较高的优先级,心跳连接上的事件应该优先处理。但是由于事件循环的特性,如果数据请求连接上的数据先于心跳连接到达(早到1ms),那么这个event loop就会调用相应的event handler去处理数据请求,而在下一次epoll_wait()的时候再来处理心跳事件。因此在同一个event loop中区分连接的优先级并不能达到预想的效果。我们应该用单独的event loop来管理心跳连接,这样就能避免数据连接上的事件阻塞了心跳事件,因为它们分属不同的线程。
作者在第三章曾写到:总结起来,我推荐的C++多线程服务端编程模式为:one loop per thread + thread pool:
1.event loop用作non-blocking IO和定时器。
2.thread pool用来做计算,具体可以是任务队列或生产者消费者队列。
当时(2010年2月)写这篇博客时作者还说:“以这种方式写服务器程序,需要一个优质的基于Reactor模式的网络库来支撑,我只用过in-house的产品,无从比较并推荐市面上常见的C++网络库,抱歉”。(in-house的产品是由一个公司或组织内部团队开发和制造的产品,而不是外部供应商或承包商制造的产品。这些产品通常是为了满足公司的内部需求而开发的,例如内部办公软件、工具和设备等)
现在有了muduo网络库,作者终于能够用具体的代码示例把自己的思想完整地表达出来了。归纳一下,实用的方案有5种,muduo直接支持后4种:
上表中N表示并发连接数目,C
1
_1
1和C
2
_2
2是与连接数无关、与CPU数目有关的常数。
作者再用银行柜台办理业务为比喻,简述各种模型的特点。银行有旋转门,办理业务的客户人员从旋转门进入(IO);银行也有柜台,客户在柜台办理业务(计算)。要想办理业务,客户要先通过旋转门进入银行;办理完之后,客户要再次通过旋转门离开银行。一个客户可以办理多次业务,每次都必须从旋转门进出(TCP长连接)。另外,一个旋转门一次只允许一个客户通过(无论进出),因为read()/write()只能同时调用其中一个。
方案5:这间小银行有一个旋转门、一个柜台、每次只允许一名客户办理业务。而且当有人在办理业务时,旋转门是锁住的(计算和IO在同一线程)。为了维持工作效率,银行要求客户应该尽快办理业务,最好不要在取款的时候打电话去问家里人密码,也不要在通过旋转门的时候停下系鞋带,这都会阻塞其他堵在门外的客户。如果客户很少,这是很经济且高效的方案;但是如果场地较大(多核),则这种布局就浪费了不少资源,只能并发(concurrent)不能并行(parallel)。如果确实一次办不完,应该离开柜台,到门外等着,等银行通知再来继续办理(分阶段回调)。
方案8:这间银行有1个旋转门、一个或多个柜台。银行进门后有一个队列,客户在这里排队到柜台(线程池)办理业务。即在单线程Reactor后面接了一个线程池用于计算,可以利用多核。旋转门基本是不锁的,随时都可以进出。但是排队会消耗一点时间,相比之下,方案5中客户一进门就能立刻办理业务。另外一种做法是线程池里的每个线程都有自己的任务队列,而不是整个线程池共用一个任务队列。这样的好处是避免全局队列的锁争用,坏处是计算资源有可能分配不平均,降低并行度。
方案9:这间大银行相当于包含方案5中的多家小银行,每个客户进大门的时候就被固定分配到某一间小银行中,他的业务只能由这间小银行办理,他每次都要进出小银行的旋转门。但总体来看,大银行可以同时服务多个客户。这时同样要求办理业务时不能空等(阻塞),否则会影响分到同一间小银行的其他客户。而且必要的时候可以为VIP客户单独开一间或几间小银行,优先办理VIP业务。这跟方案5不同,方案5中,当普通客户在办理业务的时候,VIP客户也只能在门外等着。这是一种适应性很强的方案,也是muduo原生的多线程IO模型。
方案11:这间大银行有多个旋转门,多个柜台。旋转门和柜台之间没有一一对应关系,客户进大门的时候就被固定分配到某一旋转门中(奇怪的安排,易于实现线程安全的IO,见第四章),进入旋转门之后,有一个队列,客户在此排队到柜台办理业务。这种方案的资源利用率可能比方案9更高,一个客户不会被同一小银行的其他客户阻塞,但延迟也比方案9略大。