一个简单的IOCP(IO完成端口)服务器/客户端类

作者:  Amin Gholiha    翻译:高庆余
文章来源:  [url]http://www.codeproject.com/KB/IP/iocp_server_client.aspx[/url]
 
前言:源代码使用比较高级的 IOCP 技术,它能够有效的为多个客户端服务,利用 IOCP 编程 API ,它也提供了一些实际 问题的解决办法,并且提供了一个简单的带回复的文件传输的客户端 / 服务器。
 
1.1  要求:
l         文 章要求读者熟悉  C++, TCP/IP,  套接字  (socket)  编程  , MFC,  和多线程。
l          源代码使用  Winsock 2.0    IOCP  技术,并且要求:
Ø          Windows NT/2000 or later: Requires Windows NT 3.5 or later.
Ø          Windows 95/98/ME:  不支持
Ø          Visual C++ .NET, or a fully updated Visual C++ 6.0.
1.2  摘要:
在你开发不同类型的软件,不久之后或者更晚,你必须得面对客户端 / 服务器端的发展。对程序员来说,写一个全面的 客户端 / 服务器的代码是很困难的。这篇文章提供了一 个简单的,但却强大的客户端 / 服务器源代码,它能够 被扩展到许多客户端 / 服务器的应用程序中。源 代码使用高级的 IOCP 技术,这种技术能高效的 为多个客户端提供服务。IOCP 技术提供了一种对 一个线程—一个客户端( one-thread-one client )这种瓶颈问题(很多中问题的一 个)的有效解决方案。它使用很少的一直运行的线程和异步输入 / 输出,发送 / 接收。 IOCP 技术被广泛应用于各自高性能的服务器,像 Apache 等。源代码也提供了一系列的函数,在处理通信、客户端 / 服务器接收 / 发送文件函数、还有线程池处理等方面都会经常用到。文章主要关注利用 IOCP 应用 API 函数的实际解决方案,也提供了一个全面的代码文档。此外,也为你呈现了一个能处理多个连接、同时能够进行文件传输的简单回复客户端 / 服务器。
2.1.     介绍:
这片文章提供了一个类,它是一个应用于客户端和服务器的源代码,这个类使用 IOCP 和异步函数,我们稍后会进行介绍。这个源代码 是根据很多代码和文章得到的。
利用这些简单的源代码,你能够:
l          服务 / 连接多个客户端和服务器。
l          异步发送和接 收文件。
l          为了处理沉重的客户端 / 服务器请求,创建并管理一个逻辑工作者线程 池。(  logical worker thread pool )。
我们很难找到充分的,但简单的能够应对客户端 / 服务器通信的源代码。在网上发现的源代码即复杂(超过 20 个类),又不能提供足够的功能。本问的代码尽量简单,也有好的文档。我们将简要介绍 Winsock API 2.0 提供的 IOCP 技术,编码时遇到的疑难问题,以及这些问题的 应对方案。
2.2.       异步输入 / 输出完成端口( IOCP )简介
一个服务器应用程序,假如不能够同时为多个客户端提供服务,那它就没有什么意义。通常的异步 I/O 调用,还有多线程都是这个目的。准确的说,一 个异步  I/O  调用能够立即返回,尽管有阻塞的  I/O  调用。同时,  I/O  异步调用的结果必须和主线程同步。 这可以用很多种方法实现,同步可以通过下面方法实现:
l          利用事件—— 当异步调用完成时设定的信号。这种方法的优点是线程必须检查和等待这个信号被设定。
l          使用  GetOverlappedResult 函 数——这个方法和上面方法有相同的优点。
l          使用异步程序调用( APC )——这种方法有些缺点。第一, APC 总是在正被调用的线程的上下文中被调用;第 二,调用线程必须暂停,等待状态的改变。
l          使用 IOCP ——这种方法的缺点是有些疑难问题必须解决。 使用 IOCP 编码多少有些挑战。
2.2.1        为什么使用 IOCP
使用 IOCP ,我们能够克服 一个线程 —— 一个客户端 问题。我们知道,假如软件不是运行在一个真实的多处理器机器上,它的性能会严重下降。线程是系统的资源,它们即不是无限的,也不便宜。
IOCP 提供了一种利用有限的( I/O 工作线程)公平的处理多客户端的输入 / 输出问题的解决办法。线程并不被阻塞,在无事 可作的情况下也不使 CPU 循环。
2.3.       什么是 IOCP
我们已经知道, IOCP 仅仅是一个线程同步对象,有点像信号量( semaphore ),因此 IOCP 并不是一个难懂的概念。一个 IOCP 对象和很多支持异步 I/O 调用的 I/O 对象相联系。线程有权阻塞 IOCP 对象,直到异步 I/O 调用完成。
3              IOCP 如何工作
为了得到更多信息,建议你参考其它的文章( 1, 2, 3, see References )。
使用 IOCP ,你必须处理 3 件事情。将一个套接字绑定到一个完成端口,使用异步 I/O 调用,和使线程同步。为了从异步 I/O 调用得到结果,并知道一些事情,像哪个客户端进行的调用,我们必须传递两个参数:  CompletionKey 参数  还 有  OVERLAPPED 结构体 
3.1.     CompletionKey 参数
CompletionKey 参数是第一个参数,是一个 DWORD 类型的变量。你可以给它传递你想要的任何值,这些值总是和这个参数联系。通常,指向结构体的指针,或者包含客户端指定对象的类的指针 被传递给这个参数。在本文的源代码中,一个  ClientContext 结构体的指针被传递给  CompletionKey 参数。
3.2.     OVERLAPPED 参数
这个参数通常被用来传递被异步 I/O 调用的内存。要重点强调的是,这个数据要被加锁,并且不要超出物理内存页,我们之后进行讨论。
3.3.     将套接字和完成端口进行绑定
一旦创建了完成端口,通过调用  CreateIoCompletionPort 函数可以将一个套接字和完成端口进行绑定,像下面的方法:
 
[cpp]  view plain copy
  1. BOOL IOCPS::AssociateSocketWithCompletionPort(SOCKET socket,   
  2.                                               HANDLE hCompletionPort,   
  3.                                               DWORD dwCompletionKey)  
  4. {  
  5.     HANDLE h = CreateIoCompletionPort((HANDLE) socket, hCompletionPort,   
  6.                                         dwCompletionKey, m_nIOWorkers);  
  7.     return h == hCompletionPort;  
  8. }  

 
3.4.     进行异步 I/O 调用
通过调用  WSASend WSARecv 函数,进行实际的异步调用。这些函数也需要包含将要被用到的内存指针的参数WSABUF 。通常情况下,当服务器 / 客户端想要执行一个 I/O 调用操作,它们并不直接去做,而是发送到完成 端口,这些操作被 I/O 工作线程执行。这是因 为,要公平的分配 CPU 。通过给完成端口传递一 个状态,进行 I/O 调用。象下面这样:

 

BOOL bSuccess = PostQueuedCompletionStatus( m_hCompletionPort, 
                                            pOverlapBuff->GetUsed(), 
                                            (DWORD) pContext, 
                                            &pOverlapBuff->m_ol );

 

 
3.5.     线程的同步
通过调用  GetQueuedCompletionStatus 函数进行线程的同步(看下面)。这个函数也提供了 CompletionKey 参数  OVERLAPPED 参数。

 


BOOL GetQueuedCompletionStatus(
                               HANDLE CompletionPort,       // handle to completion port
                               LPDWORD lpNumberOfBytes,     // bytes transferred
                               PULONG_PTR lpCompletionKey,  // file completion key
                               LPOVERLAPPED *lpOverlapped,  // buffer
                               DWORD dwMilliseconds         // optional timeout value
                               );

 

 

3.6.     四个棘手的 IOCP 编码问题和它们的对策
使用 IOCP 会遇到一些问题,有些问题并不直观。在使用 IOCP 的多线程场景中,并不直接控制线程流,这是因为线程和通信之间并没有联系。在这部分,我们将提出四个不同的问题,在使用 IOCP 开发客户端 / 服务器程序时会遇到它们。它们是:
l        WSAENOBUFS  出错问题。
l          数据包的重排序问题。
l          访问紊乱(  access violation  )问题。
 
3.6.1   WSAENOBUFS 出错问题。
这个问题并不直观,并且很难检查。因为,乍一看,它很像普通的死锁,或者内存泄露。假设你已经弄好了 你的服务器并且能够很好的运行。当你对服务器进行承受力测试的时候,它突然挂机了。如果你幸运,你会发现这和WSAENOBUFS 出错有关。
伴随着每一次的重叠发送和接收操作,有数据的内存提交可能会被加锁。当内存被锁定时,它不能越过物理 内存页。操作系统会强行为能够被锁定的内存的大小设定一个上限。当达到上限时,重叠操作将失败,并发送WSAENOBUFS 错误。
假如一个服务器在在每个连接上提供了很多重叠接收,随着连接数量的增长,很快就会达到这个极限。如果 服务器能够预计到要处理相当多的并发客户端的话,服务器可以在每个连接上仅仅回复一个 0 字节的接收。这是因为没有接收操作和内存无关,内存不需要被锁定。利用这个方法,每一个套接字的接收内存都应该被完整的保留, 这是因为,一旦  0  字节的接收操作完成,服务器仅仅为套接字的接 收内存的所以数据内存返回一个非阻塞的接收。 利用  WSAEWOULDBLOCK , 当非阻塞接收失败时,也没有数据被阻塞。这种设计的目的是,在牺牲数据吞吐量的情况下,能够处理最大 量的并发连接。当然,对于客户端如何和服务器交互,你知道的越多越好。在以前的例子中,每当 0 字节的接收完成,返回存储了的数据,马上执行非阻塞接收。假如服务器知道客户端突然发送数据,当 0 字节接收一旦完成,为防止客户端发送一定数量 的数据(大于每个套接字默认的 8K 内存大小),它可以投递一个或多个重叠接收。
源代码提供了一个简单的解决 WSAENOBUFS 错误的可行方案。对于 0 字节内存,我们采用  WSARead  () 函 数  (见  OnZeroByteRead  ())。 当调用完成,我们知道数据在 TCP/IP 栈中,通过采用几个异步  WSARead () 函数读取 MAXIMUMPACKAGESIZE 的内存。这个方法在数据达到时仅仅锁定物理内存,解决了 WSAENOBUFS问题。但是这个方案降低了服务器的吞吐量(见第 9 部分的 Q6 和 A6 例子)。
3.6.2   数据包的重排序问题
在参考文献 3 中也讨论了这个问题。尽管使用 IOCP ,可以使数据按照它们被发送的顺序被可靠的处理,但是线程表的结果是实际工作线程的完成顺序是不确定的。例如,假如你有两个 I/O 工作线程,并且你应该接收“字节数据块 1 、字节数据块 2 、字节数据块 3 ” ,你可以按照错误的顺序处理它们,也就是“字 节数据块 2 、字节数据块 1 、字节数据块 3 ” 。这也意味着,当你通过把发送请求投递到 IO 完成端口来发送数据时,数据实际上是被重新排 序后发送的。
这个问题的一个实际解决办法是,为我们的内存类增加顺序号,并按照顺序号处理内存。意思是,具有不正 确号的内存被保存备用,并且因为性能原因,我们将内存保存在希哈表中(例如  m_SendBufferMap    m_ReadBufferMap)。
要想得到更多这个方案的信息,请查看源代码,并在 IOCPS 类中查看下面的函数:
l          GetNextSendBuffer  (..)    GetNextReadBuffer(..)  为了得到排序的发送或接收内存。
l           IncreaseReadSequenceNumber  (..)    IncreaseSendSequenceNumber(..)  为了增加顺序号。
3.6.3   异步阻塞读和 字节块包处理问题
大多数服务器协议是一个包,这个包的基础是第一个 X 位的描述头,它包含了完整包的长度等详细信 息。服务器可以解读这个头,可以算出还需要多少数据,并一直解读,直到得到一个完整的包。在一个时间段内,服务器通过异步读取调用是很好的。但是,假若我 们想全部利用 IOCP 服务器的潜力,我们应该 有很多的异步读操作等待数据的到达。意思是很多异步读无顺序完成(像在 3.6.2 讨论的),通过异步读操作无序的返 回字节块流。还有,一个字节块流( byte chunk streams )能包含一个或多个包,或者包的一部分,如图 1 所示:

一个简单的IOCP(IO完成端口)服务器/客户端类_第1张图片


图 1
这个图表明部分包(绿色)和完整的包(黄色)在字节块流中是如何异步到达的。
这意味着我们要想成功解读一个完整包,必须处理字节流数据块( byte stream chunks ) 。还有,我们必须处理部分包,这使得字节块包 的处理更加困难。完整的方案可以在 IOCP 类里的  ProcessPackage(..) 函数中找到。
3.6.4   访问紊乱( access violation )问题。
这是一个次要问题,是编码设计的结果,而不是 IOCP 的特有问题。倘若客户端连接丢失,并且一个 I/O 调用返回了一个错误标识,这样我们知道客户端已经不在了。在  CompletionKey 参数中,我们为它传递一个包含了客户端特定数据的结构体的指针。假如我们释放被  ClientContext 结构体占用的内存,被同一个客户端执行 I/O 调用所返回的错误码,我们为  ClientContext 指针传递双字节的  CompletionKey 变量,试图访问或删除  CompletionKey 参数,这些情况下会发生什么?一个访问紊乱发生了。
这个问题的解决办法是为  ClientContext 结构体增加一个阻塞 I/O 调用的计数 (  m_nNumberOfPendlingIO )  当我们知道没有阻塞 I/O 调用时我们删除这个结构体。  EnterIoLoop(..) 函数和 ReleaseClientContext(..) . 函数就是这样做的。
3.7       源代码总揽
源代码的目标是提供一些能处理与 IOCP 有关的问题的代码。源代码也提供了一些函数,它们在处理通信、客户端 /服务器接收 / 发送文件函数、还有线程池处理等方面会经常用到。

一个简单的IOCP(IO完成端口)服务器/客户端类_第2张图片


图 2 源代码 IOCPS 类函数总揽
我们有很多 I/O 工作线程,它们通过完成端口( IOCP )处理异步 I/O 调用,这些工作线程调用一些能把需要大量计算的请求放到一个工作队列着中的虚函数。逻辑工作线程从队列中渠道任务,进行处理,并通过使 用一些类提供的函数将结果返回。图形用户界面( GUI )通常使用 Windows 消息,通过函数调用,或者使用共享的变量,和主要类进行通信。

图 3 . The figure above shows the class overview.

图 3 显示了类的总揽。
图 3 中的类归纳如下:
l           CIOCPBuffer  :管理被异步  I/O  调用使用的内存的类。
l           IOCPS :处理所有通信的主要类。
l           JobItem :包含被逻辑工作线程所执行工作的结构体。
l           ClientContext :保存客户端特定信息的结构体(例如:状态、数据 )。
 
3.7.1   内存设计—— CIOCPBuffer 类
当使用异步 I/O 调用时,我们必须为 I/O 操作提供一个私有内存空间。当我们分配内存时要考虑下面一些情况:
l          分配和释放内 存是很费时间的,因此我们要反复利用分配好的内存。所以,我们像下面所示将内存保存在一个连接表中。
·                 // Free Buffer List..
·                  
·                     CCriticalSection m_FreeBufferListLock;
·                     CPtrList m_FreeBufferList;
·                 // OccupiedBuffer List.. (Buffers that is currently used)
·                  
·                     CCriticalSection m_BufferListLock;
·                     CPtrList m_BufferList;
·                 // Now we use the function AllocateBuffer(..)
·                  
// to allocate memory or reuse a buffer.
l         有时,当一个异步 I/O 调用完成时,我们可能在内存中有部分包,因此我们为了得到一个完整的消息,需要分离内存。在 CIOCPS 类中的函数  SplitBuffer  () 可以实现这一目标。我们有时也需要在两个内存 间复制信息, CIOCPS 类中的  AddAndFlush  () 函数可以实现。
l          我们知道,我 们为我们的内存增加序列号和状态变量(  IOZeroReadCompleted  () )。
l          我们也需要字节流和数据相互转换的方法,在 CIOCPBuffer 类中提供了这些函数。
在我们的 CIOCPBuffer 类中,有上面所有问题的解决办法。
3.8       如何使用源代码
从 IOCP 中派生你自己的类,使用虚函数,使用 IOCPS 类提供的函数(例如:线程池)。使用线程池,通过使用少数的线程,为你为各种服务器或客户端高效的管理大量的连接提供了可能。
3.8.1   启动和关闭服务器 / 客户端
启动服务器,调用下面的函数:

 

BOOL Start(int nPort=999,
           int iMaxNumConnections=1201,
           int iMaxIOWorkers=1,
           int nOfWorkers=1,
           int iMaxNumberOfFreeBuffer=0,
           int iMaxNumberOfFreeContext=0,
           BOOL bOrderedSend=TRUE, 
           BOOL bOrderedRead=TRUE,
           int iNumberOfPendlingReads=4);

 

l         nPortt :服务器将监听的端口号(在客户端模式设为 -1 )。
l         iMaxNumConnections  :最多允许连接数。
l          iMaxIOWorkers  :输入  /  输出工作线程数。
l          nOfWorkers  :逻辑工作者数(在运行时能被改变)。
l          iMaxNumberOfFreeBuffer  :保留的重复利用的内存的最大数量(  -1  :无    0  :无穷)。
l          iMaxNumberOfFreeContext  :保留的重复利用的客户端信息的最大数量(  -1  :无    0  :无穷)。
l          bOrderedRead  :用来进行顺序读。
l          bOrderedSend  :用来进行顺序发送。
l          iNumberOfPendlingReads  :等待数据的异步读循环的数量。在连接到一个远端的连接时调用下面的函数:
Connect( const CString &strIPAddr, int nPort)
l          strIPAddr  :远端服务器的  IP  地址。
l          nPort  :端口。
关闭服务器,调用函数:  ShutDown  ()。
例如:
MyIOCP m_iocp;
if(!m_iocp.Start( -1, 1210, 2, 1, 0, 0))
    AfxMessageBox("Error could not start the Client");

….

m_iocp.ShutDown();

5.1 文件传输

       使用 Winsock 2.0 的  TransmitFile 函数传输文件    TransmitFile 函数在连接的 套接字句柄上传输文件数据。此函数使用操作系统的缓冲管理机制接收文件数据,在套接字上提供高性能的文件传输。在异步文件传输上有以下几个重要方面:
l            除非  TransmitFile 函数返回,否则不能再对套接字执行 发送 或 写入 操作,不然会破坏文件的传输。在执行  PrepareSendFile(..)  函数后,所有对  ASend 函数的调用都 是不允许的。
l            由于系统是连续读文件数据,打开文件句柄的  FILE_FLAG_SEQUENTIAL_SCAN特性可以提高缓存性能。
l            在发送文件(  TF_USE_KERNEL_APC )时,我们 使用内核的异步程序调用。 TF_USE_KERNEL_APC 的使用可以带来明显的性能提升。很可能(尽管不一定),带有  TransmitFile 的线程的上下文环境的初始化会有沉重的计算负担;这种情况下可以防止反复执行 APC (异步程序调用)。
文件传输的顺序如下:服务器通过调用  PrepareSendFile(..) 函 数初始化文件传输。客户端接收到文件信息时,通过调用  PrepareReceiveFile(..) 函数准备接收,并且给服务器发送一个包来开始文件传输。在服务器收到包后,它调用使用高性能的  TransmitFile 函数的  StartSendFile(..) 函数传输指 定的文件。
 

6 源代码例子

提供的源代码是一个模拟客户端 / 服务器的例子,它也提供了文件传输功能。在源码中,从类  IOCP 派生出的类 MyIOCP 处理客户端和服务器端的通信。在 4.1.1 部分提到了这个虚函数的用法。
在客户端,或者服务器端的代码中,虚函数  NotifyReceivedPackage 是重点。描述如下:

void MyIOCP::NotifyReceivedPackage( CIOCPBuffer *pOverlapBuff,
                                    int nSize,ClientContext *pContext)  
{    
    BYTE PackageType=pOverlapBuff->GetPackageType();    
    switch(PackageType)    
    {
    case Job_SendText2Client :
        Packagetext(pOverlapBuff,nSize,pContext);         
        break;
    case Job_SendFileInfo :             
        PackageFileTransfer(pOverlapBuff,nSize,pContext);
        break;         
    case Job_StartFileTransfer:             
        PackageStartFileTransfer(pOverlapBuff,nSize,pContext);             
        break;         
    case Job_AbortFileTransfer:
        DisableSendFile(pContext);
        break;
    };
}

这个函数处理进来的消息和远程连接发送的请求。在这种情形下,它只不过进行一个简单的回复或者传输文件。源代码分为两部分, IOCP 和 IOCPClient ,
它们是连接的双方。

6.1 编译器问题

在使用 VC++ 6.0 或者 .NT 时,在处理类 CFile 时可能会出现一些奇怪的错误。像下面这样:
“if (pContext->m_File.m_hFile != INVALID_HANDLE_VALUE) <-error C2446: '!=' : no conversion "

"from 'void *' to 'unsigned int'”
在你更新头文件( *.h ),或者更新你的 VC++ 6.0 版本后,或者只是改变类型转换错误,都可能会解决这些问题。经过一些修改,这个客户端 / 服务器的源代码在没有 MFC 的情况下也能使用。

7 注意点和解决规则

在你将此代码用于其它类型的程序时,有一些编程的陷阱和源代码有关,使用“多线程编程”可以避免。不确定的错误是那些随时发生的错 误,并且通过执行相同的出错的任务的顺序这种方式很难降低这些不确定的错误。这类错误是存在的最严重的错误,一般情况下,它们出错是因为源代码设计执行的 内核的出错上。当服务器运行多个 IO 工作线程时,为连接的客户端服务,假如编程人员没有考虑源代码的多线程环境,就可能会发生像违反权限这种不确定的错误。

解决规则 #1 :

像下面例子那样,绝不在使用上下文 “锁”之前锁定客户端的上下文(例如  ClientContext )之前进行读 / 写。通知函数(像:  Notify*(ClientContext *pContext) )已经是“线程安全的”,你访问  ClientContext 的成员函数,而不考虑上下文的加锁和解锁

[cpp]  view plain copy
  1. // Do not do it in this way  
  2. // …   
  3. If(pContext->m_bSomeData)  
  4.     pContext->m_iSomeData=0;  
  5. // …  
[cpp]  view plain copy
  1. // Do it in this way.  
  2. // ….  
  3. pContext->m_ContextLock.Lock();   
  4. If(pContext->m_bSomeData)   
  5.    pContext->m_iSomeData=0;   
  6. pContext->m_ContextLock.Unlock();   
  7. // …  

 

当然,你要明白,当你锁定一个上下文时,其他的线程或 GUI 都将等待它。

解决规则 #2 :

要避免,或者“特别注意”使用那些有复杂的“上下文锁”,或在一个“上下文锁”中有其他类型的锁的代码。因为它们很容易导致“死 锁”。(例如: A 等待 B , B 等待 C ,而 C 等待 A   => 死锁)。

pContext->m_ContextLock.Lock();

//… code code .. 
pContext2->m_ContextLock.Lock(); 

// code code.. 
pContext2->m_ContextLock.Unlock(); 

// code code.. 
pContext->m_ContextLock.Unlock();
上面的代码可以导致一个死锁。

解决规则 #3 :

绝不要在通知函数(像  Notify*(ClientContext *pContext) )的外面访问 一个客户端的上下文。假如你必须这样做,务必使用  m_ContextMapLock.Lock(); …  m_ContextMapLock.Unlock() 对它进行封装。如下面代码所示:
ClientContext* pContext=NULL ; 
m_ContextMapLock.Lock(); 
pContext = FindClient(ClientID); 
// safe to access pContext, if it is not NULL
// and are Locked (Rule of thumbs#1:) 
//code .. code.. 
m_ContextMapLock.Unlock(); 

// Here pContext can suddenly disappear because of disconnect. 
// do not access pContext members here.



8 下一步的工作
下一步,代码会被更新,在时间顺序上会具有下面的特性:
1.          可以接受新的 连接的  AcceptEx(..) 函数的应用将添加到源代码中,用来处理短时的连接爆炸(  short lived connection bursts )和 DOS 攻击。
2.          源代码可以很 容易的用于其它平台,像  Win32, STL,    WTL 

你可能感兴趣的:(一个简单的IOCP(IO完成端口)服务器/客户端类)