WinSock重叠I/O模型

一.重叠I/O模型的概念

当调用ReadFile()WriteFile()时,如果最后一个参数lpOverlapped设置为NULL,那么线程就阻塞在这里,直到读写完指定的数据后,它们才返回。这样在读写大文件的时候,很多时间都浪费在等待ReadFile()WriteFile()的返回上面。如果ReadFile()WriteFile()是往管道里读写数据,那么有可能阻塞得更久,导致程序性能下降

为了解决这个问题,Windows引进了重叠I/O的概念,它能够同时以多个线程处理多个I/O。其实你自己开多个线程也可以处理多个I/O,但是系统内部对I/O的处理在性能上有很大的优化。它是Windows下实现异步I/O最常用的方式。

Windows为几乎全部类型的文件提供这个工具:磁盘文件、通信端口、命名管道和套接字。通常,使用ReadFile()WriteFile()就可以很好地执行重叠I/O

重叠模型的核心是一个重叠数据结构。若想以重叠方式使用文件,必须用 FILE_FLAG_OVERLAPPED标志打开它,例如:

HANDLE hFile = CreateFile(lpFileName, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);

如果没有规定该标志,则针对这个文件(句柄),重叠I/O是不可用的。如果设置了该标志,当调用ReadFile()WriteFile()操作这个文件(句柄)时,必须为最后一个参数提供OVERLAPPED结构:

// WINBASE.H

typedef struct _OVERLAPPED{

        DWORD   Internal;

        DWORD   InternalHigh;

        DWORD   Offset;

        DWORD   OffsetHigh;

        HANDLE  hEvent; //关键的一个参数

}OVERLAPPED, *LPOVERLAPPED;

头两个32位的结构字InternalInternalHigh由系统内部使用;其次两个32位结构字OffsetOffsetHigh使得可以设置64位的偏移量,该偏移量是要文件中读或写的地方。

因为I/O异步发生,就不能确定操作是否按顺序完成。因此,这里没有当前位置的概念。对于文件的操作,总是规定该偏移量。在数据流下(如COM端口或socket),没有寻找精确偏移量的方法,所以在这些情况中,系统忽略偏移量。这四个字段不应由应用程序直接进行处理或使用,OVERLAPPED结构的最后一个参数是可选的事件句柄,当I/O完成时,该事件对象受信(signaled)。程序通过等待该对事件对象受信来做善后处理。

设置了OVERLAPPED参数后,ReadFile()/WriteFile()的调用会立即返回,这时候你可以去做其他的事(所谓异步),系统会自动替你完成ReadFile()/WriteFile()相关的I/O操作。你也可以同时发出几个ReadFile()/WriteFile()的调用(所谓重叠)。当系统完成I/O操作时,会将OVERLAPPED.hEvent置信,我们可以通过调用WaitForSingleObject/WaitForMultipleObjects来等待这个I/O完成通知,在得到通知信号后,就可以调用GetOverlappedResult来查询I/O操作的结果,并进行相关处理。由此可以看出,OVERLAPPED结构在一个重叠I/O请求的初始化及其后续的完成之间,提供了一种沟通或通信机制。注意OVERLAPPED结构的生存周期,一般动态分配,待I/O完成后,回收重叠结构。

Win32重叠I/O机制为基础,自WinSock 2发布开始,重叠I/O便已集成到新的WinSock API中,比如WSARecv()/WSASend()。这样一来,重叠I/O模型便能适用于安装了WinSock 2的所有Windows平台。可以一次投递一个或多个WinSock I/O请求。针对那些提交的请求,在它们完成之后,应用程序可为它们提供服务(对I/O的数据进行处理)。

相应的,要想在一个套接字上使用重叠I/O模型来处理网络数据通信,首先必须使用 WSA_FLAG_OVERLAPPED这个标志来创建一个套接字。如下所示:

SOCKET s = WSASocket(AF_INET, SOCK_STEAM, 0, NULL, 0,  WSA_FLAG_OVERLAPPED);

创建套接字的时候,假如使用的是socket()函数,而非WSASocket()函数,那么会默认设置WSA_FLAG_OVERLAPPED标志。成功创建好了一个套接字,将其与一个本地接口绑定到一起后,便可开始进行这个套接字上的重叠I/O操作,方法是调用下述的WinSock 2函数,同时为它们指定一个WSAOVERLAPPED结构参数(#define WSAOVERLAPPED OVERLAPPED// WINSOCK2.H):

1WSASend()

2WSASendTo()

3WSARecv()

4WSARecvFrom()

5WSAIoctl()

6AcceptEx()

7TransmitFile()

若随一个WSAOVERLAPPED结构一起调用这些函数,函数会立即返回,无论套接字是否设为锁定模式。它们依赖于WSAOVERLAPPED结构来返回一个I/O请求操作的结果

比起阻塞、selectWSAAsyncSelect以及WSAEventSelect等模型,WinSock的重叠I/O(Overlapped I/O)模型使应用程序能达到更佳的系统性能。因为它和这4种模型不同的是,使用重叠模型的应用程序通知缓冲区收发系统直接使用数据。也就是说,如果应用程序投递了一个10KB大小的缓冲区来接收数据,且数据已经到达套接字,则该数据将直接被拷贝到投递的缓冲区。而这4种模型中,数据到达并拷贝到单套接字接收缓冲区(Per Socket Buffer)中,此时应用程序会被系统通知可以读入的字节数。当应用程序调用接收函数之后,数据才从单套接字缓冲区拷贝到应用程序的缓冲区。这样就减少了一次从I/O缓冲区到应用程序缓冲区的拷贝,差别就在于此。

实际编程时,可以投递一个0字节缓冲区的WSARecv/WSASend操作,这样就没有用户缓冲区与I/O操作相关联,避免了用户缓冲区的锁定(过多的锁定可能导致非分页内存池耗尽,即WSAENOBUFS),应用程序绕开单套接字缓冲区而直接与TCP Stack进行数据交互,从而避免了内存拷贝。当然,只要投递了足够多的重叠发送/接收操作,就能避免额外的内存拷贝,这时将单套接字缓冲区设置为0并不能提升性能。因为应用程序的发送缓冲区将始终被锁定直到可以下传给TCP,所以停用套接字的发送缓冲区对性能的影响比停用接收缓冲区小。然而,如果接收缓冲区被设置为0,而又未投递重叠接收操作,则进来的数据都只能停留在TCP Stack中,而TCP驱动程序的缓冲区最多只能接收窗口大小。TCP缓冲区被定位在非分页内存池中,假如很多连接发数据过来,但我们根本没有投递接收操作,则将消耗大量的非分页内存池。非分页内存池是一种有限的资源,过多的锁定可能导致非分页内存池耗尽,即WSAENOBUFS

Windows NTWindows 2000中,重叠I/O模型也允许应用程序以一种重叠方式实现对套接字连接的处理。具体的做法是在监听套接字上调用AcceptEx函数。AcceptEx是一个特殊的WinSock扩展函数,由mswsock.dll实现,使用时需包含Mswsock.h头文件,链接Mswsock.lib库文件。该函数最初的设计宗旨是在Windows NTWindows 2000操作系统上使用Win 32的重叠I/O机制。但事实上,它也适用于WinSock 2中的重叠I/OAcceptEx的定义如下:

// MSWSOCK.H

AcceptEx(

       IN SOCKET sListenSocket,

       IN SOCKET sAcceptSocket,

       IN PVOID lpOutputBuffer,

       IN DWORD dwReceiveDataLength,

       IN DWORD dwLocalAddressLength,

       IN DWORD dwRemoteAddressLength,

       OUT LPDWORD lpdwBytesReceived,

       IN LPOVERLAPPED lpOverlapped);

参数一sListenSocket参数指定的是一个监听套接字。

参数二sAcceptSocket参数指定的是另一个套接字,负责对进入连接请求的接受 AcceptEx()函数和accept()函数的区别在于,我们必须提供接受的套接字,而不是让函数自动为我们创建。正是由于要提供套接字,所以要求我们事先调用socket()WSASocket()函数创建一个套接字,以便通过sAcceptSocket参数,将其传递给AcceptEx()

参数三lpOutputBuffer指定的是一个特殊的缓冲区,因为它要负责三种数据的接收:服务器的本地地址,客户机的远程地址,以及在新建连接上接收的第一个数据块。存储顺序是:接收到的数据块本地地址远程地址。

参数四dwReceiveDataLength以字节为单位,指定了在lpOutputBuffer缓冲区开头保留多大的空间,用于数据的接收。如这个参数设为0,那么只接受连接,不伴随接收数据

参数五dwLocalAddressLength和参数六dwRemoteAddressLength也是以字节为单位,指定在lpOutputBuffer缓冲区中,保留多大的空间,在一个套接字被接受的时候,用于本地和远程地址信息的保存。要注意的是,和当前采用的传送协议允许的最大地址长度比较起来,这里指定的缓冲区大小至少应多出16字节。举个例子来说,假定正在使用的是TCP/IP协议,那么这里的大小应设为SOCKADDR_IN结构的长度+16字节

参数七lpdwBytesReceived参数用于返回接收到的实际数据量,以字节为单位。只有在操作以同步方式完成的前提下,才会设置这个参数。假如AcceptEx()函数返回ERROR_IO_PENDING,那么这个参数永远都不会设置,我们必须利用完成事件通知机制,获知实际读取的字节量

最后一个参数是lpOverlapped,它对应的是一个OVERLAPPED结构,允许AcceptEx()以一种异步方式工作。如我们早先所述,只有在一个重叠I/O应用中,该函数才需要使用事件对象通知机制hEvent字段,这是由于此时没有一个完成例程参数可供使用。

 

二.获取重叠I/O操作完成结果

当异步I/O请求挂起后,最终要知道I/O操作是否完成。一个重叠I/O请求最终完成后,应用程序要负责读取重叠I/O操作的结果。对于读,直到I/O完成,接收缓冲器才有效(参考IRP缓冲区管理)。对于写,要知道写是否成功,有几种方法可以做到这点,最直接的方法是调用(WSA)GetOverlappedResult,其函数原型如下:

WINBASEAPI BOOL WINAPI

GetOverlappedResult(

HANDLE hFile,

LPOVERLAPPED lpOverlapped,

LPDWORD lpNumberOfBytesTransferred,

BOOL bWait);

BOOL WSAAPI WSAGetOverlappedResult(

SOCKET s,

LPWSAOVERLAPPED lpOverlapped,

LPDWORD lpcbTransfer,

BOOL fWait,

LPDWORD lpdwFlags);

参数一为的文件/套接字句柄。

参数二为参数一关联的(WSA) OVERLAPPED结构,在调用CreateFile()WSASocket()AcceptEx()时指定。

参数三指向字节计数指针,负责接收一次重叠发送或接收操作实际传输的字节数。

参数四是确定命令是否等待的标志。Wait参数用于决定函数是否应该等待一次重叠操作完成。若将Wait设为TRUE,那么直到操作完成函数才返回;若设为FALSE,而且操作仍然处于未完成状态,那么(WSA)GetOverlappedResult()函数会返回FALSE值。

(WSA)GetOverlappedResult()函数调用成功,返回值就是TRUE。这意味着我们的重叠I/O操作已成功完成,而且由参数三BytesTransfered参数指向的值已进行了更新。若返回值是FALSE,那么可能是由下述任何一种原因造成的:

重叠I/O操作仍处在待决状态。

重叠操作已经完成,但含有错误。

重叠操作的完成状态不可判决,因为在提供给 WSAGetOverlappedResult函数的一个或多个参数中,存在着错误。

失败后,由BytesTransfered参数指向的值不会进行更新,而且我们的应用程序应调用(WSA)GetLastError()函数,检查到底是何种原因造成了调用失败以使用相应容错处理。如果错误码为SOCKET_ERROR/WSA_IO_INCOMPLETE(Overlapped I/O event is not in a signaled state)SOCKET_ERROR/WSA_IO_PENDING(Overlapped I/O operation is in progress),则表明I/O仍在进行。当然,这不是真正错误,任何其他错误码则真正表明一个实际错误。

下面介绍两种常用重叠I/O完成通知的方法。

1.使用事件通知

使用(WSA)GetOverlappedResult()是直截了当的,它吻合重叠I/O的概念。毕竟,如果要等待I/O,也许使用常规I/O命令更好。对于大多数程序,反复检查I/O是否完成,并非最佳。解决方案之一是使用(WSA)OVERLAPPED结构中的hEvent字段,使应用程序将一个事件对象句柄同一个文件/套接字关联起来。

当指定OVERLAPPED参数给ReadFile()/WriteFile()WSARecv()/WSASend()后,可以再为(WSA)OVERLAPPED最后一个参数提供自定义的事件对象(通过(WSA)CreateEvent()创建)。

I/O完成时,系统更改(WSA)OVERLAPPED结构对应的事件对象的传信状态,使其从未传信unsignaled)变成已传信signaled)。由于我们之前将事件对象分配给了(WSA)OVERLAPPED结构,所以只需简单地调用WaitForSingleObject/WaitForMultipleObjectsWSAWaitForMultipleEvents函数,从而判断出一个(一些)重叠I/O在什么时候完成。通过WaitForSingleObject/WaitForMultipleObjectsWSAWaitForMultipleEvents函数返回的索引可以知道这个重叠I/O完成事件是在哪个HANDLEFileSocket)上发生的。

然后调用(WSA)GetOverlappedResult()函数,将发生事件的HANDLEFILESOCKET)传给参数一,将这个HANDLE对应的(WSA)OVERLAPPED结构传给参数二,这样判断重叠调用到底是成功还是失败。如果返回FALSE值,则重叠操作已经完成但含有错误。或者重叠操作的完成状态不可判决,因为在提供给(WSA)GetOverlappedResult()函数的一个或多个参数中存在着错误。失败后,由BytesTransfered参数指向的值不会进行更新,应用程序应调用(WSA)GetLastError()函数,调查到底是何种原因造成了调用失败。

(WSA)GetOverlappedResult()函数返回TRUE,则根据先前调用异步I/O函数时设置的缓冲区(ReadFile/WriteFileWSARecv/WSASendlpBuffer字段)BytesTransfered,使用指针偏移定位就可以准确操作接受到的数据了。

利用事件对象来完成同步通知的方法比重复调用(WSA)GetOverlappedResult()浪费处理器时间的方案要高效得多。但WaitForMultipleObjects/WSAaitForMultipleEvent支持的事件对象个数的上限为MAXIMUM_WAIT_OBJECTS/WSA_MAXIMUM_WAIT_EVENTS=64

2.使用完成例程

对于文件重叠I/O操作,等待I/O操作结束的另外方法是使用ReadFileEx()WriteFileEx()。这些命令只用于重叠I/O,当为它们的最后一个参数lpCompletionRoutine传递了一个完成例程指针(回调函数地址)时,I/O操作结束时将调用此函数进行处理。

完成例程指针LPOVERLAPPED_COMPLETION_ROUTINE定义如下:

// WINBASE.H

typedef  VOID (WINAPI *LPOVERLAPPED_COMPLETION_ROUTINE)(

        DWORD dwErrorCode,

        DWORD dwNumberOfBytesTransfered,

        LPOVERLAPPED lpOverlapped );

相应在WinSock 2中,WSARecv()/WSASend()最后一个参数lpCompletionROUTINE是一个可选的指针,它指向一个完成例程。若指定此参数(自定义函数地址),在重叠请求完成后,将调用完成例程处理。完成例程本质上是一种APCAsynchronous Procedure Calls)。

WinSock 2中完成例程指针LPWSAOVERLAPPED_COMPLETION_ROUTINE定义略有不同:

// WINSOCK2.H

typedef void (CALLBACK * LPWSAOVERLAPPED_COMPLETION_ROUTINE)(

        DWORD dwError,

        DWORD cbTransferred,

        LPWSAOVERLAPPED lpOverlapped,

        DWORD dwFlags );

     前三个参数同LPOVERLAPPED_COMPLETION_ROUTINE,参数四一般不用,置0用完成例程完成一个重叠I/O请求之后,参数中会包含下述信息:

参数一dwError表明了一个重叠操作(由lpOverlapped指定)的完成状态是什么。

参数二BytesTransferred参数指定了在重叠操作实际传输的字节量是多大。

参数三lpOverlapped参数指定的是调用这个完成例程的异步I/O操作函数(ReadFileEx()/WriteFileEx()WSARecv()/WSASend())(WSA)OVERLAPPED结构参数。

提交带有完成例程的重叠I/O请求时,(WSA)OVERLAPPED结构的事件字段hEvent一般不再使用。使用一个含有完成例程指针参数的异步I/O函数发出一个重叠I/O请求之后,一旦重叠I/O操作完成,作为我们的调用线程,必须能够通知完成例程指针所指向的自定义函数开始执行,提供数据处理服务。这样一来,便要求将调用线程置于一种可警告的等待状态,在I/O操作完成后,能自动调用完成例程。WSAWaitForMultipleEvents()函数可用来将线程置于一种可警告的等待状态。这样做的代价是必须创建一个事件对象可用于WSAWaitForMultipleEvents()函数。假定应用程序只用完成例程对重叠请求进行处理,便不需要引入事件对象。作为一种变通方法,我们的应用程序可用Win32SleepEx()函数将自己的线程置为一种可警告的等待状态。当然,亦可创建一个伪事件对象,不将它与任何东西关联在一起。假如调用线程经常处于繁忙状态,而且并不处于一种可警告的等待状态,那么完成例程根本不会被通知执行。

如前面所述,WSAWaitForMultipleEvents()通常会等待同(WSA)OVERLAPPED结构关联在一起的事件对象。该函数也可用于将我们的线程设置为一种可警告的等待状态,为已经完成的重叠I/O请求调用完成例程进行处理(前提是将fAlertable参数设为TRUE)。使用一个含有完成例程指针的异步I/O函数提交了重叠I/O请求之后, WSAWaitForMultipleEvents()的期望返回值是WAIT_IO_COMPLETIONOne or more I/O completion routines are queued for execution),而不再是事件对象索引。从宏WAIT_IO_COMPLETION的注解可知,它的意思是有完成例程需要执行。SleepEx()函数的行为实际上和WSAWaitForMultipleEvents()差不多,只是它不需要任何事件对象。对SleepEx函数的定义如下:

WINBASEAPI DWORD WINAPI

SleepEx(

    DWORD dwMilliseconds,

    BOOL bAlertable );

其中,dwMilliseconds参数定义了SleepEx()函数的等待时间,以毫秒为单位。假如将dwMilliseconds设为INFINITE,那么SleepEx()会无休止地等待下去。bAlertable参数规定了一个完成例程的执行方式,若将它设置为FALSE,则使用一个含有完成例程指针的异步I/O函数提交了重叠I/O请求后,I/O完成例程不会被通知执行,而且SleepEx()函数不会返回,除非超过由dwMilliseconds规定的时间;若将它设置为TRUE,则完成例程会被通知执行,同时SleepEx()函数返回WAIT_IO_COMPLETION

完成例程处理模型中,投递重叠I/O请求的同时注册完成例程,待I/O完成时由系统回调,并克服了事件通知模型的个数限制。利用完成例程处理重叠I/OWinSock程序的编写步骤如下:

(1) 新建一个监听套接字,在指定端口上监听客户端的连接请求。

(2) 接受一个客户端的连接请求,并返回一个会话套接字负责与客户端通信。

(3) 为会话套接字关联一个WSAOVERLAPPED结构。

(4) 在套接字上投递一个异步WSARecv请求,方法是将WSAOVERLAPPED指定成为参数,同时提供一个完成例程。

(5) 在将fAlertable参数设为TRUE的前提下,调用WSAWaitForMultipleEvents,并等待一个重叠I/O请求完成。重叠请求完成后,完成例程会自动执行,而且WSAWaitForMultipleEvents会返回一个WAIT_IO_COMPLETION。在完成例程内,可随一个完成例程一道投递另一个重叠WSARecv请求。

(6) 检查WSAWaitForMultipleEvents是否返回WAIT_IO_COMPLETION

(7) 重复步骤(5)(6)

当调用accept处理连接时,一般创建一个AcceptEvent伪事件,当有客户连接时,需要手动SetEvent(AcceptEvent);当调用AcceptEx处理重叠的连接时,一般为ListenSocket创建一个ListenOverlapped结构,并为其指定一个伪事件,当有客户连接时,系统自动将其置信。这些伪事件的作用在于,当含有完成例程指针的异步I/O操作(WSARecv)完成时,设置了fAlertableWSAWaitForMultipleEvents返回WAIT_IO_COMPLETION,并调用完成例程指针指向的完成例程对数据进行处理。

重叠I/O模型的缺点是它一般要为每一个I/O请求都开一个线程,当同时有成千上万个请求发生时,系统处理线程上下文切换是非常耗时的。所以这也就引出了更为先进的完成端口模型IOCP,用线程池来解决这个问题。

 

参考

Windows 2000 Systems Programming Black Book  Al Williams

Network Programming for Microsoft Windows  Anthony Jones,Jim Ohlund

你可能感兴趣的:(网络通信/流媒体)