完成端口模式下的高性能网络服务器

来源:互联网 发布:货币就业利息通论知乎 编辑:程序博客网 时间:2024/05/07 10:28
第三章 完成端口模式下的高性能网络服务器

3.1开始
完成端口听起来好像很神秘和复杂,其实并没有想象的那么难。这方面的文章在论坛上能找到的我差不多都看过,写得好点的就是CSDN.NET上看到的一组系列文章,不过我认为它只是简单的翻译了一下Network Programming for Microsoft Windows 2nd 中的相关内容,附上的代码好像不是原书中的,可能是另一本外文书里的。我看了以后,觉得还不如看原版的更容易理解。所以在我的开始部分,我主要带领初学者理解一下完成端口的有关内容,是我开发的经验,其他的请参考原书的相关内容。
采用完成端口的好处是,操作系统的内部重叠机制可以保证大量的网络请求都被服务器处理,而不是像WSAAsyncSelect 和WSAEventSelect的那样对并发的网络请求有限制,这一点从上一章的测试表格中可以清楚的看出。
完成端口就像一种消息通知的机制,我们创建一个线程来不断读取完成端口状态,接收到相应的完成通知后,就进行相应的处理。其实感觉就像WSAAsyncSelect一样,不过还是有一些的不同。比如我们想接收消息,WSAAsyncSelect会在消息到来的时候直接通知Windows消息循环,然后就可以调用WSARecv来接收消息了;而完成端口则首先调用一个WSARecv表示程序需要接收消息(这时可能还没有任何消息到来),但是只有当消息来的时候WSARecv才算完成,用户就可以处理消息了,然后再调用一个WSARecv表示等待下一个消息,如此不停循环,我想这就是完成端口的最大特点吧。
Per-handle Data 和 Per-I/O Operation Data 是两个比较重要的概念,Per-handle Data用来把客户端数据和对应的完成通知关联起来,这样每次我们处理完成通知的时候,就能知道它是哪个客户端的消息,并且可以根据客户端的信息作出相应的反应,我想也可以理解为Per-Client handle Data吧。Per-I/O Operation Data则不同,它记录了每次I/O通知的信息,比如接收消息时我们就可以从中读出消息的内容,也就是和I/O操作有关的信息都记录在里面了。当你亲手实现完成端口的时候就可以理解他们的不同和用途了。
CreateIoCompletionPort函数中有个参数NumberOfConcurrentThreads,完成端口编程里有个概念Worker Threads。这里比较容易引起混乱,NumberOfConcurrentThreads需要设置多少,又需要创建多少个Worker Threads才算合适?NumberOfConcurrentThreads的数目和CPU数量一样最好,因为少了就没法利用多CPU的优势,而多了则会因为线程切换造成性能下降。Worker Threads的数量是不是也要一样多呢,当然不是,它的数量取决于应用程序的需要。举例来说,我们在Worker Threads里进行消息处理,如果这个过程中有可能会造成线程阻塞,那如果我们只有一个Worker Thread,我们就不能很快响应其他客户端的请求了,而只有当这个阻塞操作完成了后才能继续处理下一个完成消息。但是如果我们还有其他的Worker Thread,我们就能继续处理其他客户端的请求,所以到底需要多少的Worker Thread,需要根据应用程序来定,而不是可以事先估算出来的。如果工作者线程里没有阻塞操作,对于某些情况来说,一个工作者线程就可以满足需要了。
其他问题,Network Programming for Microsoft Windows 2nd中,作者还提出了如何安全的退出应用程序等等实现中的细节问题,这里我就不一一讲述了,请读者参考原书的相关内容,如果仍有疑问,可以联系我。

3.2实现
下面是一般的实现步骤
1. 获得计算机信息,得到CPU的数量。创建一个完成端口,第四个参数置0,指定NumberOfConcurrentThreads为CPU个数。
2. Determine how many processors exist on the system.
3. Create worker threads to service completed I/O requests on the completion port using processor information in step 2. In the case of this simple example, we create one worker thread per processor because we do not expect our threads to ever get in a suspended condition in which there would not be enough threads to execute for each processor. When the CreateThread function is called, you must supply a worker routine that the thread executes upon creation. We will discuss the worker thread's responsibilities later in this section.
4. Prepare a listening socket to listen for connections on port 5150.
5. Accept inbound connections using the accept function.
6. Create a data structure to represent per-handle data and save the accepted socket handle in the structure.
7. Associate the new socket handle returned from accept with the completion port by calling CreateIoCompletionPort. Pass the per-handle data structure to CreateIoCompletionPort via the completion key parameter.
8. Start processing I/O on the accepted connection. Essentially, you want to post one or more asynchronous WSARecv or WSASend requests on the new socket using the overlapped I/O mechanism. When these I/O requests complete, a worker thread services the I/O requests and continues processing future I/O requests, as we will see later in the worker routine specified in step 3.
9. Repeat steps 5–8 until server terminates.

那么学习完成端口编程从哪里开始比较好,对于初学者而言,直接进入编程并不是一个好主意,我建议初学者首先学习用异步Socket模式,即WSAEventSelect模式构建一个简单的聊天服务器。当把Windows网络编程的概念有一个清晰的认识之后,再深入研究完成端口编程。
接着就是深入研究具体的编程实现了,从Network Programming for Microsoft Windows 2nd中摘录的这段经典代码可以说是非常合适的,这里我只简单解释一下其中比较关键的地方,还有不明白的可以参看原书,或者联系我。

主程序段:
1. HANDLE CompletionPort;
2. WSADATA wsd;
3. SYSTEM_INFO SystemInfo;
4. SOCKADDR_IN InternetAddr;
5. SOCKET Listen;
6. int i;
7.
8. typedef struct _PER_HANDLE_DATA
9. {
10. SOCKET Socket;
11. SOCKADDR_STORAGE ClientAddr;
12. // 在这里还可以加入其他和客户端关联的数据
13. } PER_HANDLE_DATA, * LPPER_HANDLE_DATA;
14.
15. // 初始化Windows Socket 2.2
16. StartWinsock(MAKEWORD(2,2), &wsd);
17.
18. // Step 1:
19. // 创建完成端口
20.
21. CompletionPort = CreateIoCompletionPort(
22. INVALID_HANDLE_VALUE, NULL, 0, 0);
23.
24. // Step 2:
25. // 检测系统信息
26.
27. GetSystemInfo(&SystemInfo);
28.
29. // Step 3: 创建工作者线程,数量和CPU的数量一样多
30. // Create worker threads based on the number of
31. // processors available on the system. For this
32. // simple case, we create one worker thread for each
33. // processor.
34.
35. for(i = 0; i < SystemInfo.dwNumberOfProcessors; i++)
36. {
37. HANDLE ThreadHandle;
38.
39. // Create a server worker thread, and pass the
40. // completion port to the thread. NOTE: the
41. // ServerWorkerThread procedure is not defined
42. // in this listing.
43.
44. ThreadHandle = CreateThread(NULL, 0,
45. ServerWorkerThread, CompletionPort,
46. 0, NULL;
47.
48. // Close the thread handle
49. CloseHandle(ThreadHandle);
50. }
51.
52. // Step 4:
53. // 创建监听Socket
54.
55. Listen = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0,
56. WSA_FLAG_OVERLAPPED);
57.
58. InternetAddr.sin_family = AF_INET;
59. InternetAddr.sin_addr.s_addr = htonl(INADDR_ANY);
60. InternetAddr.sin_port = htons(5150);
61. bind(Listen, (PSOCKADDR) &InternetAddr,
62. sizeof(InternetAddr));
63.
64. // 开始监听
65.
66. listen(Listen, 5);
67.
68. while(TRUE)
69. {
70. PER_HANDLE_DATA *PerHandleData=NULL;
71. SOCKADDR_IN saRemote;
72. SOCKET Accept;
73. int RemoteLen;
74. // Step 5: 等待客户端连接,然后将客户端Socket加入完成端口
75. // Accept connections and assign to the completion
76. // port
77.
78. RemoteLen = sizeof(saRemote);
79. Accept = WSAAccept(Listen, (SOCKADDR *)&saRemote,
80. &RemoteLen);
81.
82. // Step 6: 初始化客户端数据
83. // Create per-handle data information structure to
84. // associate with the socket
85. PerHandleData = (LPPER_HANDLE_DATA)
86. GlobalAlloc(GPTR, sizeof(PER_HANDLE_DATA));
87.
88. printf("Socket number %d connected/n", Accept);
89. PerHandleData->Socket = Accept;
90. memcpy(&PerHandleData->ClientAddr, &saRemote, RemoteLen);
91.
92. // Step 7:
93. // Associate the accepted socket with the
94. // completion port
95.
96. CreateIoCompletionPort((HANDLE) Accept,
97. CompletionPort, (DWORD) PerHandleData, 0);
98.
99. // Step 8: 发出对客户端的I/O请求,等待完成消息
100. // Start processing I/O on the accepted socket.
101. // Post one or more WSASend() or WSARecv() calls
102. // on the socket using overlapped I/O.
103. WSARecv(...);
104. }
105.
106.

工作者线程

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)
{
// 等待完成端口消息,未收到消息德时候则阻塞线程

ret = GetQueuedCompletionStatus(CompletionPort,
&BytesTransferred,(LPDWORD)&PerHandleData,
(LPOVERLAPPED *) &PerIoData, INFINITE);

// First check to see if an error has occurred
// on the socket; if so, close the
// socket and clean up the per-handle data
// and per-I/O operation data associated with
// the 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)
{
// Do something with the received data
// in PerIoData->Buffer
}

// Post another WSASend or WSARecv operation.
// As an example, we will post another WSARecv()
// I/O operation.

Flags = 0;

// Set up the per-I/O operation data for the next
// overlapped call
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);
}
}

3.3 小节

讲这么点就完了?你一定认为我介绍的东西并没有超过原书中的内容,实事上完成端口编程的精髓就是上面的代码和原书中的有关叙述。如果我再把他们完整的重复一遍,那又有什么意思呢?根据我的经验,设计网络服务器的真正难点,不在于完成端口技术,所以我想利用小节把自己编程中的一些经验告诉大家。
首先是服务器的管理,一个服务器首先要分析它的设计目标是应对很多的连接还是很大的数据传送量。这样在设计工作者线程时就可以最大限度的提高性能。管理客户端方面,我们可以将客户端的数据捆绑到Perhand-Data数据结构上,如果还有需要,可以建一个表来记录客户端的宏观情况。
在Ares引擎中,我将文件传送和大容量数据传送功能也封装进了服务器和客户端。我建议服务器和客户端都应该封装这些功能,尽管我们并不是做FTP服务器,但是当客户端需要和服务器交换文件和大块数据时,你会发现这样做,灵活性和性能都能做得比用单纯的FTP协议来更好,所以在你的服务器和客户端可以传送数据包以后,把他们都做进去吧。
为了服务器不被黑客攻击,或被BUG弄崩溃,我们还需要认真设计服务器的认证机制,以及密切注意程序中的溢出,一定要在每一个使用缓冲区的地方加上检查代码。可以说并没有现成的办法来解决这个问题,不然就没有人研究网络安全了,所以我们要做的是尽量减少错误,即使出现错误也不会造成太大损失,在发现错误的时候能够很快纠正同类错误。
还有就是对客户端情况的检测,比如客户端的正常和非正常断开连接。如果不注意这一点,就会造成服务器资源持续消耗而最终崩溃,因为我们的服务器不可能总是重启,而是要持续的运行,越久越好。还有比如客户端断开连接后又尝试连接,但是在服务器看来这个客户“仍然在线“,这个时候我们不能单纯的拒绝客户端的连接,也不能单纯的接收。
讲了几点服务器设计中的问题,他们只是众多问题中的一小部分,限于时间原因,在这个版本的文章中就说这么多。你一定会发现,其实网络编程最困难和有成就的地方,并不是服务器用了什么模式等等,而是真正深入设计的时候碰到的众多问题。正是那些没有标准答案的问题,值得我们去研究和解决。