众所周知,高并发的大型服务器程序一直面临着架构复杂、线程众多难以管理、并发性能提升困难的问题。为此,各种平台都提供系统级的高级设施来协助开发者解决这个难题,例如Linux平台的epoll。对于我们熟悉的Windows平台,则有一个名为IOCP(完成端口)的内核对象,通过它,我们可以方便地创建高并发、高性能、可伸缩的网络服务器程序。
IOCP即I/0 Completion Parts,它是迄今为止Windows平台上最为复杂的一种I/O模型,假如一个程序需要管理为数众多的套接字,那么采用这种模型往往可以达到最佳的系统性能。当你的应用程序需要同时管理数百乃个至上千个套接字的时候,而且希望随着系统内安装的CPU的数量增多,应用程序的性能也可以线性地提升时,你应该考虑使用IOCP模型。IOCP是Windows平台唯一适用于高负载服务器的一个技术,它利用一些线程帮助平衡”I/O请求”所引起的负载,这样的构架特别适用于产生所谓的”Scalable”服务器,这是一种能够籍着增加RAM、磁盘空间、CPU个数而提升应用程序效能的一种系统。
在决定使用IOCP之前,我们先来回顾一下传统的网络服务程序的工作模式。传统的工作模式大致是一个listen线程监听到来自网络的服务请求时,创建一个线程来对请求作出响应。这样,随着并发数的提高,系统中就会活跃着大量的线程,最终,疲于奔命的Windows内核不得不耗费大量的时间用于线程的上下文切换,而对于有价值的应用计算则力不从心。尽管可以引入线程池来改善线程的创建和管理,使系统的运行有条不紊!但创建这样的应用所需要的技巧和繁琐的逻辑足以使人敬而远之,你需要自己小心翼翼地处理一切!与系统提供的设施相比,无论从兼容性上还是性能上,传统的工作模式都不是最好的选择,你应该放弃”发明轮子”的念头。
本质上,利用IOCP模型,我们也需要线程池的思想。我们需要事先创建一定数量的工作线程,所有工作线程在同一个IOCP对象上阻塞,并等待一次IO操作完成,当一次IO操作完成时,被绑定的这个IOCP对象将得到状态的更新,IOCP对象的状态更新通知会激活工作者线程的执行。
接下来,我们看看一个最小巧的利用IOCP模型的服务器应用是如何炮制并工作的。
一、首先,你需要创建IOCP内核对象。
一个系统级的API——CreateIoCompletionPort可用于IOCP对象的创建。
HANDLE CreateIoCompletionPort(
IN HANDLE FileHandle,
IN HANDLE ExistingCompletionPort,
IN ULONG_PTR CompletionKey,
IN DWORD NumberOfConcurrentThreads
);
这个函数不仅用于IOCP对象的创建,还用于将一个句柄同IOCP对象的绑定。这个函数有4个参数,但在创建IOCP对象时,我们对前3个参数都不感兴趣。第4个参数NumberOfConcurrentThreads用于告诉系统在这个IOCP对象上的线程并发数,即允许多少个线程被同时执行。为了避免频繁的线程场景切换,人们一般希望在每一个处理器上运行一个线程,这样,我们可以把这个参数设置为0,这表明为这个IOCP对象同时服务的线程数量随系统中的CPU数量而定,有多少个CPU就允许多少个线程被同时运行。
HANDLE hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, NULL, 0);
这样,我们就创建了一个IOCP对象,它的句柄保存在hIOCP变量中。接下来,我们就可以把IO操作的句柄与这个IOCP对象建立联系。
二、IO操作与IOCP对象的绑定。
事实上,IOCP除了接受网络套接字的绑定还可以接受WIN32的其它对象,例如文件句柄。这里,我们只关心socket句柄的绑定问题。
我们需要建立一个socket监听,然后把accept返回的socket绑定到IOCP对象上,让IOCP对象与IO操作的状态建立起联系。有关socket的建立问题在此用代码代替讨论、略过不表:
SOCKET socketServer = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
if (socketServer == INVALID_SOCKET)
{
return FALSE;
}
sockaddr_in addrServer;
ZeroMemory(&addrServer, sizeof(sockaddr_in));
addrServer.sin_family = AF_INET;
addrServer.sin_addr.s_addr = INADDR_ANY;
addrServer.sin_port = htons(usPort);
if (bind(socketServer, (sockaddr*)&addrServer, sizeof(sockaddr_in)) == SOCKET_ERROR)
{
int nError = WSAGetLastError();
closesocket(m_SocketServer);
return FALSE;
}
if (listen(socketServer, MAX_CONNECT_QUEUE) == SOCKET_ERROR)
{
closesocket(m_SocketServer);
return FALSE;
}
上面的步骤创建一个用于监听的socket并开始网络监听,然后我们使用WSAAccept不断接受来自网络的接入请求,把该函数返回的socket句柄与IOCP对象绑定,随后,我们将在该socket上执行异步的网络操作,操作完成时IOCP对象将使挂起的工作线程立即进入工作状态。
While (TRUE)
{
socketAccept = WSAAccept(socketServer, NULL, NULL, NULL, 0);
LPPerHandleData pPerHandleData = (LPPerHandleData)GlobalAlloc(GPTR, sizeof(PerHandleData));
pPerHandleData->sock = socketAccept;
if (CreateIoCompletionPort((HANDLE)socketAccept, hIOCP, (ULONG_PTR)pPerHandleData , 0) == NULL)
{
ASSERT(FALSE);
}
// 接下来,就可以就可以使用WSARecv/WSASend来异步地接收/发送消息了。
WSARecv(…);
WSASend(…);
}
注意CreateIoCompletionPort的第三个参数CompletionKey,它指向一个被称作单句柄对象的自定义数据结构体地址,当IO操作完成时,工作线程籍由IOCP对象获得这个状态的改变,IOCP将原原本本地把绑定时指定的这个结构体指针传给工作线程。我们可以把socket句柄保存在单句柄结构体中,这样,借助于这个自定义的结构体,工作线程可以明确地知道是与哪个句柄相关的操作发生了状态的改变。
可以定义类似于下面的结构体用于单句柄数据:
// 单句柄数据结构
typedef struct tagPerHandleData{
bool bUsed; // 是否正在使用
SOCKET sock;
sockaddr_in addrClient;
LPPerIoOperData lpPerIoOpDataRecv;
LPPerIoOperData lpPerIoOpDataSend;
ZJRecvBufItem stRecvBufItem;
…
}PerHandleData, *LPPerHandleData;
一般情况,我们并不象上述代码那样直接GlobalAlloc出来一个单句柄结构体,使用一个所谓的单句柄池来管理是更好的选择。每个新接入的socket需要单句柄数据时,我们从单句柄池中获取,socket断开后,我们再把与之相关联的单句柄归还给单句柄池。
三、创建工作线程。
在完成了IOCP的创建之后,我们最重要的工作还没有做,那就是创建工作线程。工作线程创建多少个为宜,需要根据应用的性质来确定,一般来说,推荐的数量是CPU数量的两倍略多一点。因此,我们首先获得CPU的数量,然后乘2加2。
int nThreadsNum = nNumberOfProcessors * 2 + 2;
HANDLE hThread = NULL;
for (int i=0; i<nThreadsNum; ++i)
{
hThread = CreateThread(NULL, 0, WorkerThread, hIOCP, 0, NULL);
}
DWORD WINAPI WorkerThread(LPVOID lpParam)
{
HANDLE hIOCP = (HANDLE)lpParam;
DWORD dwBytesTransfered;
LPPerHandleData lpPerHandleData;
LPPerIoOperData lpPerIoOperData;
while (TRUE)
{
// 等待绑定到hIOCP指定的完成端口上的socket完成IO操作
GetQueuedCompletionStatus(hIOCP, &dwBytesTransfered, (LPDWORD)&lpPerHandleData, (LPOVERLAPPED*)&lpPerIoOperData, INFINITE);
if (dwBytesTransfered == 0 && lpPerHandleData == 0 && lpPerIoOperData == 0)
{
// WorkerThread线程收到退出消息!
break;
}
// 处理网络事件
}
}
GetQueuedCompletionStatus函数用于获取指定IOCP的”排队完成状态”,它让一个以上的线程在此函数上挂起,等待指定的IOCP的状态通知。当IOCP状态更新后,在此IOCP上挂起的线程中的某一个线程将被立即激活。该函数原型是:
BOOL GetQueuedCompletionStatus(
IN HANDLE CompletionPort,
OUT LPDWORD lpNumberOfBytesTransferred,
OUT PULONG_PTR lpCompletionKey,
OUT LPOVERLAPPED *lpOverlapped,
IN DWORD dwMilliseconds
);
参数lpNumberOfBytesTransferred是指一次IO操作完成的字节数,lpCompletionKey是我们在调用CreateIoCompletionPort绑定socket句柄与IOCP时指定的单句柄数据,前面已经提到,通过这个参数我们可以知道是在哪个socket上发生的。lpOverlapped用于获取WIN32重叠IO操作的一个重叠结果,本质上,IOCP利用了WIN32重叠IO的机制。我们使用WSASend和WSARecv函数异步地投递发送和接收请求时,需要一个OVERLAPPED的结构体,WSASend和WSARecv将直接返回,操作完成后这个OVERLAPPED结构体指针由GetQueuedCompletionStatus函数取回。
由于IO操作的数据在IO操作完成后是必须要让工作线程知道的,而WSASend、WSARecv及GetQueuedCompletionStatus函数均接受一个OVERLAPPED指针,因此,我们可以定义一个结构体来保存OVERLAPPED结构和IO操作的数据,让OVERLAPPED成为第一个成员,并在调用那三个函数时把参数强转成OVERLAPPED指针。这样的结构体叫作”单IO操作数据”,它的定义可以是这样的:
// 单IO操作数据结构
typedef struct tagPerIoOperData{
WSAOVERLAPPED stOverlapped
bool bUsed;
WSABUF stDataBuf;
char szBuf[TCP_BUF_SIZE];
OperationType emOperType;
LPZJSendBufItem lpSendBuf;
tagPerHandleData* pPHD;
}PerIoOperData, *LPPerIoOperData;
至此,一个以IOCP模型建立服务器应用的基本步骤就完成了。但实际的应用还必须考虑更多的细节,迎接更多的挑战。例如,如何投递近饱和的accept请求以更快的速度响应更多网络接入请求,如何通知上层应用接收到了来自网络的数据包,以及如何向网络发送数据包……只要理解了基本的IOCP模型,其它的尽管有些繁琐,但并不复杂,基于这种模型去深入地考虑问题,一切的问题都可以迎刃而解!