基于完成端口模型的网络服务器性能优化研究

2017-03-30 09:43杨勇
科教导刊 2016年36期

杨勇

摘 要 完成端口模型(IOCP)在各种网络并发I/O 处理的模型中,是效率最高的。为进一步提高完成端口的执行性能,可以对模型处理流程中的各步骤作进一步优化。连接池技术可以实现SOCKET的重复利用。对象池技术改善完成端口模型对内存资源的利用效率,WSARecv函数采用零字节投递处理重叠I/O,可降低操作系统资源开销。

关键词 IOCP 完成端口 连接池 对象池

中图分类号:TP393.05 文献标识码:A DOI:10.16400/j.cnki.kjdkx.2016.12.016

Abstract The completion port model (IOCP) is the most efficient model for concurrent I/O processing in a variety of network. In order to further improve the performance of the completion port, we can further optimize the steps in the process of model processing. Connection pool technology can be used to achieve the reuse of SOCKET. Object pool technology to improve the completion port model of memory resource utilization efficiency, WSARecv function using zero byte delivery processing overlap I/O, can reduce the operating system resource overhead.

Keywords IOCP; Completion port; connection pool; object pool

0 引言

完成端口(IOCP)對网络服务器管理多个连接套接字具有非常高的效率,有优秀的系统延展性。与普通多线程模型处理并发连接相比较。完成端口的优势在于:其一,普通线程模型对于用户连接是一对一的,一个连接对应一个线程。如果当前在线连接达到千以上,则系统同时运行千个以上的线程,系统运行速度会大幅下降,因为线程创建、退出需要耗费大量系统资源,线程数量太多,线程间切换耗费的CPU时间片也越多。每个线程运行所分到的CPU时间片太少,线程运行速度显著变慢。 针对多线程模型缺陷,线程池模型(Thread Pool)可以减少建立、退出线程的系统资源开销。但是对于并发连接高峰时段, 线程池模型并不能减少并发运行线程数量。完成端口则在线程池模型的基础上做进一步的优化,是目前效率最高,系统资源占用最小的线程池模型。并发线程太多的原因是服务于每一个连接的线程不能快速退出。每个连接在请求和应答过程中,数据传输可能由于网络或者用户操作等原因造成传输延迟,只要数据传输全过程未完成,线程即不能退出。完成端口把接收和回传数据两个步骤分解到多个线程中单独完成,因此每一个线程在系统中持续的时间变短,同时在线的线程数量大幅减少。其二,完成端口对数据处理采用异步模式,数据的接收和发送由系统进行,WSARecv,WSASend 函数调用后立即返回。系统处理数据结束后再发消息通知。因此可以同时响应多个连接的请求 。本文探讨了进一步优化完成端口I/O管理的几种方法。

1 完成端口建立过程

建立基于完成端口的网络服务程序的过程是:(1)创建完成端口对象,调用函数 CreateIoCompletionPort(__in HANDLE FileHandle,__in_opt HANDLE ExistingCompletionPort,__in ULONG_PTR CompletionKey,__in DWORD NumberOfConcurrentThreads);该函数返回完成端口句柄。函数只需设定最后一个参数NumberOfConcurrentThreads的值,指定在完成端口上同时运行的工作线程数量。设为0则表示工作线程数与系统CPU数一样多。(2)建立接收用户连接的主线程,在主线程里,创建连接套接字,并把套接字和已经建立的完成端口绑定,该步骤仍然使用CreateIoCompletionPort函数完成,第一个参数就是绑定的套接字,第二个参数是完成端口句柄,第三个参数是与套接字关联的句柄,通常是一个指针,指向关联对象,关联对象可以存储与套接字有联系的数据。套接字上可以开始调用WSARecv函数投递接收数据请求。(3)创建工作线程。工作线程中调用GetQueuedCompletionStatus 函数从系统通知队列中取出数据接收完成的重叠I/O对象。在工作线程里读取I/O对象关联的数据缓冲区,处理数据完毕后,根据需要,可以调用WSASend 函数回传响应数据,或者调用WSARecv函数投递下一个接收请求。如果数据处理业务逻辑比较复杂或耗时很长,也可以单独置于其他线程中完成,完成后再通知工作线程做下一步处理。

2 完成端口性能优化

2.1 连接池技术(Socket Pool)

Windows系统下SOCKET的创建需要消耗很多资源,耗费相当的cpu时间,对于多连接应用,大量SOCKET的创建会使服务器对客户端的响应延迟。因此我们希望开始时就预先建立好多个SOCKET对象,无需等到客户连接上来时再创建。另一方面,客户对服务器的连接状态变化非常频繁,典型的如web服务器,SOCKET频繁的创建和销毁,降低服务器的性能。我们希望连接断开的SOCKET,不再简单地销毁掉,而是放入一个池中,在需要的时候重用这个SOCKET,减少频繁创建销毁SOCKET对象而带来的性能损失, winsock2库提供了一个新的AcceptEx函数来取代过去的accept函数:

BOOL AcceptEx(

__in SOCKET sListenSocket,

__in SOCKET sAcceptSocket,

__in PVOID lpOutputBuffer,

__in DWORD dwReceiveDataLength,

__in DWORD dwLocalAddressLength,

__in DWORD dwRemoteAddressLength,

__out LPDWORD lpdwBytesReceived,

__in LPOVERLAPPED lpOverlapped

);

AcceptEx功能与accept类似,用于接受连接请求,第一个参数含义与accept一样,为监听SOCKET。我们知道,accept接受连接后,再创建一个SOCKET,作为函数返回值。而AcceptEx 要求提前用 WSASocket函数创建SOCKET ,传递给第二个参数sAcceptSocket,AcceptEx函数并不阻塞等待客户连接到sAcceptSocket,而是立即返回。因此我们可以在一个循环里多次创建SOCKET,并多次调用AcceptEx函数,预先建立好多个SOCKET以等待客户的连接,由于第一个参数监听sListenSocket绑定到完成端口,AcceptEx类似于WSARecv非阻塞的重叠调用。最后一个参數lpOverlapped指定重叠数据结构,一般将sAcceptSocket包含在这个结构中,当连接完成时,完成包由完成端口置入通知队列,再由工作线程处理已经真正建立连接的sAcceptSocket。

当连接断开时,不采用closesocket函数关闭连接,回收资源,而是采用DisconnectEx函数:BOOL DisconnectEx( __in SOCKET hSocket, __in LPOVERLAPPED lpOverlapped, __in DWORD dwFlags, __in DWORD reserved);

该函数回收而不是关闭SOCKET,第二个参数必须取TF_REUSE_SOCKET。之后重新绑定回收的套接字hSocekt到完成端口,然后再次调用AcceptEx 函数,将其放入连接池。

2.2 对象池技术(Object Pool)

重叠I/O模型,每建立一个套接字连接,都要在堆区创建与之关联的重叠I/O对象,作为WSASend或WSARec函数的参数。而当连接关闭时,I/O对象随之被销毁,对象反复创建销毁导致堆区内存反复分配、释放,会使系统中出现大量的内存碎片,降低内存的利用效率。应用对象池技术可以解决内存碎片的问题。构建一个对象容器,需要时从对象池中取出一个空闲对象,用完后并不释放,而是放到对象容器中以供下一次再利用。省却了内存分配、释放过程的系统开销;放回对象池的对象在内存中的位置并没有变化,仅仅是内容被重置,因而不会产生内存碎片。可用对象池模版类来实现,根据具体需求再实例化模版类。下面给出示例代码:

template class ObjPool

{

deque free_set; //空闲对象集合

deque obj_set;// 全体对象集合

TCritic crticobj; //临界区对象

public:

T* GetObj () {

T* pObj;

crticobj.Critic(); //进入保护区

if(free_set.size()>0 ) {

pObj = free_set.front();

free_set.pop_front();

}

else{

pObj = new T;

obj_set.push_back(pObj);

}

crticobj.UnCritic ();//离开保护区

return pObj;

}

void FreeObj ( T* pObj) {

((T*) pObj)->Reset(); //重置对象

crticobj.Critic();

free_set.push_back(pObj); //放回空闲对象容器

crticobj.UnCritic ();

}

};

模版类ObjPool 使用stl deque容器类保存空闲对象指针。deque类型的容器能快速在数组头部弹出元素和尾部添加元素。类的成员函数GetObj ()负责提供可用对象,如果空闲对象集合free_set中有可用对象,则先从free_set头部取出一个空闲对象指针。再将该指针从free_set中弹出。如果free_set为空,则创建一个新的可用对象,并把对象指针保存到总对象集obj_set中。对free_set的读取操作需要在各线程间同步。不能有两个线程同时访问free_set。读取操作要设置临界区加以保护:crticobj为一个临界区对象,crticobj.Critic()进入保护区,crticobj.UnCritic ()离开保护区。成员函数Reset()重置对象,由于不同类型的对象重置方法不同,当模版类ObjPool实例化时再具体定义Reset()函数。

2.3 减少WSARecv调用的系统资源消耗。

由于WSARecv函数的异步特性,调用后立即返回,可以服务于大量的连接请求。但每一次WSARecv调用,系统都会为之分配接收数据的缓冲区,即使只接收一个字节,系统也会分配最小单元为4k的内存,且在数据接收未完成时,分配的缓冲区将被系统锁定。如果WSARecv调用过多,将有大量非分页内存被锁定,一旦达到系统锁定内存值的上限。 WSARecv就会返回“WSAENOBUFS”的错误。 所以,当系统尚未真正收到数据而处于等待状态时,只请求一个0字节的缓冲区,内存锁定值为零,无论投递多少请求都不会出现系统资源耗尽的问题。当系统收到数据,完成端口收到一个零字节的完成包。 相当于数据到来时的“通知”。此时再调用WSARecv函数投递非0字节缓冲区接收数据。但是,当客户连接断开时,也会收到0字节的数据。区分这两种情况的方法是,判断完成包数据缓冲区的大小,如果是零,则表明是零字节投递的结果,否则,是客户断开连接套接字关闭的结果。下面给(下转第81页)(上接第36页)出示例代码:

GetQueuedCompletionStatus( IOCP_handle,&dwRecv…) ;

if( dwRec ==0) //收到零字节数据

{

if( PerIO->buffer.len == 0){ //零字节WSARecv调用

//再次投递缓冲区大小为buffersize的接收请求

IOCPobj->IOCP_Recv(PerIO, buffersize,NULL );

}

else IOCPobj ->IOCP_Error(PerIO); //客户连接断开了,处理断开错误。

}

3 结语

本文分析了完成端口多线程模型并发处理运行机制,提出了几种提高完成端口运行效率,减少系统开销的方法,这些方法已经成功运用在多个网络服务系统的开发中。

注释

① 王艳平.Windows网络与通信程序设计[M].人民邮电出版社,2009.