文章来源:http://www.cnblogs.com/Hybird3D/archive/2012/02/02/2335000.html
前段时间接手了一个网络游戏前端连接服务器的开发工作,由于服务器需要在windows平台上部署,并且需要处理大量的客户端连接,因此采用IOCP来做为服务器端的编程模型就成了不二选择。虽然我对服务器开发并不陌生,但我一直以来对IOCP抱着不屑一顾的态度,感觉这个编程模型太过复杂,并不是一个良好的系统设计,所以一直没有用过。这回重新拿起来研究了一下,经过一个多月的研究和开发,目前服务器已经基本完成,即将着手进行压力测试。在研究的过程中,从网络上搜索了IOCP的相关文章和教程不下几十篇,开源的IOCP服务器框架也看了好几个,但都差强人意,在一些教程和文章中也存在诸多误导和不足,所以写下这组文章来谈一下我自己的开发心得。
第一篇主要谈一些原理
在很多编程论坛里经常会看到有人讨论如何开发高性能服务器的问题,但是初学者往往会把精力纠结到API的使用上,错误的认为使用了一些高级的API就意味着高性能,属于只见树木不见森林。以下是我认为高性能服务器设计应该遵循的一些基本原则:
在不同应用场合中的服务器对性能的需求是不一样的,有些需要处理大量的并发连接,有些追求高实时性(低延迟),有些则追求高吞吐量,有些要求大量的IO操作而有些则需要大量的CPU计算。所谓的高性能服务器设计就在于针对具体的性能要求给出专门的设计方案,而通用的适用于普遍场合的服务器设计那就不叫高性能了,因此在设计你的服务器之前搞清楚你的性能设计目标是非常重要的,这将指导你做出正确的选择。
服务器的资源包括:网络带宽、包吞吐量、CPU资源、内存资源等等。在任何时候服务器的资源都是有限的,制约性能的唯一因素就在于资源的瓶颈,而要把性能最大的发挥出来就需要找出资源的瓶颈,并进行合理的分配和优化。这里举一个简单的例子:对于TCP连接来说,虽然它是抽象为数据流协议的,但是在底层实现上是依赖于IP数据报协议,因此在估算服务器能处理的最大字节吞吐量的时候就不能简单的以网络带宽数据来估算,而是要根据IP包吞吐量 * 每TCP包大小来进行估算,在实际中还涉及到RTT(平均延迟时间)及TCP滑动窗口大小,nagle算法的采用等等因素,如果你每次TCP包的发送大小只有几十字节的话,那么是远远达不到实际的理论带宽的,如果你的服务器是以字节吞吐量为设计目标的话,那么就需要想办法增加每个TCP包的发送大小。
所谓高性能是节省出来的,这是一句真理。几乎所有的程序员都是理性的,没有人会去刻意或者毫无道理的浪费系统资源。但往往我们会在不知不觉中浪费系统资源,这主要源于我们的无知。由于编程语言、接口、库及框架将底层的细节抽象了,所以当我们只停留在这些抽象层次上,就很难认识到抽象背后隐藏的东西,在不知不觉中浪费了系统资源。具体来说,每一个系统API的调用在程序上看只是一句函数调用而已,但是每个API背后的开销则是大不相同的,先来看一个简单的例子:
在一个TCP数据包的构造中通常我们需要先发送一个头(里面可能只是简单的标识一下这个包的长度),然后再发送实际的内容,见代码:
这样看上去虽然只是2个简单的不起眼的API调用,但实际上却会造成很大的开销,send本身是一个昂贵的系统级调用,需要占用大量的CPU时间(send的调用需要几到几十个us),同时第一send可能会导致底层构造并发送一个只带有4字节内容的TCP包,而一个TCP头就需要40字节,这严重的降低了网络利用率。所以,如果我们把这两段数据拷贝到一个数据缓冲区并调用一次send发送的话,性能就会大大提升,这个例子同时暗示我们:如果有机会可以合并更多小数据包并一次性调用send操作的话,那么性能将会有很大的提升。
其他方面的例子也很多,例如线程切换的开销,cache missing的开销,cache一致性的开销,锁的开销等等。避免浪费听上去是一句简单的废话,但实际上告诉我们的是需要深入的了解抽象背后的细节。
通讯的延迟和吞吐量往往是矛盾的,我们可以通过一个简单的类比来解释这个道理:考虑一个邮差从A点送信到B点,假设用户每隔2分钟向A点的邮箱中投递一封邮件,邮差从A点的邮箱中取出信件后赶往B地,路上需要10分钟时间,然后将信件放到B的分发点后返回A点,忽略邮差取信和发信所消耗的时间,如此循环往复。 在这个例子里,用户的邮件送达B点的延迟最坏需要20分钟最好则需要10分钟,邮差一个来回需要20分钟平均可以送10封信,因此邮差一个来回的开销可以达到的吞吐量为10。接着,我们改变一下条件:让邮差在返回A点后等待10分钟后再向B出发,于是邮件送达B点的延迟变为最坏30分钟最好10分钟,现在邮差一个来回可以送15封信,吞吐量变大了。在网络通讯中,数据包的构造、传送和接收是一个有很大开销的操作,只有尽可能多的在一次传输中传送更多的数据才能提高吞吐量,在实际的测试中一次TCP发送的数据量至少需要超过1KB,才能接近理论数据吞吐量。但在实际中,一次用户数据的发送量往往很小(这取决于应用的类型),如果人为的加上一定等待和缓冲,就可以达到以时间换空间的效果。
“稳定压倒一切”,对于服务器来说是一句至理名言。服务器的资源是有限的,所能承载的最大负载必然是有限的。正如前段时间杯具的12306铁路网络售票系统,想必很多人都深有体会(可惜我从来没有体会过春运)。在服务器超负荷运行中最杯具的就是称之为"雪崩效应"的一类问题,当负载达到一个临界点后服务器性能急转直下,使得正常的服务也无法进行甚至直接宕机。因此,作为一个有职业素养的服务器端程序员(非临时工和无证程序员),在设计中必须对各种最坏情况要有预计,并通过前期设计及后期的压力测试来确定服务器所能达到的满负载指标,对超负载情况要有保护措施(拒绝服务新的连接以保证服务器的安全),当然在实际的运营中还需要为服务器保留一定的安全边界以防止各种颠簸酿成杯具。
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也不算那么糟糕。
首先介绍一下这个服务器的技术背景。在分布式网络游戏服务器中,前端连接服务器是一种很常见的设计。他的职责主要有:
1. 为客户端和后端的游戏逻辑服务器提供一个软件路由 —— 客户端一旦和前端服务器建立TCP连接以后就可以通过这个连接和后端的游戏服务器进行通讯,而不再需要和后端的服务器再建立新的连接。
2. 承担来自客户端的IO压力 —— 一组典型的网络游戏服务器需要服务少则几千多则上万(休闲游戏则可以多达几十万)的游戏客户端,这个IO处理的负载相当可观,由一组前端服务器承载这个IO负担可以有效的减轻后端服务器的IO负担,并且让后端服务器也只需要关心游戏逻辑的实现,有效的实现IO和业务逻辑的解耦。
架构如图:
对于网络游戏来说,客户端与服务器之间需要进行频繁的通讯,但是每个数据包的尺寸基本都很小,典型的大小为几个字节到几十个字节不等,同时用户上行的数据量要比下行数据量小的多。不同的游戏类型对延迟的要求不太一样,FPS类的游戏希望延迟要小于50ms,MMO类型的100~400ms,一些休闲类的棋牌游戏1000ms左右的延迟也是可以接受的。因此,网络游戏的通讯是以优化延迟的同时又必须兼顾小包的合并以防止网络拥塞,哪个因素为主则需要根据具体的游戏类型来决定。
技术背景就介绍这些,后面介绍的IOCP连接服务器就是以这些需求为设计目标的。
在动手实现这个连接服务器之前,我首先考察了一些现有的开源IOCP服务器框架库,老牌的如ACE,整个库太多庞大臃肿,代码也显老态,无好感。boost.asio据说是个不错的网络框架也支持IOCP,我编译运行了一下他的例子,然后尝试着阅读了一下asio的代码,感觉非常恐怖,完全弄不清楚内部是怎么实现的,于是放弃。asio秉承了boost一贯的变态作风,将C++的语言技巧凌驾于设计和代码可读性之上,这是我非常反对的。其他一些不入流的IOCP框架也看了一些,真是写的五花八门什么样的实现都有,总体感觉下来IOCP确实不太容易把握和抽象,所以才导致五花八门的实现。最后,还是决定自己重新造轮子。
任何的服务器框架从本质上说都是封装一个事件(Event)消息循环。而应用层只要向框架注册事件处理函数,响应事件并进行处理就可以了。一般的同步IO处理框架是先收到IO事件然后再进行IO操作,这类的事件处理框架我们称之为Reactor。而IOCP的特殊之处在于用户是先发起IO操作,然后接收IO完成的事件,次序和Reactor是相反的,这类的事件处理框架我们称之为Proactor。从词根Re和Pro上,我们也可以容易的理解这两者的差别。除了网络IO事件之外,服务器应该还可以响应Timer事件及用户自定义事件。框架做的事情就是把这些事件统统放到一个消息队列里,然后从队列中取出事件,调用相应的事件处理函数,如此循环往复。
IOCP为我们提供了一个系统级的消息队列(称之为完成队列),事件循环就是围绕着这个完成队列展开的。在发起IO操作后系统会进行异步处理(如果能立刻处理的话也会直接处理掉),当操作完成后自动向这个队列投递一条消息,不管是直接处理还是异步处理,最后总会投递完成消息。
顺便提一下:这里存在一个性能优化的机会:当IO操作能够立刻完成的话,如果让系统不要再投递完成消息,那么就可以减少一次系统调用(这至少可以节省几个微秒的开销),做法是调用SetFileCompletionNotificationModes(handle, FILE_SKIP_COMPLETION_PORT_ON_SUCCESS),具体的可以查阅MSDN。
对于用户自定义事件可以使用Post来投递。对于Timer事件,我的做法则是实现一个TimerHeap的数据结构,然后在消息循环中定期检查这个TimerHeap,对超时的Timer事件进行调度。
IOCP完成队列返回的消息是一个OVERLAPPED结构体和一个ULONG_PTR complete_key。complete_key是在用户将Socket handle关联到IOCP的时候绑定的,其实用性不是很大,而OVERLAPPED结构体则是在用户发起IO操作的时候设置的,并且OVERLAPPED结构可以由用户通过继承的方式来扩展,因此如何用好OVERLAPPED结构在螺丝壳里做道场,就成了封装好IOCP的关键。
这里,我使用了一个C++模板技巧来扩展OVERLAPPED结构,先看代码:
IOCPHandler是用户对象的接口,用户扩展这个接口来实现IO完成事件的处理。然后通过一个OverlappedWrapper<T>的模板类将用户对象和OVERLAPPED结构封装成一个对象,T类型就是用户扩展的对象,由于用户对象位于OVERLAPPED结构体的前面,因此我们会将OVERLAPPED的指针传递给IO操作的API,同时我们在OVERLAPPED结构的后面还放置了一个用户对象的指针,当GetQueuedCompletionStatus接收到OVERLAPPED结构体指针后,我们通过这个指针就可以找到用户对象的位置了,然后调用虚函数Complete或者OnError就可以了。
图解一下对象结构:
在这里利用我们利用了C++的多态来扩展OVERLAPPED结构,在框架层完全不用关心接收到的是什么IO事件,只需要应用层自己关心就够了,同时也避免了使用丑陋的难于扩展的switch..case结构。
对于异步操作来说,最让人痛苦的事情就是需要把原本顺序逻辑的代码强行拆分成多块来回调,这使得代码中原本蕴含的顺序逻辑被打散,并且在各个代码块里的上下文变量无法共享,必须另外生成一个对象放置这些上下文变量,而这又引发一个对象生存期管理的问题,对于没有GC的C++来说尤其痛苦。解决异步逻辑的痛苦之道目前有两种方案:一种是用coroutine(协作式线程)将异步逻辑变成同步逻辑,在Windows上可以使用Fiber来实现coroutine;另一种方案是使用闭包,闭包原本是函数式语言的特性,在C++里并没有,不过幸运的是我们可以通过一个稍微麻烦一点的方法来模拟闭包行为。coroutine在解决异步逻辑方面是最拿手的,特别是一个函数里需要依次进行多个异步操作的时候尤其强大(在这种情况下闭包也相形见拙),但是另一方面coroutine的实现比较复杂,线程的手工调度常常把人绕晕,对于IOCP这种异步操作比较有限的场景有点杀鸡用牛刀的感觉。因此最后我还是决定使用C++来模拟闭包行为。
这里演示一个典型的异步IO用法,看代码:
一个异步发送的例子:void Client::Send(const char* data, int size)
{
const char* buf = AllocSendBuffer(data, size);
struct SendHandler : public IOCPHandler
{
Client* client;
int cookie;
virtual void Destroy(){ delete this; }
virtual void Complete(ULONG_PTR key, DWORD size){
if(!client->CheckAvaliable(cookie))
return;
client->EndSend(size);
}
virtual void OnError(ULONG_PTR key, DWORD error){
if(!client->CheckAvaliable(cookie))
return;
client->OnError(E_SocketError, error);
}
};
OverlappedWrapper<SendHandler>* handler = new OverlappedWrapper<SendHandler>();
handler->cookie = _clientId;
handler->client = this;
int sent = 0;
Error e = _socket.AsyncSend(buf, size, *handler, &sent);
if(e.Check()){
LogError2("SendAsync Failed. %s", FormatAPIError(_socket.CheckError()).c_str());
handler->Destroy();
OnError(E_SocketError, _socket.CheckError());
}
else if(sent == size){
handler->Destroy();
EndSend(size);
}
}
这个例子中,我们在函数内部定义了一个SendHandler对象,模拟出了一个闭包的行为,我们可以把需要用到的上下文变量放置在SendHandler内,当下次回调的时候就可以访问到这些变量了。本例中,我们在SendHandler里记了一个cookie,其作用是当异步操作返回时,可能这个Client对象已经被回收了,这个时候如果再调用EndSend必然会导致错误的结果,因此我们通过cookie来判断这个Client对象是否是那个异步操作发起时的Client对象。
使用闭包虽然没有coroutine那样漂亮的顺序逻辑结构,但是也足够方便你把各个异步回调代码串起来,同时在闭包内共享需要用到的上下文变量。另外,最新版的C++标准对闭包有了原生的支持,实现起来会更方便一些,如果你的编译器足够新的话可以尝试使用新的C++特性。
在绝大多数讲解IOCP的文章中都会建议使用多个工作线程来处理IO事件,并且把工作线程数设置为CPU核心数的2倍。根据我的印象,这种说法的出处来自于微软早期的官方文档。不过,在我看来这完全是一种误导。IOCP的设计初衷就是用尽可能少的线程来处理IO事件,因此使用单线程处理本身是没有问题的,这可以使实现简化很多。反之,用多线程来处理的话,必须处处小心线程安全的问题,同时也会涉及到加锁的问题,而不恰当的加锁反而会使性能急剧下降,甚至不如单线程程序。有些同学可能会认为使用多线程可以发挥多核CPU的优势,但是目前CPU的速度足够用来处理IO事件,一般现代CPU的单个核心要处理一块千兆网卡的IO事件是绰绰有余的,最多的可以同时处理2块网卡的IO事件,瓶颈往往在网卡上。如果是想通过多块网卡提升IO吞吐量的话,我的建议是使用多进程来横向扩展,多进程不但可以在单台物理服务器上进行扩展,并且还可以扩展到多台物理服务器上,其伸缩性要比多线程更强。
当时微软提出的这个建议我想主要是考虑到在IO线程中除了IO处理之外还有业务逻辑需要处理,使用多线程可以解决业务逻辑阻塞的问题。但是将业务逻辑放在IO线程里处理本身不是一种好的设计模式,这没有很好的做到IO和业务解耦,同时也限制了服务器的伸缩性。良好的设计应该将IO和业务解耦,使用多进程或者多线程将业务逻辑放在另外的进程或者线程里进行处理,而IO线程只需要负责最简单的IO处理,并将收到的消息转发到业务逻辑的进程或者线程里处理就可以了。我的前端连接服务器也是遵循了这种设计方法。
IOCP最大的优势就是他的灵活性,关闭socket上的发送缓冲区就是一例。很多人认为关闭发送缓冲的价值是可以减少一次内存拷贝的开销,在我看来这只是捡了一粒芝麻而已。主流的千兆网卡其最大数据吞吐量不过区区120MB/s,而内存数据拷贝的吞吐量是10GB/s以上,多一次120MB/s数据拷贝,仅消耗1%的内存带宽,意义非常有限。
在普通的Socket编程中,我们只有打开nagle算法或者不打开的选择,策略的选择和参数的微调是没有办法做到的。而当我们关闭发送缓冲之后,每次Send操作一定会等到数据发送到对方的协议栈里并且收到ACK确认才会返回完成消息,这就给了我们一个实现自定义的nagle算法的机会。对于网络游戏这种需要频繁发送小数据包,打开nagle算法可以有效的合并发送小数据包以降低网络IO负担,但另一方面也加大了延迟,对游戏性造成不利影响。有了关闭发送缓冲的特性之后,我们就可以自行决定nagle算法的实现细节,在上一个send操作没有结束之前,我们可以决定是立刻发送新的数据(以降低延迟),还是累积数据等待上一个send结束或者超时后再发送。更复杂一点的策略是可以让服务器容忍多个未结束的send操作,当超出一个阈值后再累积数据,使得在IO吞吐量和延迟上达到一个合理的平衡。
前面提到了关闭socket的发送缓冲,那么就涉及到我们自己如何来分配发送缓冲的问题。
一种策略是给每个Socket分配一个固定大小的环形缓冲区。这会存在一个问题:当缓冲区内累积的未发送数据加上新发送的数据大小超出了缓冲区的大小,这个时候就会碰上麻烦,要么阻塞以等待前面的数据发送完毕(但是IO线程不可以阻塞),要么干脆直接把Socket关闭,一个妥协的办法是尽可能把发送缓冲区设置的大一些,但这又会白白浪费很多内存。
另一种策略是让所有的客户端socket共享一个非常大的环形缓冲区,假设我们保留一个1G的内存区域给这个环形缓冲区,每次需要向客户端发送数据时就从这个环形缓冲区分配内存,当缓冲区分配到底了再绕到开头重新分配。由于这个缓冲区非常大,1G的内存对千兆网卡来说至少需要花费10s才能发送完,并且在实际应用中这个时间会远超10s。因此当新的数据从头开始分配的时候,老的数据早已经发送掉了,不用担心将老的数据覆盖,即使碰到网络阻塞,一个数据包超过10s还未发送掉的话,我们也可以通过超时判断主动关闭这个socket。
允许socket重用是IOCP另一个优势,我们可以在server启动时,根据我们对最大服务人数的预计,将所有的socket资源都分配好。一般来说每个socket必需对应一个client对象,用来记录一些客户端的信息,这个对象池也可以和socket绑定并预先分配好。在服务运行前将所有的大块对象的内存资源都预先分配好,用一个FreeList来做对象池的分配,在客户端下线之后再将资源回收到池中。这样就可以避免在服务运行过程中动态的分配大的对象,而一些需要临时分配的小对象(例如OVERLAPPED结构),我们可以使用诸如tcmalloc之类的通用内存分配器来做,tcmalloc内部使用小对象池算法,其分配性能和稳定性非常好,并且他的接口是非侵入式的,我们仍然可以在代码里保留malloc/free及new/delete。很多服务在长期运行之后出现运行效率降低,内存占用过大等问题,都跟频繁的分配和释放内存导致出现大量的内存碎片有关。所以做好服务器的内存分配管理是至关重要的一环。
待续....
下一篇将通过几个压力测试和profiling的例子,来分析服务器的性能和瓶颈所在,请大家关注。