转载
http://www.cnblogs.com/Hybird3D/archive/2012/02/02/2335000.html
前段时间接手了一个网络游戏前端连接服务器的开发工作,由于服务器需要在windows平台上部署,
并且需要处理大量的客户端连接,因此采用IOCP来做为服务器端的编程模型就成了不二选择。
虽然我对服务器开发并不陌生,但我一直以来对IOCP抱着不屑一顾的态度,
感觉这个编程模型太过复杂,并不是一个良好的系统设计,所以一直没有用过。
这回重新拿起来研究了一下,经过一个多月的研究和开发,目前服务器已经基本完成,即将着手进行压力测试。
在研究的过程中,从网络上搜索了IOCP的相关文章和教程不下几十篇,开源的IOCP服务器框架也看了好几个,
但都差强人意,在一些教程和文章中也存在诸多误导和不足,所以写下这组文章来谈一下我自己的开发心得。
第一篇主要谈一些原理
高性能服务器的设计原则
在很多编程论坛里经常会看到有人讨论如何开发高性能服务器的问题,
但是初学者往往会把精力纠结到API的使用上,错误的认为使用了一些高级的API就意味着高性能,属于只见树木不见森林。
以下是我认为高性能服务器设计应该遵循的一些基本原则:
1. 有明确的服务器性能设计目标
在不同应用场合中的服务器对性能的需求是不一样的,
有些需要处理大量的并发连接,有些追求高实时性(低延迟),有些则追求高吞吐量,有些要求大量的IO操作而有些则需要大量的CPU计算。
所谓的高性能服务器设计就在于针对具体的性能要求给出专门的设计方案,
而通用的适用于普遍场合的服务器设计那就不叫高性能了,因此在设计你的服务器之前搞清楚你的性能设计目标是非常重要的,这将指导你做出正确的选择。
2. 合理的估算和分配服务器资源
服务器的资源包括:网络带宽、包吞吐量、CPU资源、内存资源等等。
在任何时候服务器的资源都是有限的,制约性能的唯一因素就在于资源的瓶颈,而要把性能最大的发挥出来就需要找出资源的瓶颈,并进行合理的分配和优化。
这里举一个简单的例子:对于TCP连接来说,虽然它是抽象为数据流协议的,但是在底层实现上是依赖于IP数据报协议,
因此在估算服务器能处理的最大字节吞吐量的时候就不能简单的以网络带宽数据来估算,而是要根据IP包吞吐量 * 每TCP包大小来进行估算,
在实际中还涉及到RTT(平均延迟时间)及TCP滑动窗口大小,nagle算法的采用等等因素,
如果你每次TCP包的发送大小只有几十字节的话,那么是远远达不到实际的理论带宽的,
如果你的服务器是以字节吞吐量为设计目标的话,那么就需要想办法增加每个TCP包的发送大小。
3. 避免不必要的浪费
所谓高性能是节省出来的,这是一句真理。
几乎所有的程序员都是理性的,没有人会去刻意或者毫无道理的浪费系统资源。
但往往我们会在不知不觉中浪费系统资源,这主要源于我们的无知。
由于编程语言、接口、库及框架将底层的细节抽象了,
所以当我们只停留在这些抽象层次上,就很难认识到抽象背后隐藏的东西,在不知不觉中浪费了系统资源。
具体来说,每一个系统API的调用在程序上看只是一句函数调用而已,但是每个API背后的开销则是大不相同的,先来看一个简单的例子:
在一个TCP数据包的构造中通常我们需要先发送一个头(里面可能只是简单的标识一下这个包的长度),然后再发送实际的内容,见代码:
send(socket, &packet_size, 4);
send(socket, packet, packet_size);
这样看上去虽然只是2个简单的不起眼的API调用,但实际上却会造成很大的开销,
send本身是一个昂贵的系统级调用,需要占用大量的CPU时间(send的调用需要几到几十个us),
同时第一send可能会导致底层构造并发送一个只带有4字节内容的TCP包,而一个TCP头就需要40字节,这严重的降低了网络利用率。
所以,如果我们把这两段数据拷贝到一个数据缓冲区并调用一次send发送的话,性能就会大大提升,
这个例子同时暗示我们:
如果有机会可以合并更多小数据包并一次性调用send操作的话,那么性能将会有很大的提升。
其他方面的例子也很多,例如线程切换的开销,cache missing的开销,cache一致性的开销,锁的开销等等。
避免浪费听上去是一句简单的废话,但实际上告诉我们的是需要深入的了解抽象背后的细节。
4. 在延迟和吞吐量上做权衡
通讯的延迟和吞吐量往往是矛盾的,我们可以通过一个简单的类比来解释这个道理:
考虑一个邮差从A点送信到B点,假设用户每隔2分钟向A点的邮箱中投递一封邮件,
邮差从A点的邮箱中取出信件后赶往B地,路上需要10分钟时间,然后将信件放到B的分发点后返回A点,
忽略邮差取信和发信所消耗的时间,如此循环往复。
在这个例子里,用户的邮件送达B点的延迟最坏需要20分钟最好则需要10分钟,邮差一个来回需要20分钟平均可以送10封信,
因此邮差一个来回的开销可以达到的吞吐量为10。
接着,我们改变一下条件:让邮差在返回A点后等待10分钟后再向B出发,于是邮件送达B点的延迟变为最坏30分钟最好10分钟,
现在邮差一个来回可以送15封信,吞吐量变大了。
在网络通讯中,数据包的构造、传送和接收是一个有很大开销的操作,
只有尽可能多的在一次传输中传送更多的数据才能提高吞吐量,在实际的测试中一次TCP发送的数据量至少需要超过1KB,才能接近理论数据吞吐量。
但在实际中,一次用户数据的发送量往往很小(这取决于应用的类型),如果人为的加上一定等待和缓冲,就可以达到以时间换空间的效果。
5. 要为最坏和满负载情况做设计
“稳定压倒一切”,对于服务器来说是一句至理名言。
服务器的资源是有限的,所能承载的最大负载必然是有限的。
正如前段时间杯具的12306铁路网络售票系统,想必很多人都深有体会(可惜我从来没有体会过春运)。
在服务器超负荷运行中最杯具的就是称之为"雪崩效应"的一类问题,
当负载达到一个临界点后服务器性能急转直下,使得正常的服务也无法进行甚至直接宕机。
因此,作为一个有职业素养的服务器端程序员(非临时工和无证程序员),在设计中必须对各种最坏情况要有预计,
并通过前期设计及后期的压力测试来确定服务器所能达到的满负载指标,
对超负载情况要有保护措施(拒绝服务新的连接以保证服务器的安全),
当然在实际的运营中还需要为服务器保留一定的安全边界以防止各种颠簸酿成杯具。
IOCP编程模型的优缺点
IOCP是一种典型的异步IO设计范式,简单的说就是当发起一个IO操作后,
不等待操作结束就立刻返回,IO操作的结果在另外一个队列上得到通知并回调。
异步IO本身并不是一种高级的东西。
相反的,在操作系统底层,所有的IO请求都是异步发起的,然后通过中断处理对结果进行处理,中断是一种在硬件层面上的回调机制。
因为异步IO在编程上相当困难,特别是对于那些不具备高级特性的语言来说。
所以在设计操作系统时,设计师会将这些底层IO的复杂性封装起来,抽象成容易使用的同步IO调用供上层使用。
同步IO的意思是IO操作发起后,调用会等待IO操作结束才返回结果(阻塞模式),
或者当IO不能立刻完成时返回错误(非阻塞模式),同时再提供一种查询机制(Select模式),告诉用户当前的IO可执行状态 ,
通常我们会用Reactor模式将Select封装起来,将用户主动查询变成事件回调(这不影响Select查询的本质)。
而IOCP则将底层的IO复杂性暴露出来,还原出IO异步性的本质,这实际上是一种抽象的倒退,因此IOCP是一种复杂的编程模型。
在连接数少的情况下,Select和IOCP没有明显的性能差异,
一次IO操作都是2次系统调用,Select是先查询再发起IO请求,IOCP是先发起IO请求再接收通知。
但是Select方式在处理大量非活动连接时是比较低效的,因为每次Select需要对所有的Socket状态进行查询,
而对非活动的Socket查询是没有意义的浪费,另外由于Socket句柄不能设置用户私有数据,
当查询返回Socket句柄时还需要一个额外的查询来找到关联的用户对象,这两点是Select低效的关键。
在搞明白低效的原因之后只要接口稍作改进就可以对此进行优化,
Linux下的epoll模型就是对此的一种改进,epoll的改进在于:
1. 不再对Socket状态做查询,而是对Socket事件做查询,避免了无用的Socket状态检查
2. 在事件对象里可以设置用户私有数据,避免了从Socket句柄到用户对象的查询。
这两点改进使得epoll完全克服了Select模式在大量非活动连接时的低效问题,同时保持了同步IO容易编程的优点,将Select改成epoll是非常方便的。
由于IOCP不需要去检查Socket的状态,同样可以解决Select的低效问题,
但代价是程序员不得不面对异步IO的复杂性,使得程序难写难用,这是IOCP不如epoll优雅的地方。
如果windows上有epoll模式的话,那么大部分情况下将会是比IOCP更好的选择。
但是IOCP的这个缺点同时也是他的优点,因为他暴露出了更多的底层细节,让我们有机会做更多的微调和性能优化。
另外一个好消息是,IO处理毕竟不是一个小规模的问题,我的前端服务器只用了大约5000行的C++代码(没有使用第三方库和框架),
而真正涉及到IO的代码应该只占1000行左右,因此只要不厌其烦耐心实现,IOCP也不算那么糟糕。