Windows网络模型的最后一种就是完成端口+线程池模型。当需要处理成千上万个套接字的时候就可以使用这种模型。该模型结构十分复杂,而且仅适用于WinNT和Win2000以上的系统。
从本质上说,完成端口模型要求我们创建一个 Win32 完成端口对象,通过指定数量的线程,对重叠 I/O 请求进行管理,以便为已经完成的重叠 I/O 请求提供服务。
通常情况下,我们会在应用程序中创建一定数量的工作者线程来处理这些通知。线程数量取决于应用程序的特定需要。理想的情况是,线程数量等于处理器的数量,不过这也要求任何线程都不应该执行诸如同步读写、等待事件通知等阻塞型的操作,以免线程阻塞。每个线程都将分到一定的CPU时间,在此期间该线程可以运行,然后另一个线程将分到一个时间片并开始执行。如果某个线程执行了阻塞型的操作,操作系统将剥夺其未使用的剩余时间片并让其它线程开始执行。也就是说,前一个线程没有充分使用其时间片,当发生这样的情况时,应用程序应该准备其它线程来充分利用这些时间片。
使用这种模型之前,首先要创建一个 I/O 完成端口对象,用它面向任意数量的套接字句柄,管理多个 I/O 请求。需要用到CreateCompletionPort函数。原型如下:
HANDLE WINAPI CreateIoCompletionPort( __in HANDLE FileHandle, __in HANDLE ExistingCompletionPort, __in ULONG_PTR CompletionKey, __in DWORD NumberOfConcurrentThreads );
该函数的作用有两个,一个是创建完成端口对象,另一个是将一个句柄同完成端口对象关联到一起。
如果仅仅为了创建一个完成端口对象,唯一注意的参数便是NumberOfConcurrentThreads(并发线程的数量),前面三个参数可忽略。
HANDLE CompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0)
NumberOfConcurrentThreads 参数的特殊之处在于,它定义了在一个完成端口上,同时允许执行的线程数量。若将该参数设为 0,表明系统内安装了多少个处理器,便允许同时运行多少个工作者线程。最好的情况是每个处理器各自负责一个线程的运行,并且为完成端口提供服务,避免过于频繁的线程上下文切换。
成功创建一个完成端口后,便可开始将套接字句柄与其关联到一起。但在关联套接字之前,首先必须创建一个或多个“工作者线程”,以便在I/O请求投递给完成端口后,为完成端口提供服务。
调用CreateIoComletionPort时指定的并发线程数量,与打算创建的工作者线程数量相比,它们代表的不是同一件事情。最后一个参数在MSDN上的解释如下:
The maximum number of threads that the operating system can allow to concurrently process I/O completion packets for the I/O completion port. This parameter is ignored if the ExistingCompletionPort parameter is not NULL.
If this parameter is zero, the system allows as many concurrently running threads as there are processors in the system.
一旦在完成端口上拥有足够多的工作者线程来为 I/O 请求提供服务,便可将套接字句柄同完成端口关联到一起。
这就需要在现有的完成端口上调用CreateIoCompletionPort函数,同时为前三个参数FileHandle,ExistingCompletionPort 和 CompletionKey提供套接字的信息。
其中FileHandle 参数指定一个要同完成端口关联在一起的套接字句柄;ExistingCompletionPort 参数指定的是一个现有的完成端口;CompletionKey(完成键)参数指定与某个套接字句柄关联在一起的“单句柄数据”,可将其作为指向一个数据结构的指针,在此数据结构中,同时包含了套接字的句柄,以及与套接字有关的其他信息,如 IP 地址等。为完成端口提供服务的线程函数可通过这个参数,取得与套接字句柄有关的信息。
按照如下步骤可以建立一个完成端口模型:
创建一个完成端口,第四个参数保持为 0,指定在完成端口上,每个处理器一次只允许执行一个工作者线程;
判断系统内到底安装了多少个处理器;
创建工作者线程,根据步骤 2) 得到的处理器信息,在完成端口上,为已完成的 I/O 请求提供服务,在这个例子中,为每个处理器都只创建一个工作者线程。调用 CreateThread 函数时,必须同时提供一个工作者例程,由线程在创建好执行;
准备好一个监听套接字,在端口上监听进入的连接请求;
使用 accept 函数,接受进入的连接请求;
创建一个数据结构,用于容纳“单句柄数据”,同时在结构中存入接受的套接字句柄;
调用 CreateIoCompletionPort 函数,将从 accept 返回的新套接字句柄同完成端口关联到一起,通过完成键(CompletionKey)参数,将单句柄数据结构传递给 CreateIoCompletionPort 函数;
开始在已接受的连接上进行 I/O 操作,在此通过重叠 I/O 机制,在新建的套接字上投递一个或多个异步 WSARecv 或 WSASend 请求。这些 I/O 请求完成后,一个工作者线程会为 I/O 请求提供服务,同时继续处理未来的其他 I/O 请求,稍后便会在步骤 3) 指定的工作者例程中
重复步骤 5) ~ 8),直至服务器中止。
HANDLE CompletionPort; WSADATA wsd; SYSTEM_INFO SystemInfo; SOCKADDR_IN InternetAddr; SOCKET Listen; int i; typedef struct _PER_HANDLE_DATA { SOCKET Socket; SOCKADDR_STORAGE ClientAddr; // Other information useful to be associated with the handle } PER_HANDLE_DATA, * LPPER_HANDLE_DATA; // Load Winsock StartWinsock(MAKEWORD(2,2), &wsd); // Step 1: // 创建一个完成端口 CompletionPort = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL, 0, 0); // Step 2: // 判断系统内到底安装了多少个处理器 GetSystemInfo(&SystemInfo); // Step 3: // 根据处理器的数量创建工作者线程 for(i = 0; i < SystemInfo.dwNumberOfProcessors; i++) { HANDLE ThreadHandle; // Create a server worker thread, and pass the // completion port to the thread. NOTE: the // ServerWorkerThread procedure is not defined // in this listing. ThreadHandle = CreateThread(NULL, 0, ServerWorkerThread, CompletionPort, 0, NULL; // Close the thread handle CloseHandle(ThreadHandle); } // Step 4: // 准备好一个监听套接字 Listen = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED); InternetAddr.sin_family = AF_INET; InternetAddr.sin_addr.s_addr = htonl(INADDR_ANY); InternetAddr.sin_port = htons(9527); bind(Listen, (PSOCKADDR) &InternetAddr, sizeof(InternetAddr)); // Prepare socket for listening listen(Listen, 5); while(TRUE) { PER_HANDLE_DATA *PerHandleData=NULL; SOCKADDR_IN saRemote; SOCKET Accept; int RemoteLen; // Step 5: // 接受客户端的连接 RemoteLen = sizeof(saRemote); Accept = WSAAccept(Listen, (SOCKADDR *)&saRemote, &RemoteLen); // Step 6: // 创建一个数据结构,用于容纳“单句柄数据” PerHandleData = (LPPER_HANDLE_DATA) GlobalAlloc(GPTR, sizeof(PER_HANDLE_DATA)); printf("Socket number %d connected\n", Accept); PerHandleData->Socket = Accept; memcpy(&PerHandleData->ClientAddr, &saRemote, RemoteLen); // Step 7: // 调用 CreateIoCompletionPort 函数,将从 accept 返回的新套接字句柄同完成端口关联到一起 CreateIoCompletionPort((HANDLE) Accept, CompletionPort, (DWORD) PerHandleData, 0); // Step 8: // 开始在已接受的连接上进行 I/O 操作 WSARecv(...); } DWORD WINAPI ServerWorkerThread(LPVOID lpParam) { // The requirements for the worker thread will be // discussed later. return 0; }
下面看一下工作者线程需要做的事情。
将套接字句柄与一个完成端口关联在一起后,便可投递发送与接收请求,开始对 I/O 请求的处理。接下来,可开始依赖完成端口,来接收有关 I/O 操作完成情况的通知。从本质上说,完成端口模型利用了 Win32 重叠 I/O 机制。在这种机制中,象 WSASend 和 WSARecv 这样的 WinsockAPI 调用会立即返回。此时,需要由应用程序负责在以后的某个时间,通过一个 OVERLAPPED 结构,来接收之前调用请求的结果。
在完成端口模型中需要使用 GetQueuedCompletionStatus(获取队列完成状态)函数,
让一个或多个工作者线程在完成端口上等待 I/O 请求完成的通知。
BOOL WINAPI GetQueuedCompletionStatus( __in HANDLE CompletionPort, __out LPDWORD lpNumberOfBytes, __out PULONG_PTR lpCompletionKey, __out LPOVERLAPPED* lpOverlapped, __in DWORD dwMilliseconds );
CompletionPort 参数对应于要在上面等待的完成端口;lpNumberOfBytes 参数负责在完成了一次 I/O 操作后(如:WSASend 或 WSARecv),接收实际传输的字节数。lpCompletionKey 参数为原先传递给CreateIoCompletionPort 函数第三个参数“单句柄数据”,最好将套接字句柄保存在这个“键”(Key)中。lpOverlapped 参数用于接收完成 I/O 操作的重叠结果。这实际是一个相当重要的参数,因为可用它获取每个 I/O 操作的数据。dwMilliseconds 参数用于指定希望等待一个完成数据包在完成端口上出现的时间,即,超时时间。假如将其设为 INFINITE,会一直等待下去。
关于单句柄数据和单I/O操作数据的那些事。
一个工作者线程从 GetQueuedCompletionStatus 函数接收到 I/O 完成通知后,在 lpCompletionKey 和 lpOverlapped 参数中,会包含一些重要的套接字信息。利用这些信息,可通过完成端口,继续在一个套接字上进行其他的处理。
通过这些参数,可获得两方面重要的套接字数据:“单句柄数据”以及单 I/O 操作数据。
其中,lpCompletionKey参数包含了“单句柄数据”,因为在一个套接字首次与完成端口关联到一起的时候,那些数据便与一个特定的套接字句柄对应起来了。这些数据正是我们在调用 CreateIoCompletionPort 函数时候,通过 CompletionKey 参数传递的。
通常情况下,应用程序会将与 I/O 请求有关的套接字句柄及其他的一些相关信息保存在这里。
lpOverlapped 参数则包含了一个 OVERLAPPED 结构,在它后面跟随“单 I/O 操作数据”。
单 I/O 操作数据可以是追加到一个 OVERLAPPED 结构末尾的、任意数量的字节。
假如一个函数要求用到一个 OVERLAPPED 结构,必须将这样的一个结构传递进去,以满足它的要求。要想做到这一点,一个简单的方法是定义一个结构,然后将 OVERLAPPED 结构作为新结构的第一个元素使用。
可定义下述数据结构,实现对单 I/O 操作数据的管理。
typedef struct { OVERLAPPED Overlapped; WSABUF DataBuf; char szBuffer[DATA_BUF_SIZE]; int OperationType; } PER_IO_OPERATION_DATA;
该结构通常与 I/O 操作关联的一些重要的数据元素,比如那个 I/O 操作的类型(发送或接收请求),用 OperationType 字段表示,同时,用于已完成 I/O 操作数据的缓冲区 szBuffer 也是非常有用的。如果想调用一个 Winsock API 函数(如:WSASend、WSARecv),要为其分配一个 OVERLAPPED 结构,这时,就可以将结构强制转换成一个 OVERLAPPED 指针,或者从结构中将 OVERLAPPED 元素的地址取出来。
PER_IO_OPERATION_DATA PerIoData; …… //可以这样调用: WSARecv(socket, ..., (OVERLAPPED *)&PerIoData); //也可以这样调用: WSARecv(socket, ..., &(PerIoData.Overlapped));
在工作线程的后面部分,等 GetQueuedCompletionStatus 函数返回了一个重叠结构(和完成键)后,便可通过 OperationType 成员,获取到底是哪个操作投递到了这个句柄之上(只需将返回的重叠结强制转换为自己的 PER_IO_OPERATION_DATA 结构)。对单 I/O 操作数据来说,它最大的一个优点便是允许在同一个句柄上同时管理多个 I/O 操作等等。
DWORD WINAPI ServerWorkerThread(LPVOID CompletionPortID) { HANDLE CompletionPort = (HANDLE) CompletionPortID; DWORD BytesTransferred; LPOVERLAPPED Overlapped; LPPER_HANDLE_DATA PerHandleData; LPPER_IO_DATA PerIoData; DWORD SendBytes, RecvBytes; DWORD Flags; while(TRUE) { //等待套接字上的I/O和完成端口进行关联 ret = GetQueuedCompletionStatus(CompletionPort, &BytesTransferred,(LPDWORD)&PerHandleData, (LPOVERLAPPED *) &PerIoData, INFINITE); //先检查套接字上是否有错误发生,有的话就关闭socket并且清除 //和单句柄数据单I/O操作关联的SOCKET if (BytesTransferred == 0 && (PerIoData->OperationType == RECV_POSTED || PerIoData->OperationType == SEND_POSTED)) { // A zero BytesTransferred indicates that the // socket has been closed by the peer, so // you should close the socket. Note: // Per-handle data was used to reference the // socket associated with the I/O operation. closesocket(PerHandleData->Socket); GlobalFree(PerHandleData); GlobalFree(PerIoData); continue; } // Service the completed I/O request. You can // determine which I/O request has just // completed by looking at the OperationType // field contained in the per-I/O operation data. if (PerIoData->OperationType == RECV_POSTED) { //处理单I/O数据的Buffer成员 } //投递另一个WSASend或WSARecv操作 Flags = 0; //为下一个重叠调用建立单I/O操作数据 ZeroMemory(&(PerIoData->Overlapped), sizeof(OVERLAPPED)); PerIoData->DataBuf.len = DATA_BUFSIZE; PerIoData->DataBuf.buf = PerIoData->Buffer; PerIoData->OperationType = RECV_POSTED; WSARecv(PerHandleData->Socket, &(PerIoData->DataBuf), 1, &RecvBytes, &Flags, &(PerIoData->Overlapped), NULL); } }
还需要正确地关闭 I/O 完成端口,特别是同时运行了一个或多个线程,在几个不同的套接字上执行 I/O 操作的时候。要避免的一个重要问题是在进行重叠 I/O 操作的同时,强行释放一个 OVERLAPPED 结构。要想避免出现这种情况,最好的办法是针对每个套接字句柄,调用 closesocket 函数,任何尚未进行的重叠 I/O 操作都会完成。一旦所有套接字句柄都已关闭,便需在完成端口上,终止所有工作者线程的运行。要想做到这一点,需要使用 PostQueuedCompletionStatus 函数,向每个工作者线程都发送一个特殊的完成数据包。
该函数会指示每个线程都“立即结束并退出”。
BOOL WINAPI PostQueuedCompletionStatus( __in HANDLE CompletionPort, __in DWORD dwNumberOfBytesTransferred, __in ULONG_PTR dwCompletionKey, __in LPOVERLAPPED lpOverlapped );
CompletionPort 参数指定想向其发送一个完成数据包的完成端口对象;而就 dwNumberOfBytesTransferred、dwCompletionKey 和 lpOverlapped 三个参数来说,每一个都允许指定一个值,直接传递给 GetQueuedCompletionStatus 函数中对应的参数。这样一来,一个工作者线程收到传递过来的三个 GetQueuedCompletionStatus 函数参数后,便可根据由这三个参数的某一个设置的特殊值,决定何时或者应该怎样退出。
下面来说说线程池技术。
它的基本思想就是在程序开始时就在内存中开辟一些线程, 当有新的客户请求到达时, 不是新创建一个线程为其服务, 而是从“池子”中选择一个空闲的线程为新的客户请求服务,服务完毕后,线程不是退出,而是进入空闲线程池中。
通过对多个任务重用已经存在的线程对象, 降低了对线程对象创建和销毁的开销。当客户请求时, 线程对象已经存在, 可以提高请求的响应时间, 从而整体地提高了系统服务的表现。
使用线程池用到的最重要的函数是QueueUserWorkItem。
BOOL WINAPI QueueUserWorkItem( __in LPTHREAD_START_ROUTINE Function, __in PVOID Context, __in ULONG Flags );
参数 Function 是一个函数指针,指向线程池中的线程必须要完成的工作,该函数必须具有以下形式:
DWORD WINAPI Function(LPVOID lpParam);
不难发现,这个函数跟创建线程的线程函数拥有相同的形式。
参数 Context 是一个 void 类型的指针,与传递给线程函数的 lpParam 是一个值。
关于最后一个参数Flags的解释可以参看MSDN
The flags that control execution. This parameter can be one or more of the following values.
ValueMeaningWT_EXECUTEDEFAULT0x00000000
By default, the callback function is queued to a non-I/O worker thread.
The callback function is queued to a thread that uses I/O completion ports, which means they cannot perform an alertable wait. Therefore, if I/O completes and generates an APC, the APC might wait indefinitely because there is no guarantee that the thread will enter an alertable wait state after the callback completes.
WT_EXECUTEINIOTHREAD0x00000001
This flag is not used.
Windows Server 2003 and Windows XP: The callback function is queued to an I/O worker thread. This flag should be used if the function should be executed in a thread that waits in an alertable state. I/O worker threads were removed starting with Windows Vista and Windows Server 2008.
WT_EXECUTEINPERSISTENTTHREAD0x00000080
The callback function is queued to a thread that never terminates. It does not guarantee that the same thread is used each time. This flag should be used only for short tasks or it could affect other timer operations. This flag must be set if the thread calls functions that use APCs. For more information, see Asynchronous Procedure Calls.
Note that currently no worker thread is truly persistent, although worker threads do not terminate if there are any pending I/O requests.
WT_EXECUTELONGFUNCTION0x00000010
The callback function can perform a long wait. This flag helps the system to decide if it should create a new thread.
WT_TRANSFER_IMPERSONATION0x00000100
Callback functions will use the current access token, whether it is a process or impersonation token. If this flag is not specified, callback functions execute only with the process token.
Windows XP: This flag is not supported until Windows XP SP2 and Windows Server 2003.
当第一次调用 QueueUserWorkItem 时, Windows操作系统将创建一个线程池,其中的一个线程将执行 Function 函数,函数执行完成后,该线程返回线程池,等待新的任务。由于 Windows 依赖于该过程来完成线程池的功能,因此 Function 中不能有任何中止该线程的调用,如 ExitThread。
假如当调用 QueueUserWorkItem 时,没有可用的线程,Windows 就可以通过创建额外的线程增加线程池中线程的数量。
线程池中的线程的数量是动态的,并且受 Windows 的控制,Windows 内部的调度算法决定处理当前线程工作负载的最佳方式。
如果知道所要处理的工作需要很长时间才能完成,可以在调用 QueueUserWorkItem 时,将参数的 Flags 设置为 WT_EXECUTELONGFUNCTION ,这时如果线程池中的所有的线程都处于忙状态, 那么 Windows 将自动创建新的线程。
Windows 线程池中的线程有两种类型,一种可以用来处理异步I/O,另一种则不能。前者依赖于IO完成端口,IOCP是一种Windows内核对象,它可以将线程和 I/O 端口绑定在特定的系统资源上,对带有完成端口的 I/O 进行处理是一个复杂的过程。
调用 QueueUserWorkItem 时,需要标识哪些线程执行 I/O,哪些线程不执行 I/O, 将 QueueUserWorkItem 中的 Flags 设置成 WT_EXECUTIONDEFAULT, 就可以告诉线程池该线程不执行异步 I/O,从而可以对其进行相应的管理;对于执行异步 I/O 的线程,则应该将其 Flags 设置为 WT_EXECUTEIONIOTHREAD。
通过完成端口和线程池技术的结合,可以写出高效的服务器模型。
源码太长了就不贴了鸟=。=
话说受何少影响也开始用鸟了=。=