Winsock I/O方法
Winsock以socket模型和socket I/O模型来控制如何在一个socket处理输入/输出。一个socket模式简单的决定当以一个socket来调用Winsock函数时它们的行为。另一方面,一个socket I/O模型描述了应用程序如何管理和处理在socket上的输入输出。
Winsock分为两种socket模式:阻塞和非阻塞。接下来将详细描述这些模式并举例说明一个应用程序如何使用它们来管理I/O。在后面,你将看到,windows提供了一些有趣的I/O模型,来帮助应用程序管理在一个或多个socket上的同时异步通信:阻塞、select、WSAAsyncSelect、WSAEventSelect、overlapped I/O、完成端口。在结尾部分,我们将回顾各种socket模型和I/O模型的优缺点,以帮助你确定哪种模型最适合你的应用。
在windows NT或之后的版本支持上述所有的I/O模型。
Sock Modes
windows 套接字以两种模式来执行I/O操作:阻塞和非阻塞。在阻塞模式下,winsock函数调用I/O操作函数 --- 比如send和recv --- 函数将等待直到操作完成才会返回到调用程序。在非阻塞模式中,winsock函数将立即返回。
Blocking Mode
在一个阻塞的socket上调用的所有的winsockAPI都有这样的行为 --- 阻塞一定时间。大多数Winsock应用程序都遵循生产-消费者模型,在这样的模型中,应用程序读(或写)一定字节数的数据并且在这些数据上执行一些计算。如下的代码片段演示了这种模型:
SOCKET sock;
char buff[256];
int done = 0,
nBytes;
...
while(!done)
{
nBytes = recv(sock, buff, 65);
if (nBytes == SOCKET_ERROR)
{
printf("recv failed with error %d\n",
WSAGetLastError());
Return;
}
DoComputationOnData(buff);
}
这段代码的问题是,如果没有数据可读,recv将可能永远不会返回,因为声明表示recv只有在从系统输入缓冲中读了一些字节的数据后才会返回。一些程序员可能忍不住想要窥视系统缓冲中有多少数据,这可以通过以MSG_PEEK调用recv(MSG_PEEK 查看当前数据。数据将被复制到缓冲区中,但并不从输入队列中删除),或者以FIONREAD选项调用ioctlsocket()(返回缓冲中有多少数据)。偷窥数据而没有真正的读取被认为是不好的编程习惯,无论如何都应该避免(读取数据之后,实际上会把它们从系统缓冲中删除)。窥视数据的开销是不容忽视的,因为检测可用的数据量需要一个或多个系统调用。当然,这样的开销是调用recv从系统缓冲中删除数据的开销的一部分。为了避免这样的开销,你不应该使用不断窥视系统网络缓冲中数据的方式来防止应用程序由于数据不足而完全僵住。一种方法是把应用程序分为读线程和计算线程。两个线程共享同一块数据缓冲。以一个同步对象来保护对共享缓冲的访问,比如一个event或mutex. 读线程不断的从网络上读取数据并把数据放到共享缓冲中。当读线程已经读了计算线程需要的最少数据量时,它可以把某个事件标记为有符号状态以通知计算线程开始执行。然后计算线程从缓冲中读取并删除数据并执行必要的计算。
下面这段代码通过两个函数示例了这种方法:ReadThread负责读取网络数据,ProcessThread执行数据上的运算。#define MAX_BUFFER_SIZE 4096
// Initialize critical section (data) and create
// an auto-reset event (hEvent) before creating the
// two threads
CRITICAL_SECTION data;
HANDLE hEvent;
SOCKET sock;
TCHAR buff[MAX_BUFFER_SIZE];
int done=0;
// Create and connect sock
...
// Reader thread
void ReadThread(void)
{
int nTotal = 0,
nRead = 0,
nLeft = 0,
nBytes = 0;
while (!done)
{
nTotal = 0;
nLeft = NUM_BYTES_REQUIRED;
// However many bytes constitutes
// enough data for processing
// (i.e. non-zero)
while (nTotal != NUM_BYTES_REQUIRED)
{
EnterCriticalSection(&data);
nRead = recv(sock, &(buff[MAX_BUFFER_SIZE - nBytes]),
nLeft, 0);
if (nRead == -1)
{
printf("error\n");
ExitThread();
}
nTotal += nRead;
nLeft -= nRead;
nBytes += nRead;
LeaveCriticalSection(&data);
}
SetEvent(hEvent);
}
}
// Computation thread
void ProcessThread(void)
{
WaitForSingleObject(hEvent);
EnterCriticalSection(&data);
DoSomeComputationOnData(buff);
// Remove the processed data from the input
// buffer, and shift the remaining data to
// the start of the array
nBytes -= NUM_BYTES_REQUIRED;
LeaveCriticalSection(&data);
}
阻塞sockets的一个缺点是,同时处理多个socket通信是变得困难。依照上面的方案,我们可以修改应用程序以便每一个socket都有自己的读线程和计算线程。这种修改虽然不怎么令人愉快,但这是一种灵活的方法。唯一的不足是,一旦你开始处理大量的sockets,这种方法就不再适用了。
Non-blocking Mode
阻塞sockets的替代方案是非阻塞sockets。使用非阻塞的sockets会有点挑战,但它和阻塞模式一样强大,而且还有一些优点。下面的例子演示如何创建一个socket并把它设为非阻塞模式:
SOCKET s;
unsigned long ul = 1;
int nRet;
s = socket(AF_INET, SOCK_STREAM, 0);
nRet = ioctlsocket(s, FIONBIO, (unsigned long *) &ul);
if (nRet == SOCKET_ERROR)
{
// Failed to put the socket into non-blocking mode
}
一旦一个socket被设置为非阻塞模式,在其上调用的接收数据和发送数据或连接管理的Winsock API都将立即返回。在大多数情况下,这些调用返回失败(WSAEWOULDBLOCK error),意思是该请求操作在调用期间没时间完成。例如,调用recv返回WSAEWOULDBLOCK表示输入缓冲中没有数据。通常应该调用多遍直到遇到一个成功的返回值。
Table 5-2 WSAEWOULDBLOCK Errors on Non-blocking Sockets | |
Function Name | Description |
WSAAccept and accept | The application has not received a connection request. Call again to check for a connection. |
closesocket | In most cases, this means that setsockopt was called with the SO_LINGER option and a nonzero timeout was set. |
WSAConnect and connect | The connection is initiated. Call again to check for completion. |
WSARecv, recv, WSARecvFrom, and recvfrom | No data has been received. Check again later. |
WSASend, send, WSASendTo, and sendto | No buffer space available for outgoing data. Try again later. |
因为非阻塞调用常常返回WSAEWOULDBLOCK错误,你应该检查所有的返回值并准备处理失败。很多程序员因此陷入这样的情况:不断的调用一个函数直到它返回成功。例如,在一个紧凑循环中放一个对recv的调用来读取200字节的数据,这和之前提到的以MSG_PEEK来不断查询一个阻塞的socket的方法一样差劲。Winsock的socket I/O模型能够帮助一个应用程序判断一个socket是否准备好了读或写。
每一个socket模式:阻塞或非阻塞,都有优点也有不足之处。阻塞模式从概念上来讲比较容易使用,但是当处理多个sockets,或以任意次数发送和接收不定数量的数据时,就变得难于管理。另一方面,非阻塞sockets比较难使用,因为需要写更多的代码可能返回的WSAEWOULDBLOCK错误。Socket I/O模型帮助应用程序以异步方式同时管理多个sockets通信。Socket I/O 模型
有六种基本的socket I/O模型提供给Winsock应用程序用来管理I/O:阻塞、select、WSAAsyncSelect、WSAEventSelect、overlapped、完成端口。这部分将解释每种模型的特征并着重介绍如何使用它们来开发能够同时管理一个或多个socket请求的应用程序。
注意,从技术角度讲,可能有一种很直接的非阻塞I/O模型,即,一个应用程序使用ioctlsocket 把所有的sockets设置为非阻塞模式。然而,情况很快就变得很糟糕,因为应用程序将会花费极大部分时间循环在这些sockets上执行I/O操作直到返回成功。阻塞模型
大多数Winsock程序员一开始都是使用阻塞模型,因为它是最简单,也是最容易使用的I/O模型。使用这种模型的应用程序一般为每个socket创建1到2个线程用来处理I/O。然后每个线程简单的调用send或recv等待读或写。
阻塞模型的最大优点是它的简单性。对于非常简单的程序和快速原型开发,这种模型很有用。它的缺点是,不适用于有多个连接的情况,因为创建多个线程将消耗宝贵的系统资源。select模型
select模型是另一个在winsock中广泛使用的模型。该模型以使用select管理I/O为中心,所以我们称之为select模型。该模型起源于实现Berkeley套接字的类UNIX系统。该模型在winsock 1.1中被引入。它允许想要避免阻塞socket的程序具备有序管理多个套接字的能力。因为winsock 1.1是向后兼容Berkeley套接字实现的,所以一个使用select函数的Berkeley套接字程序,从技术上讲,应该可以毫无修改的就能运行在windows系统上。
select函数能用来判断一个套接字上是否有数据、一个套接字是否准备好写。该函数用来防止你的应用程序阻塞在一个I/O调用上,比如在设置为阻塞模式的socket上调用send或recv,也可以用来避免非阻塞模式的socket返回WSAEWOULDBLOCK错误,这是该函数之所以存在的原因。select函数阻塞I/O操作直到参数指定的事件发生。select函数原型如下:int select(
int nfds,
fd_set FAR * readfds,
fd_set FAR * writefds,
fd_set FAR * exceptfds,
const struct timeval FAR * timeout
);
第一个参数,nfds可以忽略,它的存在是为了兼容Berkeley套接字应用程序。这里有三个fd_set参数:一个用于检查可读性(readfds),一个用于检查可写性(writefds),一个用于带外数据(exceptfds)。fd_set本质上代表一个sockets集合。
readfds指定满足如下条件的sockets:
• 有数据可读
• 连接被关闭、重置、或终止
• 调用了listen函数,并且有新连接请求到来,accept函数将返回成功。
writefds指定满足如下条件的sockets:
• 可以发送数据
• 正在进行的非阻塞的连接调用连接成功
exceptfds指定满足如下条件的sockets:
• 正在进行的非阻塞连接调用返回失败
• 有OOB数据(带外数据)可读,传输层协议使用带外数据来发送一些重要数据
例如,如果你想要测试一个socket的可读性时,你必须把它加到readfds集合,并且等待select函数完成。当select函数完成返回时,你必须你的socket是否仍然是readfds的一部分。如果是,你的socket就准备好读了,你可以开始从它读取数据。readfds, writefds, exceptfds中的两到三个可以为NULL值(但至少有一个不能为NULL),而且任何非空的集合中必须至少包含一个socket句柄。否则,select函数将没什么可等的。最后一个参数,timeout,是一个struct timeval指针,它决定select在I/O完成前,等待多久。如果timeout为NULL,select将无限期阻塞,直到至少有一个描述符满足指定条件。timeval结构定义如下,struct timeval
{
long tv_sec;
long tv_usec;
};
tv_sec单位为秒,tv_usec单位为微秒。值为{0,0}的timeout指示select立即返回,这种方式运行应用程序轮询select操作。处于性能考虑,应该避免这样使用。当select成功执行完成,它将返回fd_set结构中处于就绪状态的sockets句柄数量。如果超时,则返回0. 出错则返回SOCKET_ERROR。
在你开始使用select来监听sockets之前,你的应用程序必须创建一个或者所有的read, write, exception fd_set结构,并把socket句柄加入一个集合中。当你把一个socket赋值给了其中一个集合后,你就可以通过询问select,来判断所关注的I/O事件是否发生了。Winsock同了下面这几个宏用来操作和检查fd_set集合:
• FD_ZERO(*set) 把一个集合初始化一个空集合。一个集合在使用前应该总是被清空。
• FD_CLR(s, *set) 从集合set中删除sockt s
• FD_ISSET(s, *set) 检查s是否是set的一个成员,是则返回TRUE
• FD_SET(s,*set) 把socket s添加到set
举个例子,假若你想要在不使用阻塞的情况下,弄清楚一个socket何时才可以安全的读取数据,你只需简单的把你的socket的通过使用FD_SET宏赋值给readfd集合,然后调用select函数。下述5步描述了使用select来处理一到多个sockets的应用程序的基本模型:
1 使用FD_ZERO初始化感兴趣每一个fd_set
2 使用FD_SET把socket句柄加入到相应的fd_set集合
3 调用select函数并等待I/O活动触发了所提供的fd_set集合中的一到多个socket句柄。当select函数完成时,它返回所有fd_set集合中被触发的socket句柄的数量,并更新相应 的fd_set集合。
4 根据select返回值,通过使用FD_ISSET宏检查每一个fd_set,你的应用程序可以判断哪一个socket处于I/O就绪状态。
5 知道哪一个socket处于I/O就绪状态后,处理相应的I/O然后返回第1步继续执行select过程。(没错,要返回第一步,重新初始化fd_set集合。因为select返回后,相应的fd_set 会被更新,以指明哪写sockets处于就绪状态)。
当select返回时,它修改每一个fd_set,删除其中没有I/O就绪操作的的socket句柄。这就是为什么你应该像第4步一样,使用FD_ISSET来判断是否一个特定的socket是否是一个fd_set集合的一部分。下面的代码概括了单socket应用程序使用select模型的基本步骤。想要添加更多的sockets到该应用程序中,只需简单的涉及维护一个列表或者一个额外的sockets数组。
SOCKET s;
fd_set fdread;
int ret;
// Create a socket, and accept a connection
// Manage I/O on the socket
while(TRUE)
{
// Always clear the read set before calling
// select()
FD_ZERO(&fdread);
// Add socket s to the read set
FD_SET(s, &fdread);
if ((ret = select(0, &fdread, NULL, NULL, NULL))
== SOCKET_ERROR)
{
// Error condition
}
if (ret > 0)
{
// For this simple case, select() should return
// the value 1. An application dealing with
// more than one socket could get a value
// greater than 1. At this point, your
// application should check to see whether the
// socket is part of a set.
if (FD_ISSET(s, &fdread))
{
// A read event has occurred on socket s
}
}
}
使用select模型的好处是,使得单个线程可以处理多个sockets上的连接和I/O操作。这可以避免使用阻塞模型来处理多个连接时的线程膨胀情况。不足之处是,能够添加到fd_set结构中的sockets有个最大数量限制。默认情况下,这个最大值由FD_SETSIZE定义。为了加大该限制,一个应用程序可能重新定义FD_SETSIZE为更大的值。要注意这样的定义要放在包含WinSock2.h之前。虽然这个值可以改为更多的值,但如果处理大数量的sockets, select的另一个不足之处,低效,就无法避免了。幸好,我们还有更先进的模型。
来看一个用select模型实现echo服务器的例子
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>
int open_listenfd(int port);
#define MAXBUF 1024
int main(int argc, char **argv)
{
int listenfd, connfd, port, n, maxfd, fd, addrlen;
struct sockaddr_in clientaddr;
fd_set readfds, readyfds;
char buff[MAXBUF+1];
if (argc != 2)
{
fprintf(stderr, "usage: %s <port>\n", argv[0]);
return 0;
}
port = atoi(argv[1]);
listenfd = open_listenfd(port);
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);
maxfd = listenfd;
addrlen = sizeof(struct sockaddr_in);
while (1)
{
//每次使用前都要重新初始化fd_set
readyfds = readfds;
/* windows下,第一个函数可以忽略,设置为0即可 */
n = select(maxfd+1, &readyfds, NULL, NULL, NULL);
printf("n = %d\n", n);
if (n>0)
{
if (FD_ISSET(listenfd, &readyfds))
{
connfd = accept(listenfd, (struct sockaddr *)&clientaddr, &addrlen);
printf("new connection from: %s:%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
FD_SET(connfd, &readfds);
if (connfd > maxfd)
maxfd = connfd;
}
/*
这里windows和linux稍有不同
*/
/*********************************** windows 版本 **********************************************/
/* for(int i=0; i<(int)readfds.fd_count; i++)
{
int fd = readfds.fd_array[i];
if (fd != listenfd && FD_ISSET(fd, &readyfds))
{
n = recv(fd, buff, MAXBUF, 0);
if (n<=0) //对方关闭连接
{
perror("recv: ");
FD_CLR(fd, &readfds);
close(fd);
}
else
{
buff[n] = 0;
printf("fd: %d -- %s\n\n", fd, buff);
}
}
}*/
/*********************************** windows 版本 **********************************************/
/* 这里的循环上限是maxfd,并不是select的返回值n,这也是select效率低下的一方面:即时只有一个就绪的
socket,也要遍历所有加入到fd_set集合中的sockets
*/
for (fd=0; fd<=maxfd; fd++)
{
if (fd != listenfd && FD_ISSET(fd, &readyfds))
{
n = recv(fd, buff, MAXBUF, 0);
if (n<=0) //对方关闭连接
{
perror("recv: ");
FD_CLR(fd, &readfds);
close(fd);
}
else
{
buff[n] = 0;
printf("fd: %d -- %s\n\n", fd, buff);
}
}
}
}
}
return 0;
}
int open_listenfd(int port)
{
struct sockaddr_in myAddr;
int listenfd;
/* Create a socket descriptor */
if ( (listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
return -1;
/*
listenfd will be an end point for all requests to port on any IP address for this host
*/
bzero((char *)&myAddr, sizeof(myAddr));
myAddr.sin_family = AF_INET;
myAddr.sin_port = htons(port);
myAddr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listenfd, (struct sockaddr *)&myAddr, sizeof(myAddr)) < 0)
{
close(listenfd);
return -1;
}
if (listen(listenfd, 1024) < 0)
{
close(listenfd);
return -1;
}
return listenfd;
}
完成端口模型
对于新手,完成端口模型看起来很复杂,因为较之其它模型的初始化步骤,该模型需要做额外的工作以把sockets添加到完成端口。然而,你后面将会明白,一旦你理解了这些步骤,就知道它们其实并不复杂。对于必须要要同时管理多个sockets的应用程序,端口模型可以为其提供最好的系统性能。然而,该模型只有Windows NT、windows 2000、 Windows XP支持即其后的版本支持。与其它模型相比,完成端口模型提供了最好的扩展性。这个模型和好的用于处理成百上千的sockets。
本质上,完成端口模型要求你创建一个windows完成端口对象,该对象用来管理重叠的I/O请求,并使用特定数量的线程来处理完成的重叠I/O请求。注意,一个完成端口实际上是一个windows I/O结构,该结构不只能够接受socket句柄。要开始使用完成端口模型,你需要创建一个完成端口对象用来管理任意数量的sockets句柄的多个I/O请求。这通过调用CreateIoCompletionPort函数来完成。该函数定义如下:
HANDLE CreateIoCompletionPort(
HANDLE FileHandle,
HANDLE ExistingCompletionPort,
DWORD CompletionKey,
DWORD NumberOfConcurrentThreads
);
在详细讨论个参数的细节之前,要提醒注意,该函数实际上被用于两个不同的目的。
● 用于创建一个完成端口对象
● 用于把一个句柄关联到一个完成端口
当你初始创建一个完成端口对象时,唯一需要注意的参数是NumberOfConcurrentThreads;前面三个参数可以被忽略。参数NumberOfConcurrentThreads定义了允许并发在一个完成端口上执行的线程数。理想的情况是,在但线程上,只需要一个线程来服务于完成端口,这样可以避免上下午切换。给该参数传递0值是告诉系统允许和系统中处理器数目一样多的线程并发执行。如下代码创建一个完成端口:
CompletionPort =CreateIoCompletionPort(INVALID_HANDLE_VALUE,
NULL, 0, 0);
这将返回一个句柄,该句柄用来标识该完成端口。
工作者线程和完成端口
完成端口对象成功创建后,你就可以开始把sockets句柄关联到该对象。然而,在关联sockets句柄到该对象之前,你必须创建一到多个工作者线程,这些线程用来在当socket I/O请求被发送到该完成端口对象时服务该完成端口。讲到这里,你可能会问,应该创建多少个线程来服务该完成端口呢?实际上,这是完成端口模型复杂性体现之一,因为需要用来服务I/O请求的线程数量取决于你的应用程序的总体设计。当调用CreateIoCompletionPort时指定的并发线程数和创建的工作者线程数是相互区别的,注意到这一点很重要。它们表示的不是同样的东西。之前我们推荐你应该通过CreateIoCompletionPort为每个处理器指定一个线程以避免上下文切换。CreateIoCompletionPort函数的NumberOfConcurrentThreads参数显示告诉系统只允许n个线程同时操作该完成端口。如果你在该完成端口上创建了多于n的工作者线程,也只允许同时有n个线程操作该完成端口。(实际上,系统可能在一小段时间会超过这个值,不过系统会快速的讲到你在CreateIoCompletionPort指定的值)你可能会奇怪,会什么你会创建多于调用CreateIoCompletionPort指定的线程数呢?正如我们前面提到的,这取决于你的应用程序的总体设计。假如你的其中之一个线程调用了一个函数 --- 比如Sleep或者WaitForSingleObject --- 并且被挂起了,另一个线程就可以使用它所占用的资源(比如完成端口)。换句话说,你总是希望有和你调用CreateIoCompletionPort指定允许的线程数一样多的线程可用于执行。因此,如果你预期你的工作者线程可能会阻塞,那么,创建数量多于CreateIoCompletionPort参数NumberOfConcurrentThreads指定的线程数的工作者线程就是合理的。
一旦你有了足够多的工作者线程来服务完成端口上的I/O请求,你就可以开始把socket句柄关联到该完成端口。这要求你在一个已创建的完成端口上调用CreateIoCompletionPort函数并提供前三个参数 --- FileHandle, ExistingCompletionPort, and Completion Key – 这三个参数提供了与之相关的socket信息。FileHandle参数代表一个与完成端口相关联的socket句柄。ExistingCompletionPort指明socket被关联到的完成端口。CompletionKey参数指明每次出来某个特定的socket句柄是可以访问的数据。应用程序可以通过该参数指定与socket相关的任何数据类型。我们叫它句柄单元数据(per-handle data),因为它代表一个与句柄相关联的数据。这是个很有用的参数,你可以把这个参数当做一个指向一个数据结构的指针,而该数据结构包含socket句柄和其它socket相关信息。在本章的后面,我们将会看到,服务于完成端口的线程实例可以通过这个参数来获取与socket相关的信息。
让我们根据上述描述来开始构建一个基本的应用程序框架。下面这个例子示例了如何使用一个完成端口模型开始开发一个echo服务器。在代码中,我们遵循一下步骤:
1. 创建一个完成端口。第四个参数设为0,指定同一时刻只允许每个处理器一个线程线程运行在完成端口行。
2. 检查系统上存在多少个处理器。
3. 基于第2步获得的处理器信息,创建工作者线程来处理完成端口上已经完成的I/O请求。在这个简单的例子中,我们为每个处理器创建一个线程,因为我们预期我们的线程不会挂起以至没有足够的线程分配给每个处理器执行。调用CreateThread函数时,你必须提供一个线程例程,这个线程例程是线程开始执行的地方。我们将在这部分的讨论工作者线程的职责。
4. 准备一个监听套接字来监听端口5150上的连接。
5. 使用accept函数接受到来的连接
6. 创建一个表示句柄单元数据的数据结构,并且把已接受的连接的socket句柄存储在该数据结构中。
7. 通过调用CreateIoCompletionPort函数,把accept函数返回的新的socket句柄关联到完成端口。通过completion key parameter参数把句柄单元数据传给CreateIoCompletionPort。
8. 开始处理已接受连接上的I/O。
I/O模型的选择
当设计应用程序时怎选择I/O模型呢?每一个模型都有它的优点和不足之处。与开发一个简单,有多个服务线程的阻塞模型程序相比,所有的I/O模型都要进行相当复杂的编码。
客户端开发
当你开发一个管理一到多个sockets的客户端应用程序时,我们推荐你使用重叠I/O模型或者出于性能考虑优先使用WSAEventSelect模型。但是,如果你正在开发基于windows的应用程序,WSAAsyncSelect可能是更好的选择,因为用WSAAsyncSelect本身的消息处理模型,你的应用程序就具备了处理消息的能力。
服务端开发
如果你正在开发一个要处理多个sockets的服务器,处于性能方面考虑,我们推荐你优先使用重叠事件I/O。但是,如果你的服务器要服务于大量的I/O请求,你应该考虑使用完成端口模型,这回获得更好的系统性能。
翻译自《windows网络编程》第二版,Winsock I/O Methods一章