WinSock三种选择I/O模型

时间:2023-01-21 20:22:17

在《套接字socket及C/S通信的基本概念》和《WinSock编程基础》中,我们介绍了套接字的基本概念和WinSock API的基本调用规范。我们讨论了阻塞模式/非阻塞模式和同步I/O和异步I/O等话题。

从概念的角度,阻塞模式因其简洁易用便于快速原型化,但在应付建立连接的多个套接字或在数据的收发量不均、时间不定时却极难管理。另一方面,我们需要对非阻塞模式套接字的 WinSock API调用频繁返回的WSAEWOULDBLOCK错误加以判断处理也显得难于管理。WinSock套接字I/O模型提供了管理I/O完成通知的方法,帮助应用程序判断套接字何时可供读写。

共有6中类型的套接字I/O模型可让WinSock应用程序对I/O进行管理,它们包括blocking(阻塞)、select(选择)、WSAAsyncSelect(异步选择)、WSAEventSelect(事件选择)、overlapped(重叠)以及completionport(完成端口)。

本文讨论三种选择(都带select)模型。

 

1.基于套接字集合的select模型

(1)select模型概述

该模型时最初设计是在不使用UNIX操作系统的计算机上实现的,它们采用的是Berkeley套接字方案。select模型已集成到Winsock 1.1中,它使那些想避免在套接字调用过程中被无辜“锁定”的应用程序,采取一种有序的方式,同时进行对多个套接字的管理。

之所以称其为“select模型”,是由于它的“中心思想”便是利用select函数,实现对I/O的管理! 使用select模型,一般需要调用ioctlsocket函数将一个套接字从锁定模式切换为非锁定模式。

// 将套接字s设置为非阻塞模式

unsigned long nonBlocking = 1;

ioctlsocket(s, FIONBIO, (u_long*)&nonBlocking);

select模型本质上是一种分类处理思想,预先声明几个FD_SET(fd_set结构)集合(使用FD_ZERO初始化),例如ReadSet,WriteSet,然后调用宏FD_SET(s,&ReadSet)将关注FD_READ事件的套接字s添加到ReadSet集合,调用宏FD_SET(s,&WriteSet)将关注FD_WRITE事件的套接字s添加到WriteSet集合。其中宏FD_SET(SOCKET s, fd_set set)将s添加到set集合。从根本上说,fd_set数据类型代表着一系列按关注事件分类的套接字集合。

然后再调用select函数,对声明的集合ReadSet或WriteSet进行扫描,其函数原型如下:

int WSAAPI select(

int nfds,

        fd_set FAR * readfds,

        fd_set FAR * writefds,

        fd_set FAR *exceptfds,

        const struct timeval FAR * timeout );

其中,第一个参数 nfds会被忽略,一般赋值0。之所以仍然要提供这个参数,只是为了保持与早期的Berkeley套接字应用程序的兼容。其他的三个fd_set参数,一个用于检查可读性(readfds),一个用于检查可写性(writefds),另一个用于例外数据(exceptfds)。最后一个参数timeout用于决定select()等待I/O操作完成时最大忍耐时间,在等待时间内select()函数阻塞。当timeout为空时,无限等待直到有I/O完成;当*timeout=0时,select()函数立即返回,用做轮询。

例如我们只关注FD_READ事件,则select(0,&ReadSet,NULL,NULL,NULL)。WinSock要求这三个fd_set参数至少有一个不为NULL,而在其他平台下经常只关注最后一个参数用于实现相当于sleep()的延时功能。

select()函数用于判断套接字上是否存在数据(any data incoming?)或者能否向一个套接字写数据(output buffer available?)。调用select()会修改每个fd_set结构,它扫描注册到集合ReadSet和WriteSet中的套接字是否有读写事件发生,若有,则对集合进行更新,删除那些不存在待决I/O操作的套接字句柄。select()完成后,返回所有仍在fd_set集合中的套接字句柄总数。

然后,我们需要遍历查询之前注册到某个集合中的套接字是否仍为其中一部分。这需要调用FD_ISSET(SOCKET s, fd_set set)来测试套接字是否属于关注同类事件的套接字集合set。若是,则对待决的I/O进行处理(再次recv()/send()执行真正的拷贝)。

(2)select模型的应用实例

由于select模型源于Berkeley套接字方案,故常用作实现跨平台的POLL组件。在Linux下,select和poll是一个级别的,以下梳理了经典开源通信库中用到的select模型。

(1)curl/lib/select.h(c)中的Curl_socket_ready()调用。

/*

 * This is an internal function used for waiting for read or write

 * events on a pair of file descriptors.  It uses poll() when a fine

 * poll() is available, in order to avoid limits with FD_SETSIZE,

 * otherwise select() is used.

*/

(2)thttpd/fdwatch.h(c)中fdwatch()中的WATCH()调用。

/* fdwatch.h - header file for fdwatch package

**

** This package abstracts the use of the select()/poll()/kqueue()

** system calls.  The basic function of these calls is to watch a set

** of file descriptors for activity.

**/

(3)Apache Httpd/httpd/srclib/apr/下的

include/apr_poll.h中定义了Pollset Methods 的枚举变量apr_pollset_method_e。

poll/unix/select.c中的apr_poll()和impl_pollset_poll()调用。

(4)nginx/Windows使用的是 Win32的 API ,而不是Cygwin模拟的。当前只有select 这种网络模式,所以你不能指望它拥有高性能和高可扩展性。

nginx/src/event/modules

ngx_poll_module.c

ngx_select_module.c/ngx_win32_select_module.c中的ngx_select_process_events()调用。

(5)其他

C++ Sockets Library中的SocketHandler::ISocketHandler_Select()

Jrtplib中的RTPUDPv4Transmitter::WaitForIncomingData()

live555中的blockUntilReadable()和BasicTaskScheduler::SingleStep()

PeerCast中的WSAClientSocket::checkTimeout()

……

(3)select模型的局限性

select模型的优势在于能够从单个线程的多个套接字上进行多重连接及I/O管理,这样就避免了伴随阻塞套接字和多重连接的线程剧增。但可以加到fd_set结构中的最大套接字数量FD_SETSIZE在WINSOCK2.H中定义为64,底层程序强加了一个fd_set的最大值,通常情况下是1024。当然,我们可以分批FD_SET()→select()→FD_ISSET()来突破此限制。

select模型可以跨平台,对于千路并发的中小型服务器差不多够用。在具体平台开发网络通信程序时,可以结合平台特性,发挥平台机制优势。

 

2.基于Windows消息处理WSAAsyncSelcet模型

WinSock提供了一个有用的异步I/O通知模型。利用这个模型,应用程序可在一个套接字上,接收以Windows消息为基础的网络事件通知。具体的做法是在创建好一个套接字后,调用WSAAsyncSelect函数,它的函数原型如下:

int WSAAPI WSAAsyncSelect(

SOCKET s,

HWND hWnd,

u_int wMsg,

long lEvent);

调用WSAAsyncSelect()函数时,套接字即自动设置为非阻塞模式。

这个函数完成的功能是,将参数一所指定的套接字s(包括监听套接字和会话套接字)上感兴趣的一系列网络事件以位或|掩码组合形式(FD_XXX|FD_XXX)注册到参数四lEvent中,然后将lEvent中的网络事件通知绑定到参数二指定的窗口hWnd和参数三指定的自定义消息wMsg进行处理。

对于标准的Windows例程(常称为“WindowProc”),这个模型充分利用了Windows窗口消息处理机制。该模型亦得到了MFC(Microsoft Foundation Class,微软基础类库)对象CSocket的采纳。

由于使用Windows消息机制,故要想在应用程序中使用WSAAsyncSelect模型,首先必须用CreateWindow()函数创建一个窗口,再为该窗口提供一个窗口过程处理函数(WindowProc)。然后在WindowProc中读取自定义的WM_SOCKET消息内容,针对不同的网络事件进行相关处理。参考《VC网络通信API概览》中的CAsyncSocket/CSocket。

网络事件消息的wParam参数为对应发生该事件的套接字句柄,lParam参数的高字位(一般用WSAGETSELECTERROR宏取得HIWORD)包含出错码,lParam参数的低字位(一般用WSAGETSELECTEVENT宏取得LOWORD)则标识了网络事件代码(FD_XXX)。一般先检查高位,再检查低位进行网络事件的处理。在实际使用时,要注意各个网络事件(FD_XXX)发生的时机判断并进行合理的I/O处理。

    WSAAsyncSelect模型适合合作性的多任务消息GUI环境,优点是它可以在系统开销不大的情况下同时处理多个连接,而select模型则需要建立fd_set结构。缺点是即使不需要窗口的CUI应用程序也必须创建一个额外的暗窗口。同时,由于Windows消息泵本身的局限性,单窗口程序处理成千上万个套接字中的所有事件也可能成为性能瓶颈。

 

3.基于事件通知的WSAEventSelect模型

在WSAAsyncSelcet模型中,当利用WSAAsyncSelect()函数将套接字及其关注的网络事件绑定到一个窗口消息后,当有网络事件发生时,窗口会发出消息通知。我们还可以使用一种基于事件对象传信状态来发出网络事件通知的WSAEventSelect模型。

首先调用与WSAAsyncSelect同工的WSAEventSelect函数,其原型如下:

int WSAAPI WSAEventSelect(

SOCKET s,

        WSAEVENT hEventObject,

        long lNetworkEvents );

调用WSAEventSelect()函数时,套接字即自动设置为非阻塞模式。

调用WSAEventSelect()函数将参数一指定的套接字s关注的网络事件以位或|掩码组合形式(FD_XXX|FD_XXX)注册到参数三lNetworkEvents,并将该套接字绑定到参数二指定的事件对象hEventObject。这样当lNetWorkEvents中的事件发生时,Windows将hEventObject置信(由Unsignaled变为Signaled)。

#define WSAEVENT                HANDLE

当事件对象受信后,我们需要获得这个通知,这需要调用等待事件对象的同步函数,主要有WaitForSingleObjectWaitForMultipleObjectsWSAWaitForMultipleEvents

函数WaitForSingleObject定义如下:

WINBASEAPI DWORD WINAPI

WaitForSingleObject(

HANDLE hHandle,

DWORD dwMilliseconds );

对于函数WaitForSingleObject,如果超过参数二dwMilliseconds设定的时限,函数返回WAIT_TIMEOUT;在限定时限内,只有当其等待的对象受信(例如线程返回,事件受信等)后,该函数才返回,返回值为WAIT_OBJECT_0,此时,Windows将自动重置该对象。

函数WaitForMultipleObjects定义如下:

WINBASEAPI DWORD WINAPI

WaitForMultipleObjects(

        DWORD nCount,

        CONST HANDLE *lpHandles,

        BOOL bWaitAll,

        DWORD dwMilliseconds );

    WinSock中的WSAWaitForMultipleEvents函数原型如下:

DWORD WSAAPI WSAWaitForMultipleEvents(

        DWORD cEvents,

        const WSAEVENT FAR * lphEvents,

        BOOL fWaitAll,

        DWORD dwTimeout,

        BOOL fAlertable);

WaitForSingleObject不同的是,WaitForMultipleObjectsWSAWaitForMultipleEvents支持在多个对象的等待。它们支持nCount/cEvents和lpHandles/lphEvents参数定义了由HANDLE/WSAEVENT对象构成的一个数组。在这个数组中,nCount/cEvents指定的是事件对象的数量,而lphEvents对应的是一个指针,用于直接引用该数组。要注意的是,WaitForMultipleObjects/WSAaitForMultipleEvents只能支持由MAXIMUM_WAIT_OBJECTS/WSA_MAXIMUM_WAIT_EVENTS对象规定的一个最大值,在此定义成64个。因此,针对发出WSAWaitForMultipleEvents调用的每个线程,该I/O模型一次最多都只能支持64个套接字。假如想让这个模型同时管理不止64个套接字,必须创建额外的工作者线程,以便等待更多的事件对象。

WSAWaitForMultipleEvents的最后一个参数是fAlertable,在我们使用WSAEventSelect模型的时候,它是可以忽略,常设为FALSE,该参数主要用于重叠I/O的完成例程处理模型中使用。其他参数意义同WaitForMultipleObjects

参数一指定了对象个数,参数二则往往是一个对象数组。同样,若超过参数四设定的时限,它们都会返回WSA_WAIT_TIMEOUT。在设定时限内,若参数三WaitAll = FALSE,则只要其等待的事件对象中有一个受信,该函数即返回WAIT_OBJECT_i(i=[0,nCount-1])或WSA_WAIT_EVENT_i(i=[0,cEvents-1]);若WaitAll =TRUE,则要等到所有对象都受信后该函数才返回。直到所有等待的对象都受信,系统才将所有受信事件对象状态重置(由Signaled变为Unsignaled)。应用程序往往根据返回的索引(相对预定义其实索引)使用switch-case分发流程处理不同的事件。对于多个事件,往往WaitAll被设置成FALSE,这样只要有事件发生就及时处理。

调用WSAWaitForMultipleEvents返回受信事件对象的索引,根据索引也可以知道其对应的套接字。因为在实际程序中,一个套接字绑定一个事件对象:Socket[index]←→WSAEvent[index]。

在Windows消息机制处理WinSock事件中,有网络事件发生时,Windows根据消息号取出消息内容进行处理。在事件通知模型中,当调用WSAWaitForMultipleEvents接到和消息通知对应的事件通知后,就需要查获发生的网络事件(类比消息内容)。WSAEnumNetworkEvents函数负责查获一个套接字上发生的网络事件,其原型如下:

WINSOCK_API_LINKAGE int WSAAPI

WSAEnumNetworkEvents(

SOCKET s,

        WSAEVENT hEventObject,

        LPWSANETWORKEVENTS lpNetworkEvents );

传递套接字参数s,当然这里是上一步中WSAWaitForMultipleEvents 返回的Index对应的socket,调用WSAEnumNetworkEvents函数来获取套接字s上所发生的事件,并将其保存到lpNetworkEvents结构中。

hEventObject参数则是可选的;它指定了一个事件句柄,对应于打算重设的那个事件对象。当然,如果设置该值,应该为上一步中WSAWaitForMultipleEvents返回的Index对应的socket绑定的hEventObject。由于事件对象处在一个“已传信”(Signaled)状态,所以可将它传入,让Windows将其重置为“未传信”(Unsignaled)状态。如果不想用hEventObject参数,那么必须调用WSAResetEvent/ResetEvent函数来重置事件对象。

将发生的网络事件存储在lpNetworkEvents结构中之后,接下来就需要针对事件进行处理(类比WindowProc中的消息处理)。WSANETWORKEVENTS数据结构定义如下:

typedef struct _WSANETWORKEVENTS {

        long lNetworkEvents;

        int iErrorCode[FD_MAX_EVENTS];

WSANETWORKEVENTSFAR * LPWSANETWORKEVENTS;

其中参数一lNetworkEvents存放着套接字s上发生的所有网络事件。与注册事件时使用位或|掩码相反,这里一般采用位与&析取相应的网络事件代码,即将lNetworkEvents与FD_XXX进行位与运算,若返回1则表示有FD_XXX网络事件发生。

这里,我们看到了FD_ISSET的影子。可以看出,WSAEventSelect是select模型和WSAAsyncSelect模型的综合。这个模型中,每个Socket都有一个事件对象,当有网络事件发生时,与窗口消息相对应的事件对象受信,然后遍历该事件对象对应的套接字上发生的网络事件。而select中是对socket按事件进行分类处理,通过FD_ISSET判断socket是否属于某个FD_SET

参数二iErrorCode指定的是一个错误代码数组,同lNetworkEvents中的事件关联在一起。针对每种网络事件,都存在着一个特殊的事件索引,名字与事件类型的名字类似,只是要在事件名字后面添加一个“_BIT”后缀字串即可。例如,对FD_READ事件类型来说,iErrorCode数组的索引标识符便是FD_READ_BIT,若无错误,其值为0。下述代码片断针对FD_READ事件的处理对此进行了阐释:

if((NetworkEvents.lNetworkEvents & FD_READ)

{

// 错误发生

       if(NetworkEvents.iErrorCode[FD_READ_BIT] != 0))

{

         printf("FD_READ failed with error %d/n",NetworkEvents.iErrorCode[FD_READ_BIT]);

}

// 处理FD_READ事件

……

}

另外,由于监听套接字的特殊性,往往利用一个事件对象来专门通知监听套接字上客户端接入事件。当有客户端请求接入(connect)时,accept返回时,我们可以调用WSASetEvent将事件置信,再调用WSAWaitForMultipleEvents获取通知,再做一些处理。有时需要主动调用WSAResetEvent即时重置事件对象,以便使其进入下一轮询。

 

参考

Network Programming for Microsoft Windows》  Anthony Jones,Jim Ohlund