一个IOCP 例子

IOCP 例子

翻译人: Kevin Chen. 

原文链接:http://www.codeproject.com/KB/IP/iocp_server_client.aspx 

1. 简介

 

 

2. IOCP

2.1. 异步完成端口(IOCP)简介

         一个服务器程序如果不能同时服务多个客户端,则不能称之为服务程序,通常,异步I/O调用和多线程被用来实现同时服务的目标。 在定义上,一个异步I/O调用立即返回, 留下I/O调用的等待。在一些时间点上,I/O异步调用的结果必须和主线程进行同步。这个可以用不同的方式来实现。这种同步可以用以下方式来实现:

·       使用事件 --- 当异步事件完成时设置一个信号。这种方式的缺点是线程需要检测或者等待事件被设置。

·       使用GetOverlappedResult函数 --- 这种方式和上一种方式有着同样的缺点。

·       使用异步程序调用 (APC) --- 这个方式有几种缺点。 第一: APC经常在调用线程的上下文中被调用。第二:为了能够执行APCs,调用线程需要在所谓的“可变等在状态”中暂停

·       使用IOCP --- 这种方式的缺点是有许多编程问题需要解决。编写IOCP会有一点困难。

2.1.1. 为什么使用IOCP

       通过使用IOCP, 我们可以克服“一个线程 -- 一个客户端”的问题。众所周知的是,当软件不是运行在一个真正的多处理器的机器上的时候,性能将大幅降低。线程是系统资源,既不是无限制的也不是便宜的。

       IOCP提供了一种用少量线程处理多客户“Input / Output” 的方式。这些线程会被暂停,并且不使用CPU周期直到有事可做。

2.2. 什么是IOCP

      我们已经说明了,IOCP只是一个线程同步对象,类似于信号量,因此,IOCP不是一个老的概念。一个IOCP对象关联到几个支持异步等待调用的I/O对象。一个已经访问IOCP的线程会暂停直到等待的异步I/O调用完成。

3. IOCP如何工作

         为了获得更多的关于这章的信息,我参考了其他的文章。

         当使用IOCP时,你需要做3件事,关联一个套接字到完成端口,执行一个异步I/O调用,与线程同步。为了获得异步I/O调用的结果并且知道,例如,那一个客户端执行了调用,你需要传递2个参数: 参数CompletionKey,和OVERLAPPED结构。

       

3.1. 完成键参数 (CompletionKey)

       第一个参数,CompletionKey,仅仅是一个类型为DWORD的变量。你可以传输任何唯一的值,这个值经常被关联到对象。通常,一个出赋值给这个参数的指针指向一个结构或类,这个指针可以包含一些客户端特定的对象。在源代码中,指向结构ClientContext的指针被赋值给CompletionKey参数。

3.2. OVERLAPPED参数

       这个参数通常被用于传输被异步调用使用的内存Buffer。需要指出的是:这个数据将被锁住并且不被换页出物理内存。后面将讨论它。

3.3. 关联一个套接字到完成端口

       一旦一个完成端口被创建, 通过调用函数CreateIoCompletionPort可以关联一个套接字到完成端口。方法如下:

 

3.4. 发起一个异步I/O调用

        为了发起一个真正的异步调用,将调用函数WSASend, WSARecv。它们同样需要参数WSABUF,WSABUF中包含了指向将被使用的缓存的指针。一个首要法则是:当服务/客户端想要调用一个I/O操作的时候,I/O操作不能直接调用,只需要将操作传递到完成端口中,并且I/O操作将被I/O工作线程执行。这样做的原因是:我们想要CPU周期被公平的平分利用。I/O调用是通过传递一个状态到完成端口中来完成的,如下所示:

 

3.5. 和线程同步

       和I/O工作线程同步是通过调用GetQueuedCompletionStatus函数来完成的。这个函数也提供了CompletionKey参数和OVERLAPPED参数。

 

3.6. 四个痛苦的IOCP编程困难以及它们的解决方案

     当只用IOCP的时候会出现一些问题,有一些是直觉问题。在一个使用IOCP的多线程方案中,一个线程函数的控制流并不是易懂的,因为在线程和通讯之间没有联系。在这节中,我们将描绘在使用IOCP开发C/S程序会出现的4个不同的问题。它们是:

·        WSAENOBUFS错误问题

·       包重新排序问题

·       异步等待读和数据块处理问题

·       违规访问问题

3.6.1. WSAENOBUFS错误问题

      这个问题并不直观并且难以探测,因为,乍一看,它像是一个普通的死锁或者内存泄漏。假设你已经开发出你的服务程序并且一切运行正常。当你压力测试你的服务程序时,它突然停止工作。如果你足够幸运,你会发现这与WSAENOBUFS错误有关。

     对于每一个重叠发送/接受操作,提交的数据缓冲区都有可能被锁住。当内存被锁住后,它不能被换页出物理内存。操作系统对于可以被锁住的内存数量强加了一个限制。当达到上限后,重叠操作会失败并返回WSAENOBUFS错误。

     如果一个服务在每个连接上分发了太多的重叠接收操作, 当连接数量增大时内存数量就会达到上限。如果一个服务器预期会有非常大量的并发客户端需要处理,服务器会传递一个0字节接收请求到每个连接上。因为没有内存用于接收操作,所以也没有内存需要被锁住。通过这种方式,每一个套接字的接受缓冲区会被完整的保留,因为一旦0-字节接受操作完成,服务器可以简单的执行一个非阻塞的接收操作以得到缓冲在套接字接收缓冲区中所有的数据。当非阻塞接受操作失败并返回WSAEWOULDBLOCK时,表示没有数据在等待接收了。这种设计可以被用来处理需要最大并发连接而牺牲每个连接的数据吞吐量的情况。当然,你越了解客户端如何与服务端交互的情况越好。在上一个例子中,一个非阻塞接收操作在0-字节接收完成获取被缓冲的数据后被执行。如果服务器知道客户端瞬间发送数据,一旦0-字节接收操作完成,将传递一个或多个重叠接收操作如果客户端发送大量数据(数据量大于默认值为8KB的接收缓冲区)。

     对于WSAENOBUFS问题,一个简单的使用解决方案已在源码中提供。当执行一个0-字节的异步WSAReas(...)  (FYI OnZeroByteRead)。当调用完成时,我们知道有数据在在TCP/IP栈中。我们通过执行几个BUF长度为MAXIMUMPACKAGESIZE的异步WSARead(...) 。这种解决方案仅在有数据到达时锁住物理内存,这就解决了WSAENOBUFS问题。但是这种方案降低了服务器的吞吐量。

3.6.2. 包重新排序

      尽管使用IO完成端口的委托操作总是按照它们被提交的顺序完成,线程调度问题意味着实际关联到完成端口的工作是在不确定的顺序下被执行。例如,如果你有两个工作线程,并且你需要接收“数据块1,数据块2,数据块3...”,你也许会在错误的顺序下处理数据块,即,“数据块2,数据块1,数据块3”。这就是说,当你通过投递请求到完成端口的方式发送数据时,数据实际上会在重新排序后被发送。

      一个使用的解决方案是添加序列号到buffer类中,如果buffer序号是有序的时候直接处理buffer中的数据。也就是说,错误序号的buffer需要被缓存起来以稍后处理,因为性能原因,我们将缓存buffers到哈希表中。(m_SendBufferMap和m_ReadBufferMap)。

     如果需要获得更多关于这个解决方案的信息,请浏览源码,并检查IOPCS类中的如下函数:

      (a). GetNextSendBuffer(...) 和 GetNextReadBuffer(...), 用于获取有序的接收/发送缓冲区。

      (b). IncreaseReadSequenceNumber(...) 和 IncreaseSendSequenceNumber(..),用以增加序列号

3.6.3. 异步等待读和数据块处理问题。

      大部分服务端协议都是基于包的协议,这些协议第X个字节代表头部,头部包含了一个完整的包的长度的详细信息。服务端可以读取包头,计算出还需要多少数据,并且持续读取数据直到获取一个完整的包。当服务端在同一时刻仅执行一个异步调用,这种方法将会工作得很好。但是,如果我们想使用IOCP的最大潜能,我们应该有几个异步读等待请求。这就意味着几个异步读将无次序的完成(如同在3.6.2节讨论的那样),并且等待读返回的数据块流不能被有序的处理。此外,一个数据块流会包含一个或多个包或者包的部分,就如下图所示:

 

     这就意味着我们需要处理字节流块以读取一个完整的包。此外,我们需要处理不完全的包(如上图所示)。这就使得字节块处理更加困难。完整的解决方案可以在IOCPS类中的ProcessPackage 函数中发现。

 

3.6.4. 访问异常问题

这是一个小问题,并且是代码设计的结果而不是IOCP特有的问题。假设一个客户端连接丢失并且I/O调用返回一个错误的标记,然后服务端知道这个客户端已经断开。我们传递了一个指针给参数CompletionKey,这个指针指向包含客户端特有数据的ClientContext结构体。如果我们释放被ClientContext占住的内存会怎么样呢,如果被同样的客户端(ClientContext)所执行的I/O请求返回一个错误码又会怎么样呢?如果我们将DWORD类型的CompletionKey转换成一个指向ClientContext的指针,并且试图访问或者删除它又会怎么呢?此时,一个访问违规发生了!

  解决这个问题的方法是:添加一个变量(m_nNumberOfPendingIO)用于记录这个结构(ClientContext)中包含了多少个等待I/O调用,当这个结构中没有任何I/O调用时侯才可以删除它。这个方式通过EnterToLoop() 函数和ReleaseClientContext()完成。

 

3.7. 源代码概述

这个源码的目的是提供一系列简单的类用以处理所有关于IOCP的难点。源码同样也提供了一组频繁使用的函数用以处理通讯和C/S软件的接收/传输功能,逻辑线程池处理等。。。

    我们有几个通过IOCP来处理异步I/O调用的工作线程,这些工作者调用相同的虚函数以可以提交需要在一个工作队列中进行大量计算的请求(???)。逻辑工作者从队列中获取任务,并处理任务,然后送回通过类中部分函数处理后的结果。GUI通常和使用Windows消息、调用函数或者共享变量的主类进行通讯。

 

 在上图中出现的类是:

v       CIOCPBuffer:管理被异步I/O调用使用的缓冲区。

v       IOCPS: 处理所有的通讯

v       JobItem:被逻辑工作者线程所处理的结构体,结构中包含任务。

v       ClientContext:保存客户特定信息的结构体。

 

3.7.1. 缓冲区设计 --- CIOCPBuffer类

     当我们使用异步I/O调用的时候,我们需要提供一个被I/O操作使用的私有缓存。当分配缓冲时,需要考虑一些因素:

       分配和释放缓冲区是代价昂贵的,因此,我们应该重用已分配的缓冲区。所以缓冲区被存放在如下所示的链表结构中:

 

v       有时候,当一个异步I/O调用结束,缓冲区中可能会有不完整的包,因此我们需要将缓冲区分片以获取一个完整的包。这项工作通过CIOPS类中的SplitBuffer函数完成。我们有时候也需要在缓冲区之间拷贝数据,这项功能通过CIOPS中的AddAndFlush函数完成。

v       我们也需要添加一个序号和状态(IOType 变量,IOZeroReadCompleted,等)到缓冲区中。

v       CIOCPBuffer中的一些函数提供了在字节流和数据之间相互转换的功能。

      针对上面谈到的问题,所有的解决方案都在CIOCPBuffer类中给出。

3.8. 如何使用源代码

   通过继承IOCP得到自己的类,并且使用虚函数和IOCPS提供的功能,能够仅使用少量线程就可以有效处理大量连接的服务器或者客户端。

启动服务: Start

关闭服务:ShutDown

3.8. 重要的变量

所有被函数使用的共享变量都需要加锁,这是为了防止访问违规和重叠写的重要方法。任何需要被锁住的变量xxx都会有一个变量为名 xxxLock的变量。

v       m_ContextMapLock

v       ContextMap  m_ContextMap:    保存所有的客户端数据(socket, client data, etc…)。

v       m_NumberOfActiveConnections: 保存已经建立的连接数。

 

4. 文件传输

  文件传输是通过WinSock2.0的TransmitFile函数来完成的。TransmitFile函数通过一个已连接的套接字来传输数据。这个函数使用操作系统的高速缓存来世接收数据,并且提供了在套接字上高性能的数据传输。

v       除非TransmitFile函数返回,套接字上没有任何其它的读写操作被执行,因为这会破坏文件。因此,在调用PrepareSendFile之后,所有的ASend调用都将无效。

v       一旦操作系统有序的读取文件数据,通过用FILE_FLAG_SEQUENTIAL_SCAN打开文件句柄可以提升cache性能。

v       当发送数据的时候(TF_USE_KERNEL_APC)时我们使用内核异步过程调用。TF_USE_KERNEL_APC可以实现显著的性能提升。这样做使得上下文为TransmitFile的线程可以被用来进行大量的计算,这种情况防止APC被触发。

文件传输的顺序是:服务端通过调用PrepareSendFile(…)函数初始化文件传输。当客户端接收到文件信息时,客户端通过调用PrepareReceiveFile(…)来初始化,并且发送一个数据包到服务端以开始文件传输。当数据包到达服务端的时候,服务端调用使用高性能的TransmitFile函数的StartSendFile(…)函数来传输指定的数据。

5. 特殊的规则

     当你在其它类型的程序中使用此代码的话,有一些关于此代码和“多线程编程”的陷阱需要避免。有一些随机的错误很难复现。这种类型的错误是最严重的错误,它们的存在是因为源代码的关键实现上出现错误。当服务端工作在多IO线程上时,或正在为多个连接的客户端服务的时候,如果在源代码中没能考虑多线程环境的话,随机错误,如访问违规,就会出现。

规则1:

   永远不要在为加锁的情况下read/write 客户端上下文,加锁情况如下所示。通知函数总是线程安全的,ClientContext的成员变量可以在不加锁的情况访问。

   同样,当锁住上下文,其它的线程或者GUI就会等待它,这点值得注意。

规则2:

     避免在“上下文锁”中嵌入其它类型的锁或者有复杂“上下文锁”特殊代码,因为这样做会导致死锁。

上面所示的代码可能会导致死锁。

规则3:

永远不要在通知函数(如Notify* (ClientContext *pContext))之外访问客户端上下文,如果需要,则要用m_ContextMapLock.Lock();m_ContextMapLock.Unlock()来锁住上下文,如下列代码所示:

 

NOTE:
1. 以上所翻译的只是文章部分内容,如有需要,请查阅原文。
2. 关于DEMO的程序架构,请参考文章:《A Simple IOCP Server/Client Class》整改
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值