Winsock 编程学习小结

 
初识 socket

第一份工作实习期间,上头就给我下达了一个对我来说较有挑战性的任务——按照原来的设计要求实现一个网络传输组件。说它有挑战性,一是因为之前我从未涉及过网络编程,即使是在 DXTetris 中实现了联机游戏,那也是用 DirectPlay 实现的,封装层次很高了,基本上涉及不到通讯过程;二是因为接口设计方面并不是完全“自由”的,要与原来的设计和现有的代码尽可能地兼容;三是时间较紧迫,要求一次成型而且大体上稳定,以便使项目的调试过程可以正常进行下去。于是,我的第一个网络程序——代号为“铁人(Steel Guy)”的网络传输组件—— SGTrans 应运而生。

SGTrans 要求实现的功能比较简单:客户端向服务器端发送一段请求数据,服务器端收到后处理此请求,并发回一段响应数据,一次会话即告结束。虽然每一个 socket 描述符都是“全双工”的,但是在业务上没有这种要求,SGTrans 中也就没有必要使用这个特性,以免增加程序的复杂度、带来更多的问题。所以 SGTrans 中的传输模式都是“半双工”式的。另外,虽然当初的设计中还要求实现带有附件文件的请求数据和响应数据传输,并且最后也已基本实现了,但考虑到保持原有系统的稳定性,在项目中并没有采用。仅仅使用这个组件进行数据交换,文件传输方面仍然使用原来的 FTP 方案。后续版本的 SGTrans 将完善文件传输的功能,所以在内部结构设计方面仍然需要考虑这一点。

第一次接触 socket 编程,再加上对于底层网络协议知之甚少,导致我走了一些弯路。在组件开发过程中,有三点值得总结一下:一是 socket 的关闭方式,MSDN 里其实也有讲到,但是起初并没有引起我的注意,直到实验时无意间注意到一个现象才意识到这一点;二是 FD_CLOSE 事件发生的时机,是与数据“非同步”的;三是 Winsock 中使用的一种在 Win32 API 中已被广泛应用的异步传输模式,即 Overlapped 方式。其中,前两点是在解决问题的过程中总结出来的,第三点是逐步学习并且最终得到成功运用的。

socket 的关闭方式

在 Server 端的底层代码中,下面的函数可以用来在一个连接对象上等待接收指定字节数的数据或者直至连接超时:

01: LRESULT ConnRecv(
02:     PCONNECTION     pConn,        // Connection object
03:     LPVOID          pBuffer,      // Data buffer pointer
04:     DWORD           dwBufferSize  // Buffer size
05: )
06: {
07:     DWORD           dwReceived    = 0;
08:     DWORD           dwFlags       = 0;
09:     WSABUF          wsaBuffer     = { dwBufferSize, (LPSTR)pBuffer };
10:     WSAOVERLAPPED   wsaOverlapped = { 0, 0, 0, 0, pConn->hIOEvent };
11:     DWORD           dwWaitResult  = 0;
12:     WSAEVENT        arrEvents[]   = { pConn->hExitEvent, pConn->hIOEvent };
13: 
14:     do
15:     {
16:         // Start an overlapped I/O
17:         int nResult = WSARecv(
18:             pConn->skt,           // Socket
19:             &wsaBuffer,           // Winsock buffers
20:             1,                    // Buffer count
21:             &dwReceived,          // Number of bytes received
22:             &dwFlags,             // Flags
23:             &wsaOverlapped,       // Overlapped structure
24:             NULL                  // Completion routine
25:         );
26: 
27:         // Decide what should we do next according to the return value
28:         if (nResult)
29:         {
30:             // See what happened...
31:             if (WSAGetLastError() != WSA_IO_PENDING)
32:                 // Something wrong, return to caller
33:                 return SOCKET_ERROR;
34: 
35:             // Wait for the pending I/O completion or exit signal
36:             DWORD dwResult = WSAWaitForMultipleEvents(
37:                 sizeof(arrEvents) / sizeof(WSAEVENT), // # of events
38:                 arrEvents,                            // Event handles
39:                 FALSE,                                // Wait all flag
40:                 pConn->dwIdleTimeout,                 // Timeout interval
41:                 FALSE                                 // Alertable flag
42:             );
43: 
44:             // Decide what should we do next according to the return value
45:             switch(dwResult)
46:             {
47:                 case WSA_WAIT_EVENT_0:        // Exit signal
48:                     ExitThread(0);
49:                 case WSA_WAIT_EVENT_0 + 1:    // I/O completion
50:                 {
51:                     // Get overlapped I/O result
52:                     if (!WSAGetOverlappedResult(
53:                             pConn->skt,       // Socket
54:                             &wsaOverlapped,   // Overlapped structure
55:                             &dwReceived,      // Bytes received
56:                             FALSE,            // Don't wait I/O completion
57:                             &dwFlags          // Flags
58:                         ))
59:                         // Failed to retrieve overlapped result, return
60:                         return SOCKET_ERROR;
61: 
62:                     // I/O completed successfully, fall through to advance the
63:                     // buffer pointer
64:                     break;
65:                 }
66:                 case WSA_WAIT_TIMEOUT:        // Connection timeout
67:                     ConnClose(pConn, WSAETIMEDOUT, TRUE);
68:                 default:                      // Wait failed, return
69:                     return SOCKET_ERROR;
70:             }
71:         }
72: 
76:         // Advance the buffer pointer
77:         wsaBuffer.buf = &wsaBuffer.buf[dwReceived];
78:         wsaBuffer.len -= dwReceived;
83: 
84:     } while(wsaBuffer.len); // Do loop until received desired bytes of data
85:     return 0;
86: }

可以看到,函数中并没有包含额外的检测 socket 关闭的代码。这是因为当初设计时曾经认为一旦 socket 被单方面关闭,另一方就可以检测到 WSAECONNRESET 错误,亦即在程序第 17 行的 Winsock 调用将失败,并在第 31 行检测到非 WSA_IO_PENDING 错误,而后来却发现有些时候(事实上是大多数时候)并不是这样的。当我使用自己编制的测试 Client 代码(一个 Console 小程序)对 Server 进行测试的时候,确实会发生 WSAECONNRESET 错误,一切正如当初预想的那样。但是如果我使用 Windows 自带的 Telnet 客户端测试 Server 连通性的时候却发现:当 Telnet 程序关闭后,Server 端的连接线程并没有出错退出,且 Server 程序的 CPU 占用率立即升至 100%。

进入 DEBUG 状态下跟踪,发现当 Telnet 程序关闭以后,第 17 行 WSARecv() 并没有返回任何错误,而是立即成功,并且 dwReceived 值为 0,从而导致连接线程进入了一个死循环。但是自己编写的 Console 测试 Client 端程序却没有这个问题,在关闭程序后 WSARecv() 立即返回错误。这个奇怪的现象一开始令我感到很迷惑,于是想到使用 netstat 命令查看一下连接状态:

Proto Local Address Foreign Address State
TCP 127.0.0.1:3716 127.0.0.1:27015 FIN_WAIT_2
TCP 127.0.0.1:27015 127.0.0.1:3716 CLOSE_WAIT

服务程序在 27015 端口监听(还好,似乎没有 RFC 文档规定这个端口属于 Counter-Strike 专用……),Telnet 客户端程序自动选择了 3716 端口。只不过这两个连接状态似乎不太寻常,于是上网查找。在 Apache 文档中找到了关于 FIN_WAIT_2 的一些解释(文章链接见后“相关文章”部分):

Starting with the Apache 1.2 betas, people are reporting many more connections in the FIN_WAIT_2 state (as reported by netstat) than they saw using older versions. When the server closes a TCP connection, it sends a packet with the FIN bit sent to the client, which then responds with a packet with the ACK bit set. The client then sends a packet with the FIN bit set to the server, which responds with an ACK and the connection is closed. The state that the connection is in during the period between when the server gets the ACK from the client and the server gets the FIN from the client is known as FIN_WAIT_2. See the TCP RFC for the technical details of the state transitions.

看来 TCP 连接关闭的过程并不是我想象中那么简单,需要双方都确认才算是一次“正常的”关闭动作。通过查看 MSDN 中 Graceful Shutdown, Linger Options, and Socket Closure 一章也明确了这一点。这也就不难解释我遇到的现象了:Telnet 程序退出时发出了断开连接的请求,即一个设置了 FIN bit 的包,Server 程序收到这个信号以后在 TCP 底层发送了一个 ACK 包。正如 Apache 文档中所说的那样,FIN_WAIT_2 并不与进程绑定,所以 Telnet 程序发出 FIN 包后立即退出了,其余的过程交由操作系统处理。另外,MSDN 中关于 WSARecv() 函数有下面的解释:

For connection-oriented sockets, WSARecv can indicate the graceful termination of the virtual circuit in one of two ways that depend on whether the socket is byte stream or message oriented. For byte streams, zero bytes having been read (as indicated by a zero return value to indicate success, and lpNumberOfBytesRecvd value of zero) indicates graceful closure and that no more bytes will ever be read. For message-oriented sockets, where a zero byte message is often allowable, a failure with an error code of WSAEDISCON is used to indicate graceful closure. In any case a return error code of WSAECONNRESET indicates an abortive close has occurred.

也就是说,流式套接字在正常关闭的时候,被动关闭一方的 WSARecv() 调用将会立即成功返回,并且指示接收到 0 个字节的数据。这也正是我遇到的情况,而我的问题在于:Server 在接收到 0 个字节的时候并没有检查 socket 是否已经开始关闭了(即 FD_CLOSE 事件已经发生),而是在持续调用 WSARecv() 希望接收到指定字节数的数据后再退出,从而导致进入了死循环。那么,为什么自己编写的客户端测试程序退出的时候不会产生这个问题?知道了套接字关闭的一些细节后,就不难解释这一点了:Windows 自带的 Telnet 程序是一个标准的 Windows 程序,它很可能是可以接收消息的,也就是说它可以对 WM_QUIT 消息作出响应,使得当单击窗口关闭按钮的时候有机会调用 closesocket() 或者 shutdown() 进行一个 Graceful Shutdown;而我写的 Client 端测试程序是一个 Console 程序,是没有消息队列的,不接收任何消息,除非显式调用 closesocket() 或者 shutdown()(而我为了编程简单,恰恰没有这么做),否则进程结束的时候任何资源都将被操作系统回收,包括套接字描述符。而这个过程显然不是那么“优美”的,也就是说在这种情况下进行了一个 Abortive Shutdown,这被认为是一个错误,此时 WSARecv() 才会报告失败(WSAECONNRESET)。

在弄清楚了问题的原因后,就不难提出解决方案了:只要在接收到 0 字节时检查 socket 关闭事件 FD_CLOSE 即可正确处理 Graceful Shutdown 的情况了。修改上述代码 72 行和 83 行之间的部分:

73:         // Check transfer result
74:         if (dwReceived)
75:         {
76:             // Advance the buffer pointer
77:             wsaBuffer.buf = &wsaBuffer.buf[dwReceived];
78:             wsaBuffer.len -= dwReceived;
79:         }
80:         else
81:             // 0 bytes received? We should check for socket closure
82:             ConnCheckForClosure(pConn);
FD_CLOSE 的“非同步”现象

严格上来说,TCP 的数据与控制信号在一条虚电路上传送,是不可能不同步的,这一点不同于 ATM。后者使用独立的信号和数据通道,所以 RELEASE 信号有可能比最后一段数据提前到达远程节点,从而可能造成数据丢失。但是 TCP 是不会发生这种情况的。根据 MSDN 中的说明,FIN 将在收到远程节点关于最后一个数据包的 ACK 信号以后才会发出,即使调用 shutdown() 或者 closesocket() 时发送队列中仍然有数据也是如此。

遗憾的是,TCP 不等于 TCP socket,socket 也不仅限于 TCP socket。socket 有它自己的一套机制。不知正宗的 BSD sockets 是怎样的,但 Winsock 在这方面显然没有它的底层做得更“地道”。MSDN 关于 WSAEventSelect() 函数的说明中有下面这段文字:

The FD_CLOSE network event is recorded when a close indication is received for the virtual circuit corresponding to the socket. In TCP terms, this means that the FD_CLOSE is recorded when the connection goes into the TIME WAIT or CLOSE WAIT states. This results from the remote end performing a shutdown on the send side or a closesocket. FD_CLOSE being posted after all data is read from a socket. An application should check for remaining data upon receipt of FD_CLOSE to avoid any possibility of losing data.

Please note Windows Sockets will record only an FD_CLOSE network event to indicate closure of a virtual circuit. It will not record an FD_READ network event to indicate this condition.

让人觉得沮丧(至少我本人感觉如此)的是,Winsock 在收到远程节点的 FIN 以后会立即触发 FD_CLOSE 事件,而不考虑当前接收缓冲区是否已空,甚至当缓冲区非空时也不会有 FD_READ 事件发生。而事实上,远程节点在发送 FIN 信号时,它的发送缓冲区一定是空的。这就造成了一个“信号与数据不是同步传输”的假象。而“完美”的同步模型似乎更应该是:收到 FIN 后等待接收缓冲区为空(即所有的数据都已被取走)后再报告 FD_CLOSE。好在这样并不会造成数据丢失,只是增加了少许麻烦:当检测到了一个 Graceful Shutdown 后并不意味着已经没有数据可读,而仅仅是 socket 层接收完成,所有的数据都已入接收缓冲区。

关于这个问题,在编码方面注意一下即可。在检测到 FD_CLOSE 事件后再检测一次缓冲区状态,若不空,则取到空为止。当然,更好的办法就是像 MSDN 中讲述的那样,接收到 0 个字节的数据后即认为开始了一个 Graceful Shutdown 过程,此时再检测 FD_CLOSE 事件,就像上面的代码那样做。

Overlapped I/O

Overlapped I/O 是在 Windows 中被广泛使用的异步传输模式,在文件、命名管道和串行设备上都可以使用。MSDN 对此有较详细的描述,故不在此赘述了。在 SGTrans 之前,我从未使用过这种传输方式,尽管 ReadFile()、WriteFile() 这些常用 API 都支持它。之所以决定在 SGTrans 中使用这种方式,很大程度上是受到了“HTTPmt”的启发。HTTPmt 是我在寻找 Winsock 编程范例时找到的一个 HTTP Server Demo 程序,它是《WinSock 2.0》这本书中的一个示例程序(这本书中所有 Demo 程序的下载页面见结尾处的“相关文章”部分),也是我在 socket 编程入门阶段阅读过的唯一一段代码。这段代码写得不错,socket 编程需要面对的常见问题在这里几乎都可以找到答案。

Overlapped I/O 方式使得程序的灵活性增强了不少。它可以在 I/O 完成时使用回调函数或设置事件的方式通知调用者,SGTrans 使用了后者,这样就可以在进行 I/O 时也有机会处理其它的事件和系统消息,看上去像一个“更聪明的阻塞式套接字”。MSDN 中提到的一点需要注意:并不是所有的 socket 都可以支持 Overlapped 方式,使用 WSASocket() 创建 socket 描述符时需要显示指定 WSA_FLAG_OVERLAPPED 标志;而使用 socket() 创建的 socket 将默认具有 WSA_FLAG_OVERLAPPED 属性,以保持向下兼容。

在刚刚接触使用 Overlapped I/O 方式工作的 socket 时,有一个问题不明确,即:在哪些情况下 Winsock 会调用 Completion Routine 或者设置完成事件?经过实验以后,发现在以下三种情况下 Completion Routine 会被调用(或完成事件被设置):

  • 接收到一段数据时
  • socket 发生错误(例如发生了一个 Abortive Shutdown)时,WSAGetOverlappedResult() 会返回 FALSE,调用 WSAGetLastError() 可以获得 socket 错误代码
  • 发生了一个 Graceful Shutdown 时,WSAGetOverlappedResult() 返回 TRUE,*lpcbTransfer 值为 0

需要注意的是,当接收缓冲区为空时,WSARecv() 也会返回 WSA_IO_PENDING,直到遇到上述三种情况之一,而非立即成功返回并报告接收了 0 个字节。否则 Graceful Shutdown 情况的判断将会较麻烦。

你可能感兴趣的:(Winsock 编程学习小结)