IOCP专题
Abstract
- Windows下的IOCP模型[1] [2]
- Windows之IOCP[3]
- C++ | IOCP 完成端口,最高性能的网络编程模型原理[4]
- IOCP详解[5]
- IOCP编程小结(中)[6]
- 采用完成端口(IOCP)实现高性能网络服务器(Windows c++版)[7]
- 单线程实现同时监听多个端口(windows平台c++代码)[8]
- IOCP详解:如何通过IOCP(I/O Completion Port)提升I/O性能[9]
- IOCP模型与网络编程[10]
- 实现UDP IOCP心得-zt[11]
- 使用IOCP需要注意的一些问题~~(不断补充)[12]
- IOCP的例子[13]
- IOCP完全开发经验总结
- 完成端口(CompletionPort)详解 - 手把手教你玩转网络编程系列之三[19]
- WinSock IOCP 模型总结(附一个带缓存池的IOCP类)[20]
- MFC高性能网络编程:完成端口[21][22]
- RIO
- IOCPServer
Citation Yao Qing-sheng.IOCP专题.FUTURE & CIVILIZATION Natural/Social Philosophy & Infomation Sciences,20240603. https://yaoqs.github.io/20240603/iocp-zhuan-ti/
※Windows下的IOCP模型[1] [2]
※IOCP简介
IOCP(I/O Completion Port,I/O完成端口)是Windows操作系统中伸缩性最好的一种I/O模型。
I/O 完成端口是应用程序使用线程池处理异步 I/O 请求的一种机制。处理多个并发异步I/O请求时,使用 I/O 完成端口比在 I/O 请求时创建线程更快更高效。
※IOCP的优势
I/O 完成端口可以充分利用 Windows 内核来进行 I/O 调度,相较于传统的 Winsock 模型,IOCP 在机制上有明显的优势。
模型 | 机制 | 特性 |
---|---|---|
select模型 | 通过select函数来管理I/O,可以确定一个或多个套接字的状态 | 该模型的优势是程序能够在单个线程内同时处理多个套接字连接,避免了阻塞模式下的线程膨胀 |
WSAAsyncSelect模型 | WSAAsyncSelect函数把socket设为非阻塞模式,并为socket绑定一个窗口句柄,依靠Windows的消息驱动机制,通过窗口进行消息接收、事件处理 | 该模型最突出的特点是与Windows的消息驱动机制融合在一起,使得开发带GUI界面的网络程序更简单 |
WSAEventSelect模型 | 该模型与WSAAsyncSelect模型类似,允许应用程序在一个或多个socket上接收基于事件的网络通知,不过该模型是经由事件对象句柄通知的 | 该模型简单易用,也不需要窗口环境,缺点是最多等待64个事件对象的限制,当socket连接数量增加时,必须创建多个线程来处理I/O |
重叠I/O模型 | 该模型引入了重叠数据结构,允许应用程序使用重叠结构一次投递一个或多个异步I/O请求 | 该模型使用Winsock 2.0库的API,如:WSASend、WSARecv等,真正做到了“异步处理” |
IOCP模型 | IOCP模型通过socket绑定完成端口,在socket上投递事件,工作线程在完成端口上轮询接收、处理事件 | IOCP充分利用内核对象的调度,只使用少量的几个线程来处理所有网络通信,消除了无谓的线程上下文切换,最大限度地提高了网络通信的性能 |
相较于传统的Winsock模型,IOCP的优势主要体现在两方面:独特的异步I/O方式和优秀的线程调度机制。
※独特的异步I/O方式
IOCP模型在异步通信方式的基础上,设计了一套能够充分利用Windows内核的I/O通信机制,主要过程为:① socket关联iocp,② 在socket上投递I/O请求,③ 事件完成返回完成通知封包,④ 工作线程在iocp上处理事件。
IOCP的这种工作模式:程序只需要把事件投递出去,事件交给操作系统完成后,工作线程在完成端口上轮询处理。该模式充分利用了异步模式高速率输入输出的优势,能够有效提高程序的工作效率。
※优秀的线程调度机制
完成端口可以抽象为一个公共消息队列,当用户请求到达时,完成端口把这些请求加入其抽象出的公共消息队列。这一过程与多个工作线程轮询消息队列并从中取出消息加以处理是并发操作。这种方式很好地实现了异步通信和负载均衡,因为它使几个线程“公平地”处理多客户端的I/O,并且线程空闲时会被挂起,不会占用CPU周期。
IOCP模型充分利用Windows系统内核,可以实现仅用少量的几个线程来处理和多个client之间的所有通信,消除了无谓的线程上下文切换,最大限度的提高了网络通信的性能。
※IOCP的使用
初次学习使用IOCP的朋友在熟悉各个API时,建议参看MSDN的官方文档MSDN
IOCP的使用主要分为以下几步:
- 创建完成端口(iocp)对象
- 创建一个或多个工作线程,在完成端口上执行并处理投递到完成端口上的I/O请求
- Socket关联iocp对象,在Socket上投递网络事件
- 工作线程调用GetQueuedCompletionStatus函数获取完成通知封包,取得事件信息并进行处理
※1 创建完成端口对象
使用IOCP模型,首先要调用 CreateIoCompletionPort 函数创建一个完成端口对象,Winsock将使用这个对象为任意数量的套接字句柄管理 I/O 请求。函数定义如下:
1 | HANDLE WINAPI CreateIoCompletionPort( |
此函数的两个不同功能:
- 创建一个完成端口对象
- 将一个或多个文件句柄(这里是套接字句柄)关联到 I/O 完成端口对象
最初创建完成端口对象时,唯一需要设置的参数是 NumberOfConcurrentThreads,该参数定义了 允许在完成端口上同时执行的线程的数量。理想情况下,我们希望每个处理器仅运行一个线程来为完成端口提供服务,以避免线程上下文切换。NumberOfConcurrentThreads 为0表示系统允许的线程数量和处理器数量一样多。因此,可以简单地使用以下代码创建完成端口对象,取得标识完成端口的句柄。
HANDLE m_hCompletion = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE,0,0,0);
※2 I/O工作线程和完成端口
I/O 工作线程在完成端口上执行并处理投递的I/O请求。关于工作线程的数量,要注意的是,创建完成端口时指定的线程数量和这里要创建的线程数量不是一回事。CreateIoCompletionPort 函数的 NumberOfConcurrentThreads 参数明确告诉系统允许在完成端口上同时运行的线程数量。如果创建的线程数量多于 NumberOfConcurrentThreads,也仅有NumberOfConcurrentThreads 个线程允许运行。
但也存在确实需要创建更多线程的特殊情况,这主要取决于程序的总体设计。如果某个线程调用了一个函数,如 Sleep 或 WaitForSingleObject,进入了暂停状态,多出来的线程中就会有一个开始运行,占据休眠线程的位置。
有了足够的工作线程来处理完成端口上的 I/O 请求后,就该为完成端口关联套接字句柄了,这就用到了 CreateCompletionPort 函数的前3个参数。
- FileHandle:要关联的套接字句柄
- ExistingCompletionPort:要关联的完成端口对象句柄
- CompletionKey:指定一个句柄唯一(per-handle)数据,它将与FileHandle套接字句柄关联在一起
※3 完成端口和重叠I/O
向完成端口关联套接字句柄之后,便可以通过在套接字上投递重叠发送和接收请求处理 I/O。在这些 I/O 操作完成时,I/O 系统会向完成端口对象发送一个完成通知封包。I/O 完成端口以先进先出的方式为这些封包排队。工作线程调用 GetQueuedCompletionStatus 函数可以取得这些队列中的封包。函数定义如下:
1 | BOOL GetQueuedCompletionStatus( |
参数说明
- CompletionPort:完成端口对象句柄
- lpNumberOfBytesTransferred:I/O操作期间传输的字节数
- lpCompletionKey:关联套接字时指定的句柄唯一数据
- lpOverlapped:投递 I/O 请求时使用的重叠对象地址,进一步得到 I/O 唯一(per-I/O)数据
lpCompletionKey 参数包含了我们称为 per-handle 的数据,该数据在套接字第一次关联到完成端口时传入,用于标识 I/O 事件是在哪个套接字句柄上发生的。可以给这个参数传递任何类型的数据。
lpOverlapped 参数指向一个 OVERLAPPED 结构,结构后面便是我们称为per-I/O的数据,这可以是工作线程处理完成封包时想要知道的任何信息。
per-handle数据和per-I/O数据结构类型示例
1 |
|
※4 示例程序
主线程首先创建完成端口对象,创建工作线程处理完成端口对象中的事件;然后创建监听套接字,开始监听服务端口;循环处理到来的连接请求,该过程具体如下:
- 调用 accept 函数等待接受未决的连接请求
- 接受新连接后,创建 per-handle 数,并将其关联到完成端口对象
- 在新接受的套接字上投递一个接收请求,该I/O完成后,由工作线程负责处理
1 | void main() |
I/O 工作线程循环调用 GetQueuedCompletionStatus 函数从 I/O 完成端口移除完成的 I/O 通知封包,解析并进行处理。
1 | DWORD WINAPI ServerThread(LPVOID lpParam) |
※5 恰当地关闭IOCP
关闭 I/O 完成端口时,特别是有多个线程在socket上执行 I/O 时,要避免当重叠操作正在进行时释放它的 OVERLAPPED 结构。阻止该情况发生的最好方法是在每个 socket 上调用 closesocket 函数,确保所有未决的重叠 I/O 操作都会完成。
一旦所有socket关闭,就该终止完成端口上处理 I/O 事件的工作线程了。可以通过调用 PostQueuedCompletionStatus 函数发送特定的完成封包来实现。所有工作线程都终止之后,可以调用 CloseHandle 函数关闭完成端口。
※Windows之IOCP[3]
IOCP全称I/O Completion Port,中文译为I/O完成端口。IOCP是一个异步I/O的Windows API,它可以高效地将I/O事件通知给应用程序,类似于Linux中的Epoll,关于epoll可以参考linux之epoll
※简介
IOCP模型属于一种通讯模型,适用于Windows平台下高负载服务器的一个技术。在处理大量用户并发请求时,如果采用一个用户一个线程的方式那将造成CPU在这成千上万的线程间进行切换,后果是不可想象的。而IOCP完成端口模型则完全不会如此处理,它的理论是并行的线程数量必须有一个上限-也就是说同时发出500个客户请求,不应该允许出现500个可运行的线程。目前来说,IOCP完成端口是Windows下性能最好的I/O模型,同时它也是最复杂的内核对象。它避免了大量用户并发时原有模型采用的方式,极大的提高了程序的并行处理能力。
※原理图
一共包括三部分:完成端口(存放重叠的I/O请求),客户端请求的处理,等待者线程队列(一定数量的工作者线程,一般采用CPU*2个)
完成端口中所谓的[端口]并不是我们在TCP/IP中所提到的端口,可以说是完全没有关系。它其实就是一个通知队列,由操作系统把已经完成的重叠I/O请求的通知放入其中。当某项I/O操作一旦完成,某个可以对该操作结果进行处理的工作者线程就会收到一则通知。
通常情况下,我们会在创建一定数量的工作者线程来处理这些通知,也就是线程池的方法。线程数量取决于应用程序的特定需要。理想的情况是,线程数量等于处理器的数量,不过这也要求任何线程都不应该执行诸如同步读写、等待事件通知等阻塞型的操作,以免线程阻塞。每个线程都将分到一定的CPU时间,在此期间该线程可以运行,然后另一个线程将分到一个时间片并开始执行。如果某个线程执行了阻塞型的操作,操作系统将剥夺其未使用的剩余时间片并让其它线程开始执行。也就是说,前一个线程没有充分使用其时间片,当发生这样的情况时,应用程序应该准备其它线程来充分利用这些时间片。
※IOCP优点
基于IOCP的开发是异步IO的,决定了IOCP所实现的服务器的高吞吐量。
通过引入IOCP,会大大减少Thread切换带来的额外开销,最小化的线程上下文切换,减少线程切换带来的巨大开销,让CPU把大量的事件用于线程的运行。当与该完成端口相关联的可运行线程的总数目达到了该并发量,系统就会阻塞,
※IOCP应用
※创建和关联完成端口
1 | //功能:创建完成端口和关联完成端口 |
※与socket进行关联
1 | typedef struct{ |
※获取队列完成状态
1 | //功能:获取队列完成状态 |
※用于IOCP的特点函数
1 | //用于IOCP的特定函数 |
※投递一个队列完成状态
1 | //功能:投递一个队列完成状态 |
※示例
※Server.cpp
1 |
|
※client.cpp
1 |
|
※C++ | IOCP 完成端口,最高性能的网络编程模型原理[4]
※0. 前言
在C++的网络编程中,存在诸多模型,如基础 Socket 模型,同步非阻塞的 select 模型,以及本文要详细说明的 IOCP 模型等。
而随着业务的深入开发,应用场景的不断拓展,一般的 Socket 模型由于其采用阻塞模式,会有很严重的性能问题,只会在我们初学网络编程的时候进行简单的使用,如开发一对一或者一对少量客户景。
而 select 模型,则采用了同步非阻塞 I/O 机制,通过 select 函数来轮询处理客户端发送过来的 Socket,很好的解决了 Socket 模型中的性能问题。在 Linux 和 Windows 平台,该模型都有比较广泛的应用场景,比如常见的 libevent 库底层就是使用的 select 模型。
至于 IOCP 模型,则来源于微软为 Windows 开的“后门”,采用特有的并发异步非阻塞通信机制,与 Linux 中的 Epoll 并称为两大高效模型,在高并发、高压环境的服务器需求下,非常推荐使用 IOCP 来应对。比如大名鼎鼎的 Nginx 服务器,底层就是使用的 IOCP 模型实现。
在这篇文章中,您将逐步认识到 IOCP 性能强劲的秘密。
※1. 什么是 IOCP?
IOCP:I/O completion ports(完成端口),一种用于处理多处理器系统中,多个异步 I/O 请求的线程模型。当进程创建 I/O 完成端口时,系统会为对应请求提供服务的线程创建关联的对象队列。利用 Windows 内核对象来对 I/O 进行调度。属于 C/S 通信模型中,性能最优秀的网络通信模型。
※2. IOCP 为什么快?
在弄清楚这个问题之前,我们需要了解 IOCP 实现的基本原理,通过实现过程,我们便可以一步步探知 IOCP 快的原因:
- 系统根据 CPU 核心的数量来创建线程;
- 系统使线程保持等待,当存在客户端请求时,将客户端请求加入到公共消息队列中;
- 系统创建的线程逐一排队,从消息队列中取出消息并对其进行处理;
- 当线程完成消息,且后续没有消息需要完成时,CPU 才会将线程挂起,不占用 CPU 的使用周期。
通俗点讲,IOCP 的实现很类似于我们坐高铁进站过安检,人群是一个个的消息,而安检机则是一个个工作线程,我们排队经过身份识别后,选择合适的位置进行安检。
![IOCP 的基本运行原理图](598c408facb5460ba85b38af57dcda94~tplv-k3u1fbpfcp-zoom-in-crop-mark 1512 0 0 0.webp)
在 IOCP 的基本运行原理中,我们会发现,CPU 基本上都是处在工作状态的,由于我们根据 CPU 的核心数创建工作线程,因此每个线程需要执行时,都能保证有可用的 CPU 资源进行调度,而同样,也能保证在执行过程中,尽可能少的发生线程的上下文切换,这是 IOCP 快的第一层“秘诀”。
在 IOCP 调度的过程中,我们不能忽略的问题是,即使我们为每个 CPU 分配了工作线程,那谁来给工作线程派“任务”呢?
此时我们便可以把目光放到那条公共消息队列中,在 IOCP 中,所有的工作线程会轮询这条公共消息队列,并从中取出消息加以处理。在这个过程中,队列与多个工作线程非常优雅地实现了异步通信与负载均衡,在线程空闲时,IOCP 也会及时将线程挂起,防止 CPU 周期的占用。
而第二条“秘诀”,便是这条公共消息队列,因此完成端口其实本质上与我们常说的端口并没有什么关系,感觉叫完成队列更合适。
至于最后一层“秘诀”,则需要回到我们的基本网络通信机制中去。
由前文可知,IOCP 运用的是异步 I/O 通信机制,那么异步 I/O 与同步 I/O 最大的区别在哪呢?
前置知识:在操作系统中,外部设备的 I/O 速度,与 CPU 相比,是有非常大的差距的。
异步与同步的本质,在于主线程与通信线程是否能够“并行”。
同步 I/O 机制,在发起 I/O 的时候,顾名思义,用户与设备的数据需要进行同步,将数据在内核缓冲区同步后,再经过拷贝返回到用户进程,此时会导致进程阻塞,影响通信效率。
![同步 I/O 机制](07b49c319ed14a70a5a84f00cb2d82a3~tplv-k3u1fbpfcp-zoom-in-crop-mark 1512 0 0 0.webp)
而异步 I/O ,则不需要等待操作完成,当用户进程发送请求之后,便可进行其他的操作,当内核将数据准备好之后,会将数据从内核缓冲区拷贝到用户进程。此时内核发送消息通知 I/O 请求完成即可。
![异步 I/O 机制](184d8777ebc34f89a438beac75b69237~tplv-k3u1fbpfcp-zoom-in-crop-mark 1512 0 0 0.webp)
由上文我们可知,异步与同步,同时具备在内核缓冲区拷贝到用户进程这个过程,而在等待外部 I/O 设备拷贝的时间里,异步可以执行其他的操作,而同步只能干等着。因此在高性能网络服务器中,使用异步通信机制,是必不可少的。这也是 IOCP 快的另一层“秘诀”。
因此我们可以稍微总结一下,IOCP 快的原因:
- 根据 CPU 内核数量创建工作线程,使用公共消息队列进行调度,保证 CPU 资源的合理利用;
- 使用公共消息队列,保证工作线程之间的负载均衡,防止 CPU 周期被浪费;
- 利用异步 I/O 通信机制,是主线程与网络线程”并行“。
※3. 使用 IOCP 的基本流程
据上文分析之后,大家也基本上对 IOCP 的原理有了一个认识,那下一步,我们就稍微了解一下 IOCP 使用的基本流程。
与基础的 Socket 通信类似,IOCP 其实也有绑定端口,创建连接,接受数据等一系列操作。简单地,使用 IOCP 的基本流程如下:
- 初始化完成端口和工作线程
- 创建 Socket,并绑定到完成端口上,监听消息连接
- 接受并监听数据
- 关闭完成端口
一般地,我们创建CPU核心数*2的工作线程,使得在某个线程 Sleep() 或WSAWaitForMultipleEvents() 将线程挂起时(此时不占用CPU时间片),CPU的内核仍旧有线程在工作,减少线程调度的时间,保证程序的执行效率)。
而具体的 IOCP 执行,诸位可以由这张流程图,直观的看到: ![IOCP 执行流程图](5169c4e57e1449a8b751ef8867e1f21c~tplv-k3u1fbpfcp-zoom-in-crop-mark 1512 0 0 0.webp)
※4. 尾声
完成端口的理解,其实相对来说没那么复杂,主要是需要对其性能强劲的各个组成进行详细的了解和认识,只有逐步拆分逐步深入,才能更精确的了解到其性能强劲的原因。而在使用的过程中,只要遵循完成端口的基本流程,并将其加入项目逐步拓展即可。在众多优秀的网络库中,诸如 AISO 网络库,其底层实现其实也用到了 IOCP 完成端口,利用封装好的网络库,有时候也比使用底层库要来得更轻松易用。因此重点在如何理解其原理,从业务入手,简化开发流程与压力,这才是上上策。
※IOCP详解[5]
※IOCP详解
IOCP(I/O Completion Port,I/O完成端口)是性能最好的一种I/O模型。它是应用程序使用线程池处理异步I/O请求的一种机制。在处理多个并发的异步I/O请求时,以往的模型都是在接收请求是创建一个线程来应答请求。这样就有很多的线程并行地运行在系统中。而这些线程都是可运行的,Windows内核花费大量的时间在进行线程的上下文切换,并没有多少时间花在线程运行上。再加上创建新线程的开销比较大,所以造成了效率的低下。
Windows Sockets应用程序在调用WSARecv()函数后立即返回,线程继续运行。当系统接收数据完成后,向完成端口发送通知包(这个过程对应用程序不可见)。
应用程序在发起接收数据操作后,在完成端口上等待操作结果。当接收到I/O操作完成的通知后,应用程序对数据进行处理。
完成端口其实就是上面两项的联合使用基础上进行了一定的改进。
一个完成端口其实就是一个通知队列,由操作系统把已经完成的重叠I/O请求的通知放入其中。当某项I/O操作一旦完成,某个可以对该操作结果进行处理的工作者线程就会收到一则通知。而套接字在被创建后,可以在任何时候与某个完成端口进行关联。
众所皆知,完成端口是在WINDOWS平台下效率最高,扩展性最好的IO模型,特别针对于WINSOCK的海量连接时,更能显示出其威力。其实建立一个完成端口的服务器也很简单,只要注意几个函数,了解一下关键的步骤也就行了。
分为以下几步来说明完成端口:
- 同步IO与异步IO
- 函数
- 常见问题以及解答
- 步骤
- 例程
※0、同步IO与异步IO
同步I/O首先我们来看下同步I/O操作,同步I/O操作就是对于同一个I/O对象句柄在同一时刻只允许一个I/O操作,原理图如下:
由图可知,内核开始处理I/O操作到结束的时间段是T2~T3,这个时间段中用户线程一直处于等待状态,如果这个时间段比较短,则不会有什么问题,但是如果时间比较长,那么这段时间线程会一直处于挂起状态,这就会很严重影响效率,所以我们可以考虑在这段时间做些事情。
异步I/O操作则很好的解决了这个问题,它可以使得内核开始处理I/O操作到结束的这段时间,让用户线程可以去做其他事情,从而提高了使用效率。
由图可知,内核开始I/O操作到I/O结束这段时间,用户层可以做其他的操作,然后,当内核I/O结束的时候,可以让I/O对象或者时间对象通知用户层,而用户线程GetOverlappedResult来查看内核I/O的完成情况。
※1、函数
我们在完成端口模型下会使用到的最重要的两个函数是:
1 | CreateIoCompletionPort、GetQueuedCompletionStatus |
CreateIoCompletionPort 的作用是创建一个完成端口和把一个IO句柄和完成端口关联起来:
1 | // 创建完成端口 |
1 | // 把一个IO句柄和完成端口关联起来,这里的句柄是一个socket 句柄 |
其中第一个参数是句柄,可以是文件句柄、SOCKET句柄。
第二个就是我们上面创建出来的完成端口,这里就把两个东西关联在一起了。
第三个参数很关键,叫做PerHandleData,就是对应于每个句柄的数据块。我们可以使用这个参数在后面取到与这个SOCKET对应的数据。
最后一个参数给0,意思就是根据CPU的个数,允许尽可能多的线程并发执行。
GetQueuedCompletionStatus的作用就是取得完成端口的结果:
1 | // 从完成端口中取得结果 |
第一个参数是完成端口
第二个参数是表明这次的操作传递了多少个字节的数据
第三个参数是OUT类型的参数,就是前面CreateIoCompletionPort传进去的单句柄数据,这里就是前面的SOCKET句柄以及与之相对应的数据,这里操作系统给我们返回,让我们不用自己去做列表查询等操作了。
第四个参数就是进行IO操作的结果,是我们在投递WSARecv / WSASend 等操作时传递进去的,这里操作系统做好准备后,给我们返回了。非常省事!!
个人感觉完成端口就是操作系统为我们包装了很多重叠IO的不爽的地方,让我们可以更方便的去使用,下篇我将会尝试去讲述完成端口的原理。
※2、常见问题和解答
※1)什么是单句柄数据(PerHandle)和单IO数据(PerIO)
单句柄数据就是和句柄对应的数据,像socket句柄,文件句柄这种东西。
单IO数据,就是对应于每次的IO操作的数据。例如每次的WSARecv/WSASend等等
其实我觉得PER是每次的意思,翻译成每个句柄数据和每次IO数据还比较清晰一点。
在完成端口中,单句柄数据直接通过GetQueuedCompletionStatus 返回,省去了我们自己做容器去管理。单IO数据也容许我们自己扩展OVERLAPPED结构,所以,在这里所有与应用逻辑有关的东西都可以在此扩展。
※2)如何判断客户端的断开
我们要处理几种情况
a. 如果客户端调用了closesocket,我们就可以这样判断他的断开:
1 | if(0== GetQueuedCompletionStatus(CompletionPort, &BytesTransferred, ...) |
b. 如果是客户端直接退出,那就会出现64错误,指定的网络名不可再用。这种情况我们也要处理的:
1 | if(0== GetQueuedCompletionStatus(...)) |
※3)什么是IOCP?
我们已经提到IOCP 只不过是一个专门实现用来进行线程间的通信的技术,和信号量(semaphore)相似,因此IOCP并不是一个复杂的概念。一个IOCP 对象是与多个I/O对象关联的,这些对象支持挂起异步IO调用。直到一个挂起的异步IO调用结束为止,一个访问IOCP的线程都有可能被挂起。
完成端口的目标是使CPU保持在满负荷状态下工作。
※4)为什么使用IOCP?
使用IOCP,我们可以克服”一个客户端一个线程”的问题。我们知道,这样做的话,如果软件不是运行在一个多核及其上性能就会急剧下降。线程是系统资源,他们既不是无限制的、也不是代价低廉的。
IOCP提供了一种只使用一些(I/O worker)线程去“相对公平地”完成多客户端的”输入输出”。线程会一直被挂起,而不会使用CPU时间片,直到有事情做完为止。
※5)IOCP是如何工作的?
当使用IOCP时,你必须处理三件事情:a)将一个Socket关联到完成端口;b)创建一个异步I/O调用; c)与线程进行同步。为了获得异步IO调用的结果,比如哪个客户端执行了调用,你必须传入两个参数:pCompletionKey参数和OVERLAPPED结构。
※3、步骤
编写完成端口服务程序,无非就是以下几个步骤:
- 创建一个完成端口
- 根据CPU个数创建工作者线程,把完成端口传进去线程里
- 创建侦听SOCKET,把SOCKET和完成端口关联起来
- 创建PerIOData,向连接进来的SOCKET投递WSARecv操作
- 线程里所做的事情:
- GetQueuedCompletionStatus,在退出的时候就可以使用PostQueudCompletionStatus使线程退出;
- 取得数据并处理;
※4、例程
下面是服务端的例程,可以使用sunxin视频中中的客户端程序来测试服务端。稍微研究一下,也就会对完成端口模型有个大概的了解了。
实例结果服务器、客户端如下:
1 | /* |
※IOCP编程小结(中)[6]
简介: 上一篇主要谈了一些基本理念,本篇将谈谈我个人总结的一些IOCP编程技巧。 网络游戏前端服务器的需求和设计 首先介绍一下这个服务器的技术背景。在分布式网络游戏服务器中,前端连接服务器是一种很常见的设计。
上一篇主要谈了一些基本理念,本篇将谈谈我个人总结的一些IOCP编程技巧。
※网络游戏前端服务器的需求和设计
首先介绍一下这个服务器的技术背景。在分布式网络游戏服务器中,前端连接服务器是一种很常见的设计。他的职责主要有:
1. 为客户端和后端的游戏逻辑服务器提供一个软件路由 —— 客户端一旦和前端服务器建立TCP连接以后就可以通过这个连接和后端的游戏服务器进行通讯,而不再需要和后端的服务器再建立新的连接。
2. 承担来自客户端的IO压力 —— 一组典型的网络游戏服务器需要服务少则几千多则上万(休闲游戏则可以多达几十万)的游戏客户端,这个IO处理的负载相当可观,由一组前端服务器承载这个IO负担可以有效的减轻后端服务器的IO负担,并且让后端服务器也只需要关心游戏逻辑的实现,有效的实现IO和业务逻辑的解耦。
架构如图:
对于网络游戏来说,客户端与服务器之间需要进行频繁的通讯,但是每个数据包的尺寸基本都很小,典型的大小为几个字节到几十个字节不等,同时用户上行的数据量要比下行数据量小的多。不同的游戏类型对延迟的要求不太一样,FPS类的游戏希望延迟要小于50ms,MMO类型的100~400ms,一些休闲类的棋牌游戏1000ms左右的延迟也是可以接受的。因此,网络游戏的通讯是以优化延迟的同时又必须兼顾小包的合并以防止网络拥塞,哪个因素为主则需要根据具体的游戏类型来决定。
技术背景就介绍这些,后面介绍的IOCP连接服务器就是以这些需求为设计目标的。
※对IOCP服务器框架的考察
在动手实现这个连接服务器之前,我首先考察了一些现有的开源IOCP服务器框架库,老牌的如ACE,整个库太多庞大臃肿,代码也显老态,无好感。boost.asio据说是个不错的网络框架也支持IOCP,我编译运行了一下他的例子,然后尝试着阅读了一下asio的代码,感觉非常恐怖,完全弄不清楚内部是怎么实现的,于是放弃。asio秉承了boost一贯的变态作风,将C++的语言技巧凌驾于设计和代码可读性之上,这是我非常反对的。其他一些不入流的IOCP框架也看了一些,真是写的五花八门什么样的实现都有,总体感觉下来IOCP确实不太容易把握和抽象,所以才导致五花八门的实现。最后,还是决定自己重新造轮子。
※服务框架的抽象
任何的服务器框架从本质上说都是封装一个事件(Event)消息循环。而应用层只要向框架注册事件处理函数,响应事件并进行处理就可以了。一般的同步IO处理框架是先收到IO事件然后再进行IO操作,这类的事件处理框架我们称之为Reactor。而IOCP的特殊之处在于用户是先发起IO操作,然后接收IO完成的事件,次序和Reactor是相反的,这类的事件处理框架我们称之为Proactor。从词根Re和Pro上,我们也可以容易的理解这两者的差别。除了网络IO事件之外,服务器应该还可以响应Timer事件及用户自定义事件。框架做的事情就是把这些事件统统放到一个消息队列里,然后从队列中取出事件,调用相应的事件处理函数,如此循环往复。
IOCP为我们提供了一个系统级的消息队列(称之为完成队列),事件循环就是围绕着这个完成队列展开的。在发起IO操作后系统会进行异步处理(如果能立刻处理的话也会直接处理掉),当操作完成后自动向这个队列投递一条消息,不管是直接处理还是异步处理,最后总会投递完成消息。
顺便提一下:这里存在一个性能优化的机会:当IO操作能够立刻完成的话,如果让系统不要再投递完成消息,那么就可以减少一次系统调用(这至少可以节省几个微秒的开销),做法是调用SetFileCompletionNotificationModes(handle, FILE_SKIP_COMPLETION_PORT_ON_SUCCESS),具体的可以查阅MSDN。
对于用户自定义事件可以使用Post来投递。对于Timer事件,我的做法则是实现一个TimerHeap的数据结构,然后在消息循环中定期检查这个TimerHeap,对超时的Timer事件进行调度。
IOCP完成队列返回的消息是一个OVERLAPPED结构体和一个ULONG_PTR complete_key。complete_key是在用户将Socket handle关联到IOCP的时候绑定的,其实用性不是很大,而OVERLAPPED结构体则是在用户发起IO操作的时候设置的,并且OVERLAPPED结构可以由用户通过继承的方式来扩展,因此如何用好OVERLAPPED结构在螺丝壳里做道场,就成了封装好IOCP的关键。
这里,我使用了一个C++模板技巧来扩展OVERLAPPED结构,先看代码:
1 | struct IOCPHandler |
IOCPHandler是用户对象的接口,用户扩展这个接口来实现IO完成事件的处理。然后通过一个OverlappedWrapper<T>
的模板类将用户对象和OVERLAPPED结构封装成一个对象,T类型就是用户扩展的对象,由于用户对象位于OVERLAPPED结构体的前面,因此我们会将OVERLAPPED的指针传递给IO操作的API,同时我们在OVERLAPPED结构的后面还放置了一个用户对象的指针,当GetQueuedCompletionStatus接收到OVERLAPPED结构体指针后,我们通过这个指针就可以找到用户对象的位置了,然后调用虚函数Complete或者OnError就可以了。
图解一下对象结构:
在事件循环里的处理方法 :
1 | DWORD size; |
在这里利用我们利用了C++的多态来扩展OVERLAPPED结构,在框架层完全不用关心接收到的是什么IO事件,只需要应用层自己关心就够了,同时也避免了使用丑陋的难于扩展的switch…case结构。
对于异步操作来说,最让人痛苦的事情就是需要把原本顺序逻辑的代码强行拆分成多块来回调,这使得代码中原本蕴含的顺序逻辑被打散,并且在各个代码块里的上下文变量无法共享,必须另外生成一个对象放置这些上下文变量,而这又引发一个对象生存期管理的问题,对于没有GC的C来说尤其痛苦。解决异步逻辑的痛苦之道目前有两种方案:一种是用coroutine(协作式线程)将异步逻辑变成同步逻辑,在Windows上可以使用Fiber来实现coroutine;另一种方案是使用闭包,闭包原本是函数式语言的特性,在C里并没有,不过幸运的是我们可以通过一个稍微麻烦一点的方法来模拟闭包行为。coroutine在解决异步逻辑方面是最拿手的,特别是一个函数里需要依次进行多个异步操作的时候尤其强大(在这种情况下闭包也相形见拙),但是另一方面coroutine的实现比较复杂,线程的手工调度常常把人绕晕,对于IOCP这种异步操作比较有限的场景有点杀鸡用牛刀的感觉。因此最后我还是决定使用C++来模拟闭包行为。
这里演示一个典型的异步IO用法,看代码:一个异步发送的例子:
这个例子中,我们在函数内部定义了一个SendHandler对象,模拟出了一个闭包的行为,我们可以把需要用到的上下文变量放置在SendHandler内,当下次回调的时候就可以访问到这些变量了。本例中,我们在SendHandler里记了一个cookie,其作用是当异步操作返回时,可能这个Client对象已经被回收了,这个时候如果再调用EndSend必然会导致错误的结果,因此我们通过cookie来判断这个Client对象是否是那个异步操作发起时的Client对象。
使用闭包虽然没有coroutine那样漂亮的顺序逻辑结构,但是也足够方便你把各个异步回调代码串起来,同时在闭包内共享需要用到的上下文变量。另外,最新版的C标准对闭包有了原生的支持,实现起来会更方便一些,如果你的编译器足够新的话可以尝试使用新的C特性。
※IO工作线程 单线程vs多线程
在绝大多数讲解IOCP的文章中都会建议使用多个工作线程来处理IO事件,并且把工作线程数设置为CPU核心数的2倍。根据我的印象,这种说法的出处来自于微软早期的官方文档。不过,在我看来这完全是一种误导。IOCP的设计初衷就是用尽可能少的线程来处理IO事件,因此使用单线程处理本身是没有问题的,这可以使实现简化很多。反之,用多线程来处理的话,必须处处小心线程安全的问题,同时也会涉及到加锁的问题,而不恰当的加锁反而会使性能急剧下降,甚至不如单线程程序。有些同学可能会认为使用多线程可以发挥多核CPU的优势,但是目前CPU的速度足够用来处理IO事件,一般现代CPU的单个核心要处理一块千兆网卡的IO事件是绰绰有余的,最多的可以同时处理2块网卡的IO事件,瓶颈往往在网卡上。如果是想通过多块网卡提升IO吞吐量的话,我的建议是使用多进程来横向扩展,多进程不但可以在单台物理服务器上进行扩展,并且还可以扩展到多台物理服务器上,其伸缩性要比多线程更强。
当时微软提出的这个建议我想主要是考虑到在IO线程中除了IO处理之外还有业务逻辑需要处理,使用多线程可以解决业务逻辑阻塞的问题。但是将业务逻辑放在IO线程里处理本身不是一种好的设计模式,这没有很好的做到IO和业务解耦,同时也限制了服务器的伸缩性。良好的设计应该将IO和业务解耦,使用多进程或者多线程将业务逻辑放在另外的进程或者线程里进行处理,而IO线程只需要负责最简单的IO处理,并将收到的消息转发到业务逻辑的进程或者线程里处理就可以了。我的前端连接服务器也是遵循了这种设计方法。
※关闭发送缓冲区实现自己的nagle算法
IOCP最大的优势就是他的灵活性,关闭socket上的发送缓冲区就是一例。很多人认为关闭发送缓冲的价值是可以减少一次内存拷贝的开销,在我看来这只是捡了一粒芝麻而已。主流的千兆网卡其最大数据吞吐量不过区区120MB/s,而内存数据拷贝的吞吐量是10GB/s以上,多一次120MB/s数据拷贝,仅消耗1%的内存带宽,意义非常有限。
在普通的Socket编程中,我们只有打开nagle算法或者不打开的选择,策略的选择和参数的微调是没有办法做到的。而当我们关闭发送缓冲之后,每次Send操作一定会等到数据发送到对方的协议栈里并且收到ACK确认才会返回完成消息,这就给了我们一个实现自定义的nagle算法的机会。对于网络游戏这种需要频繁发送小数据包,打开nagle算法可以有效的合并发送小数据包以降低网络IO负担,但另一方面也加大了延迟,对游戏性造成不利影响。有了关闭发送缓冲的特性之后,我们就可以自行决定nagle算法的实现细节,在上一个send操作没有结束之前,我们可以决定是立刻发送新的数据(以降低延迟),还是累积数据等待上一个send结束或者超时后再发送。更复杂一点的策略是可以让服务器容忍多个未结束的send操作,当超出一个阈值后再累积数据,使得在IO吞吐量和延迟上达到一个合理的平衡。
※发送缓冲的分配策略
前面提到了关闭socket的发送缓冲,那么就涉及到我们自己如何来分配发送缓冲的问题。
一种策略是给每个Socket分配一个固定大小的环形缓冲区。这会存在一个问题:当缓冲区内累积的未发送数据加上新发送的数据大小超出了缓冲区的大小,这个时候就会碰上麻烦,要么阻塞以等待前面的数据发送完毕(但是IO线程不可以阻塞),要么干脆直接把Socket关闭,一个妥协的办法是尽可能把发送缓冲区设置的大一些,但这又会白白浪费很多内存。
另一种策略是让所有的客户端socket共享一个非常大的环形缓冲区,假设我们保留一个1G的内存区域给这个环形缓冲区,每次需要向客户端发送数据时就从这个环形缓冲区分配内存,当缓冲区分配到底了再绕到开头重新分配。由于这个缓冲区非常大,1G的内存对千兆网卡来说至少需要花费10s才能发送完,并且在实际应用中这个时间会远超10s。因此当新的数据从头开始分配的时候,老的数据早已经发送掉了,不用担心将老的数据覆盖,即使碰到网络阻塞,一个数据包超过10s还未发送掉的话,我们也可以通过超时判断主动关闭这个socket。
※socket池和对象池的分配策略
允许socket重用是IOCP另一个优势,我们可以在server启动时,根据我们对最大服务人数的预计,将所有的socket资源都分配好。一般来说每个socket必需对应一个client对象,用来记录一些客户端的信息,这个对象池也可以和socket绑定并预先分配好。在服务运行前将所有的大块对象的内存资源都预先分配好,用一个FreeList来做对象池的分配,在客户端下线之后再将资源回收到池中。这样就可以避免在服务运行过程中动态的分配大的对象,而一些需要临时分配的小对象(例如OVERLAPPED结构),我们可以使用诸如tcmalloc之类的通用内存分配器来做,tcmalloc内部使用小对象池算法,其分配性能和稳定性非常好,并且他的接口是非侵入式的,我们仍然可以在代码里保留malloc/free及new/delete。很多服务在长期运行之后出现运行效率降低,内存占用过大等问题,都跟频繁的分配和释放内存导致出现大量的内存碎片有关。所以做好服务器的内存分配管理是至关重要的一环。
※采用完成端口(IOCP)实现高性能网络服务器(Windows c++版)[7]
※前言
TCP\IP已成为业界通讯标准。现在越来越多的程序需要联网。网络系统分为服务端和客户端,也就是c\s模式(client \ server)。client一般有一个或少数几个连接;server则需要处理大量连接。大部分情况下,只有服务端才特别考虑性能问题。本文主要介绍服务端处理方法,当然也可以用于客户端。
我也发表过c#版网络库。其实,我最早是从事c开发,多年前就实现了对完成端口的封装。最近又把以前的代码整理一下,做了测试,也和c#版网络库做了粗略对比。总体上,还是c性能要好一些。c#网络库见文章《一个高性能异步socket封装库的实现思路》。
Windows平台下处理socket通讯有多种方式;大体可以分为阻塞模式和非阻塞模式。阻塞模式下send和recv都是阻塞的。简单讲一下这两种模式处理思路。
阻塞模式:比如调用send时,把要发送的数据放到网络发送缓冲区才返回。如果这时,网络发送缓冲区满了,则需要等待更久的时间。socket的收发其实也是一种IO,和读写硬盘数据有些类似。一般来讲,IO处理速度总是慢的,不要和内存处理并列。对于调用recv,至少读取一个字节数据,函数才会返回。所以对于recv,一般用一个单独的线程处理。
非阻塞模式:send和recv都是非阻塞的;比如调用send,函数会立马返回。真正的发送结果,需要等待操作系统的再次通知。阻塞模式下一步可以完成的处理,在非阻塞模式下需要两步。就是多出的这一步,导致开发难度大大增加。高性能大并发网络服务器必须采用非阻塞模式。完成端口(IOCP)是非阻塞模式中性能最好的一种。
作者多年以前,就开始从事winsocket开发,最开始是采用c++、后来采用c#。对高性能服务器设计的体会逐步加深。人要在一定的压力下才能有所成就。最开始的一个项目是移动信令分析,所处理的消息量非常大;高峰期,每秒要处理30万条信令,占用带宽500M。无论是socket通讯还是后面的数据处理,都必须非常优化。所以从项目的开始,我就谨小慎微,对性能特别在意。项目实施后,程序的处理性能出乎意料。一台服务器可以轻松处理一个省的信令数据(项目是08年开始部署,现在的硬件性能远超当时)。程序界面如下:
题外话 通过这个项目我也有些体会:1)不要怀疑Windows的性能,不要怀疑微软的实力。有些人遇到性能问题,或是遇到奇怪的bug,总是把责任推给操作系统;这是不负责任的表现。应该反思自己的开发水平、设计思路。2)开发过程中,需要把业务吃透;业务是开发的基石。不了解业务,不可能开发出高性能的程序。所有的处理都有取舍,每个函数都有他的适应场合。有时候需要拿来主义,有时候需要从头开发一个函数。
※目标
开发出一个完善的IOCP程序是非常困难的。怎么才能化繁为简?需要把IOCP封装;同时这个封装库要有很好的适应性,能满足各种应用场景。一个好的思路就能事半功倍。我就是围绕这两个目标展开设计。
※1 程序开发接口
socket处理本质上可以分为:读、写、accept、socket关闭等事件。把这些事件分为两类:a)读、accept、socket关闭 b)写;a类是从库中获取消息,b类是程序主动调用函数。对于a类消息可以调用如下函数:
1 | //消息事件 |
对于b类,就是发送数据。当调用发送时,数据被放到库的发送缓冲中,函数里面返回。接口如下:
1 | enum EN_SEND_BUFFER_RESULT |
总的思路是接收时,放到接收缓冲;发送时,放到发送缓冲。外部接口只对内存中数据操作,没有任何阻塞。
※2 具有广泛的适应性
如果网络库可以用到各种场景,所处理的逻辑必须与业务无关。所以本库接收和发送的都是字节流。包协议一般有长度指示或有开始结束符。需要把字节流分成一个个完整的数据包。这就与业务逻辑有关了。所以要有分层处理思想:
※库性能测试
首先对库的性能做测试,使大家对库的性能有初步印象。这些测试都不是很严格,大体能反映程序的性能。IOCP是可扩展的,就是同时处理10个连接与同时处理1000个连接,性能上没有差别。
我的机器配置不高,cup为酷睿2 双核 E7500,相当于i3低端。
1)两台机器测试,一个发送,一个接收:带宽占用40M,整体cpu占用10%,程序占用cpu不超过3%。
2)单台机器,两个程序互发:收发数据达到30M字节,相当于300M带宽,cpu占用大概25%。
3)采用更高性能机器测试,两个程序对发数据:cpu为:i5-7500 CPU @ 3.40GHz
收发数据总和80M字节每秒,接近1G带宽。cpu占用25%。测试程序下载地址 :《完成端口(IOCP)性能测试程序(c++版本 64位程序)》。只有exe程序,不包括代码。
※网络库设计思路
服务器要启动监听,当有客户端连接时,生成新的socket句柄;该socket句柄与完成端口关联,后续读写都通过完成端口完成。
※1 socket监听(Accept处理)
关于监听处理,参考我另一篇文章《单线程实现同时监听多个端口》。
※2 数据接收
收发数据要用到类型OVERLAPPED。需要对该类型进一步扩充,这样当从完成端口返回时,可以获取具体的数据和操作类型。这是处理完成端口一个非常重要的技巧。
1 | //完成端口操作类型 |
发送处理:overlap包含要发送的数据。调用此函数会立马返回;当有数据到达时,会有通知。
1 | BOOL NetServer::PostRcvBuffer(SOCKET socket, PER_IO_OPERATION_DATA *overlap) |
从完成端口获取读数据事件通知:
1 | DWORD NetServer::Deal_CompletionRoutine() |
※3 数据发送
数据发送时,先放到发送缓冲,再发送。向完成端口投递时,每个连接同时只能有一个正在投递的操作
1 | BOOL NetServer::PostSendBuffer(SOCKET socket) |
※总结
开发一个好的封装库必须有的好的思路。对复杂问题要学会分解,每个模块功能合理,适应性要强;要有模块化、层次化处理思路。如果网络库也处理业务逻辑,处理具体包协议,它就无法做到通用性。一个通用性好的库,才值得我们花费大气力去做好。我设计的这个库,用在了公司多个系统上;以后无论遇到任何网络协议,这个库都可以用得上,一劳永逸的解决网络库封装问题。
※单线程实现同时监听多个端口(windows平台c++代码)[8]
※前言
多年前开发了一套网络库,底层实现采用IOCP(完成端口)。该库已在公司多个程序中应用;经过多次修改,长时间检验,已经非常稳定高效。最近把以前的代码梳理了一下,又加进了一些新的思路。代码结构更加合理,性能也有所提升。打算将该库一些的知识点写出来,以供参考。服务端要在多个端口监听,这种场合并不多见。但作为一个完善的网络库,似乎有必要支持此功能的。
※传统实现方法
如果监听端口个数很少,也可以采用传统的方法。因为accept函数是阻塞的,所以要实现在n个端口监听,就需要n个线程。如果监听端口个数不多,这也不是多大问题。如果监听端口多达几十个,这种方法就有些不妥。线程也是一种资源,线程过多占用资源会增加;也会导致系统负担加重。
※更可行的实现方法
实现方法有些曲折,需要一步一步分析;基本的原理就是将socket句柄与事件(event)相关联。Windows有相关的函数可以对多个事件监听,当某个事件被触发,就知道相应的socket有事件到达。可以对该socket做accept,因为已经确定该socket有事件了,所以accept函数会立即返回。这样就达到对多个端口同时监听的目的。
※1)生成socket,并与某个端口绑定
1 | struct LISTEN_SOCKET_INFO |
该函数已将需要的数据存储在列表m_listListenInfo中。
※2)启动监听线程,对多个事件监听
对多个事件监听用到如下函数:
DWORD WSAAPI WSAWaitForMultipleEvents( DWORD cEvents, const WSAEVENT *lphEvents, BOOL fWaitAll, DWORD dwTimeout, BOOL fAlertable );该函数最多可以对64个事件做跟踪,所以一个线程最多可以对64个端口做监听。(同时对超过64个端口监听的场合非常少见。本文不考虑。)
1 | //生成事件地址指针 |
下文accept函数调用,并不会阻塞。
1 | UINT IocpAccept::AcceptListenPort(SOCKET hListenSocket, UINT16 nListenPort) |
后记:同时对多个端口做监听,可能还有更好的方法。如果对几百个以上端口做监听,此方法可能就不太合适。通常情况下,对多个端口监听的场景比较少见,所以对更优化的处理方法也没深究。
※IOCP详解:如何通过IOCP(I/O Completion Port)提升I/O性能[9]
*简介:*IOCP(I/O Completion Port)是一种性能卓越的I/O模型,通过使用线程池处理异步I/O请求,有效提升系统性能。本文将深入解析IOCP的工作原理和优势,并探讨如何在实际应用中实现和优化IOCP。
IOCP(I/O Completion Port,I/O完成端口)是一种高效的I/O处理机制,尤其在处理大量并发异步I/O请求时表现出色。通过使用IOCP,应用程序可以利用线程池来管理异步I/O操作,避免了为每个I/O请求创建新线程的开销,提高了系统的整体性能。
在传统的I/O处理模型中,每当一个I/O请求到来时,系统会为其创建一个新线程来处理请求。这种方式会导致大量线程在系统中并行运行,增加了上下文切换的开销。由于线程的创建和销毁都需要耗费资源,这种方式的效率相对较低。
IOCP通过改进传统的I/O处理方式,解决了这些问题。它利用一个预先创建的线程池来处理异步I/O请求,避免了频繁的线程创建和销毁。当一个异步I/O操作完成时,IOCP会向一个特定的完成端口发送通知。应用程序可以在完成端口上等待操作结果,一旦收到完成通知,就可以对数据进行处理。
这种模型的优势在于,它减少了线程的创建和销毁开销,降低了上下文切换的频率,从而提高了系统的整体性能。此外,IOCP还提供了更好的可扩展性,能够轻松应对大量并发I/O请求。
在实际应用中,要实现IOCP,需要创建一个完成端口对象,并将线程池中的线程与该完成端口关联起来。一旦应用程序发起一个异步I/O操作,该操作会被提交到完成端口,并由关联的线程来处理。完成端口会负责管理这些操作的完成状态,并在操作完成后通知应用程序。
应用程序在完成端口上等待操作结果时,可以选择使用阻塞或非阻塞方式。阻塞方式会一直等待操作完成或超时,而非阻塞方式则会定期检查操作状态。根据实际需求选择合适的方式可以提高应用程序的效率和响应性。
值得注意的是,IOCP虽然具有显著的优势,但在某些场景下可能并不是最佳选择。例如,对于一些轻量级的I/O操作或小规模并发请求,使用IOCP可能会引入过多的开销。因此,在实际应用中,需要根据具体情况权衡利弊,选择最适合的I/O模型。
此外,为了充分利用IOCP的优势,还需要注意以下几点:
- 合理配置线程池的大小:根据实际需求和系统资源来调整线程池的大小,以获得最佳的性能表现。
- 避免过多的上下文切换:通过合理安排线程的执行顺序和优先级,减少不必要的上下文切换。
- 优化数据结构和算法:针对实际应用的场景,选择合适的数据结构和算法来提高I/O操作的效率。
- 监控和调优:定期监控系统的性能指标,根据实际情况对IOCP进行调优,以适应不断变化的工作负载。
总结来说,IOCP是一种高效、可扩展的I/O处理机制,通过合理配置和应用优化,可以帮助应用程序更好地应对大量并发异步I/O请求。了解IOCP的工作原理和应用技巧有助于在实际应用中提升系统的整体性能。
※IOCP模型与网络编程[10]
※前言
在老师分配任务(“尝试利用IOCP模型写出服务端和客户端的代码”)给我时,脑子一片空白,并不知道什么是IOCP模型,会不会是像软件设计模式里面的工厂模式,装饰模式之类的那些呢?嘿嘿,不过好像是一个挺好玩的东西,挺好奇是什么东西来的,又是一个新知识啦~于是,开始去寻找一大堆的资料,为这个了解做准备,只是呢,有时还是想去找一本书去系统地学习一下,毕竟网络的资料还是有点零散。话说,本人学习这个模型的基础是,写过一个简单的Socket服务器及客户端程序,外加一个简单的Socket单服务器对多客户端程序,懂一点点的操作系统原理的知识。于是,本着一个学习与应用的态度开始探究这个IOCP是个什么东西。
※提出相关问题
- IOCP模型是什么?
- IOCP模型是用来解决什么问题的?它为什么存在?
- 使用IOCP模型需要用到哪些知识?
- 如何使用IOCP模型与Socket网络编程结合起来?
- 学会了这个模型以后与我之前写过的简单的socket程序主要有哪些不同点?
※部分问题探究及解决:(绝大多数是个人理解,再加上个人是菜鸟,如果有什么不对的地方,欢迎指正)
- 什么是IOCP?什么是IOCP模型?IOCP模型有什么作用?
- IOCP(I/O Completion Port),常称I/O完成端口。
- IOCP模型属于一种通讯模型,适用于(能控制并发执行的)高负载服务器的一个技术。
- 通俗一点说,就是用于高效处理很多很多的客户端进行数据交换的一个模型。
- 或者可以说,就是能异步I/O操作的模型。
- 只是了解到这些会让人很糊涂,因为还是不知道它究意具体是个什么东东呢?
下面我想给大家看三个图:第一个是IOCP的内部工作队列图。(整合于《IOCP本质论》文章,在英文的基础上加上中文对照)
第二个是程序实现IOCP模型的基本步骤。(整合于《深入解释IOCP》,加个人观点、理解、翻译)
第三个是使用了IOCP模型及没使用IOCP模型的程序流程图。(个人理解绘制)
- IOCP的存在理由(IOCP的优点)及技术相关有哪些?
之前说过,很通俗地理解可以理解成是用于高效处理很多很多的客户端进行数据交换的一个模型,那么,它具体的优点有些什么呢?它到底用到了哪些技术了呢?在Windows环境下又如何去使用这些技术来编程呢?它主要使用上哪些API函数呢?呃~看来我真是一个问题多多的人,跟前面提出的相关问题变种延伸了不少的问题,好吧,下面一个个来解决。
使用IOCP模型编程的优点
① 帮助维持重复使用的内存池。(与重叠I/O技术有关)
② 去除删除线程创建/终结负担。
③ 利于管理,分配线程,控制并发,最小化的线程上下文切换。
④ 优化线程调度,提高CPU和内存缓冲的命中率。使用IOCP模型编程汲及到的知识点(无先后顺序)
① 同步与异步
② 阻塞与非阻塞
③ 重叠I/O技术
④ 多线程
⑤ 栈、队列这两种基本的数据结构需要使用上的API函数
① 与SOCKET相关
1、链接套接字动态链接库:int WSAStartup(…);
2、创建套接字库: SOCKET socket(…);
3、绑字套接字: int bind(…);
4、套接字设为监听状态: int listen(…);
5、接收套接字: SOCKET accept(…);
6、向指定套接字发送信息:int send(…);
7、从指定套接字接收信息:int recv(…);
② 与线程相关
1、创建线程:HANDLE CreateThread(…);
③ 重叠I/O技术相关
1、向套接字发送数据: int WSASend(…);
2、向套接字发送数据包: int WSASendFrom(…);
3、从套接字接收数据: int WSARecv(…);
4、从套接字接收数据包: int WSARecvFrom(…);
④ IOCP相关
1、创建完成端口: HANDLE WINAPI CreateIoCompletionPort(…);
2、关联完成端口: HANDLE WINAPI CreateIoCompletionPort(…);
3、获取队列完成状态: BOOL WINAPI GetQueuedCompletionStatus(…);
4、投递一个队列完成状态:BOOL WINAPI PostQueuedCompletionStatus(…);
※完整的简单的IOCP服务器与客户端代码实例
1 | // IOCP_TCPIP_Socket_Server.cpp |
1 | // IOCP_TCPIP_Socket_Client.cpp |
※实现UDP IOCP心得-zt[11]
TCP的IOCP是在Accept之后,将Accept创建的套接字与完成端口绑定,而在UDP中,则是把WSASocket或Socket创建的套接字与完成端口绑定。在实现UDP IOCP时,可以参考已有的TCP IOCP代码,例如http://www.cppblog.com/niewenlong/archive/2007/08/17/30224.html
另外http://www.codeproject.com/KB/IP/iocp-multicast-udp.aspx可供下的源码中的客户端代码是UDP IOCP实现
以下数据结构非常重要。
1 | typedef struct _PER_IO_OPERATION_DATA |
实现过程
创建LPPER_IO_OPERATION_DATA数据结构并进行初始化(初始化很重要)
1 | LPPER_IO_OPERATION_DATA ioperdata; |
WSAGetLastError错误代码通过WSAGetLastError的信息来测试程序中出现的问题,常见的错误有10055、10014、6等,最主要的是变量的初始化。
※使用IOCP需要注意的一些问题~~(不断补充)[12]
1- 不要为每个小数据包发送一个IOCP请求,这样很容易耗尽IOCP的内部队列…从而产生10055错误.
2- 不要试图在发送出IOCP请求之后,收到完成通知之前修改请求中使用的数据缓冲的内容,因为在这段时间,系统可能会来读取这些缓冲.
3- 为了避免内存拷贝,可以尝试关闭SOCKET的发送和接收缓冲区,不过代价是,你需要更多的接收请求POST到一个数据流量比较大的SOCKET,从而保证系统一直可以找到BUFFER来收取到来的数据.
4- 在发出多个接收请求的时候,如果你的WORKTHREAD不止一个,一定要使用一些手段来保证接收完成的数据按照发送接收请求的顺序处理,否则,你会遇到数据包用混乱的顺序排列在你的处理队列里…
5- 说起工作线程, 最好要根据MS的建议, 开 CPU个数*2+2 个, 如果你不了解IOCP的工作原理的话.
6- IOCP的工作线程是系统优化和调度的, 自己就不需要进行额外的工作了.如果您自信您的智慧和经验超过MS的工程师, 那你还需要IOCP么…
7-发出一个Send请求之后,就不需要再去检测是否发送完整,因为iocp会帮你做这件事情,有些人说iocp没有做这件事情,这和iocp的高效能是相悖的,并且我做过的无数次测试表明,Iocp要么断开连接,要么就帮你把每个发送请求都发送完整。
8- 出现数据错乱的时候,不要慌,要从多线程的角度检查你的解析和发送数据包的代码,看看是不是有顺序上的问题。
9- 当遇到奇怪的内存问题时,逐渐的减少工作线程的数量,可以帮你更快的锁定问题发生的潜在位置。
10-同样是遇到内存问题时,请先去检查你的客户端在服务器端内部映射对象的释放是否有问题。而且要小心的编写iocp完成失败的处理代码,防止引用一个错误的内部映射对象的地址。
11- overlapped对象一定要保存在持久的位置,并且不到操作完成(不管成功还是失败)不要释放,否则可能会引发各种奇怪的问题。
12- IOCP的所有工作都是在获取完成状态的那个函数内部进行调度和完成的,所以除了注意工作线程的数量之外,还要注意,尽量保持足够多的工作线程处在获取完成状态的那个等待里面,这样做就需要减少工作线程的负担,确保工作线程内部要处理费时的工作。(我的建议是工作线程和逻辑线程彻底区分开)
13- 刚刚想起来,overlapped对象要为每次的send和recv操作都准备一个全新的,不能图方便重复利用。
14- 尽量保持send和recv的缓冲的大小是系统页面大小的倍数,因为系统发送或者接收数据的时候,会锁用户内存的,比页面小的缓冲会浪费掉整个一个页面。(作为第一条的补充,建议把小包合并成大包发送)
※IOCP的例子[13]
以前在书上看过了IOCP,不过一直都没有写过代码。现在写的时候,着时对很多问题摸不着头脑。不过好在CSDN上有许多的对于IOCP问题的讨论帖,让我受益非浅啊,也把心中的一些迷茫解开了,下面给出的是可以运行的IOCP的C/S代码,自已试了在一个机器上开了一百来个客户端,跑起来暂时没出现问题(因为通信内容太简单了-)。
IOCP的三个函数:CreateIoCompletionPort、GetQueuedCompletionStatus、PostQueuedCompletionStatus;一个是用来创建想要的IOCP的HANDLE同时也是用来把我们想要的SOCKET绑定到这个HANDLE上,一个是获取IO这个HANDLE上对应的对列的状态,看有没有事件完成,一个是用来通知所有工作线程退出(这个函数我还没用到,关于这个功用是看资料上说的)。
我在写这个代码的时候,最主要的问题就是当通信完成了之后,是怎么样来判断是哪个SOCKET的哪个状态(SEND还是RECV)完成了。《WINDOWS网络编程》这本书里给的代码不是很全的哦,它的配套光盘又没有,不过好在CSDN里CB那块中有个朋友刚好帖出了这一章的代码。通过比较和一夜的思量,算是搞明白啦。主要的就是以下的数据:
1、在第二次CreateIoCompletionPort中,会传进去一个CompletionKey,这个就是要来关联到我们想要的SOCKET上的一些感兴趣的数据内容,当然最好是要一个SOCKET,也可以是其它,看自己程序的需要了。而通过GetQueueCompletionStatus的通过,就可以获得这些数据的地址了。
1 | typedef struct _PER_HANDLE_DATA |
2、第二个主要的数据结构就是这个了,现在真的是佩服当初设计这个结构的人啊(没办法,自己就是没想到这样利用法)。因为在POST操作(SEND或是RECV)是,都要一个OVERLAPPED,所以就把这个OVERLAPPED和要指明这次POST操作类型的代码OperationType(POST_SEND或POST_RECV)以及其它一些数据(比如接发收的缓冲)。这样子,在GetQueueCompletionStatus的时候,通过获取事件,也同时得到了OperationType和缓冲。这样,知道了通信类型,也得到了缓冲数据的缓冲区。这样就可以控制我们的通信了。
这个例子比较简单,没有复杂的数据处理过程(正在设计中,和大家交流交流)。用的是BCB的平台,不过写法上还是和VC里的一模一样的啊。
1 | typedef struct _PER_IO_OPERATION_DATA |
简单的客户端:
1 | //--------------------------------------------------------------------------- |
服务端。
1 | //--------------------------------------------------------------------------- |
※IOCP完全开发经验总结
※(一):简介[14]
一、后台框架:
1、IOCP内核库
库使用VC6开发(因为编译和运行快,文件小,依赖少),整体就是一个dll,主要封装了IOCP的核心代码,提供了非常多的C函数供使用。
2、Qt封装IOCP的库
这是第一层封装,将Dll库封装为一个类,完成了所有导出函数、句柄的封装,和IOCP库的载入和卸载。
3、Server封装
这是第二层封装,主要加入和解决了以下问题:
1、TCP数据的粘包处理,定义了数据的类型、简单验证、加密等。
2、随机数的同步。
3、定义了用户、群、群主的数据结构和功能,完善了群的管理(包括查找、进入和离开群,创建和解散群,群主的更换和T人,群数据发送和用户信息同步等)。
4、GameServer封装
这主要实现了业务逻辑,也做了其他一些亮点功能,比如多线程完成日志的记录、数据库的查询等。二、内核库介绍
1、心跳机制
windows系统内核的TCP协议里并没有实现心跳,导致客户端异常断开后无法检测到,所以自己实现了一套写在了核心代码里。
2、自定义程度高
导出的函数中有很多set开头的函数,让开发者自定义某些IOCP的运行机制。
3、回调函数
因为库接口是C,所以只能使用回调函数的机制来运行开发者代码,缺点是使用难度有所提高,但我封装的每一层都有回调函数的示例,参照着写即可。
4、池
内核主要有两个池:SocketContext池和IOContext池,运行起来后不会频繁的new和delete,而是通过池来分配和回收,大大降低了new和delete的开销。
5、收发各使用一个IOContext
Send维护一个发送队列,且发送一个包的最大长度是8192左右,内核发送大数据时按照这个包自动拆分包,且完善了发送错误处理。
6、锁
把这个单独提出来是因为之前的代码里用到了至少3个锁,分别用来保护两个池和一个SocketClient队列,最近我把内核重新进行了大的重构,把这三个锁都去掉了,实现了无锁IOCP,后期的文章里会慢慢进行说明。
7、其他
内核还处理了各式各样的异常和错误。目前就想起来这么多,除了IOCP核心代码是用小猪的更改而来(修改达到70%左右吧),其他的全部都是自己设计和编写的,后期也会进行开源。顺带说一句,用C++开发全栈真是活的不耐烦了。。。
原理和参考
原理方面的东西我就不赘述了,我是直接从最low模型到IOCP模型的,中间还有其他异步模型,因为都不如IOCP我就直接跳过,这里只讲一些小猪的代码里没提到的坑。关于其他异步模型和IOCP的原理、简单实现只需参考小猪的一篇文章就行:完成端口(CompletionPort)详解 - 手把手教你玩转网络编程系列之三
※(二):几个重要问题分析(上)[15]
WSASend
小猪的文章里并没有说WSASend如何安全的去用,只是一句话带过,说这个很简单,带着要严谨的科学和研究精神,我看了很多源码示例(包括说是有个很牛逼的老外写的),都没有详细的说这一部分,后来我又找了很多关于WSASend理论(包括MSDN),才总结了一些东西:
1、WSASend如果作为同步IO发送(与Send作用相同时),是非线程安全的,不能同时在多个线程中同时调用。
这个完全理解。
2、WSASend作为异步IO发送时,虽然是非线程安全的,但你可以放心的在多个线程中调用(-_-!)。
微软说的这个让人匪夷所思,我个人认为它不太可能是用了原子操作,而是windows系统本身就是个抢占式OS,也就是说,除非一个线程本身放弃CPU执行权,否则它会一直占用到死。所以它是不会把数据复制到协议栈一部分时跑去执行另一个线程代码。但我认为,对于多核CPU(此时是并行而不是并发),它还是有出错的几率(虽然非常非常低)。
3、不需要对发送失败的WSASend进行重发
这个忘了从哪里看到的了,不过确实如此,如果发送失败,说明TCP连接已经断开了,因为TCP协议本身就是保证传输的可靠性的。
4、其他问题
有博文称,虽然多线程调用WSASend是可以的,但是当你正分段发送一个大数据时,如果正好碰到了发送心跳的线程发送了一个字节的心跳,那么这一颗老鼠屎就坏了一锅汤,整个大数据就全部不能用了。所以,我甚至把心跳的WSASend也放在了同一个线程里。
5、我的方案
虽然上面说了这么多,多心的我还是只在一个线程中调用了WSASend,而且对发送失败的WSASend进行重新发送。
多个还是单个WSARecv、WSASend
我的方案是,一个SocketContext使用一个WSARecv和一个WSASend。下面举出反例:如果用多个WSARecv,假设数据是源源不断且量非常巨大的发到Server端,虽然完成端口队列是FIFO的,且取出的数据也是按顺序的,但我经过实际测试(使用PostQueuedCompletionStatus),还是会导致包的顺序混乱。原因也很简单,虽然Windows是抢占式OS,但多核CPU的线程有可能会并行的(not并发),所以会导致多个拿到队列中WSARecv数据的线程处理顺序不确定,比如后拿到WSARecv数据的线程会先处理,这就产生了顺序混乱问题。况且也不能保证咱的代码逻辑上完全没问题,所以还是安心用一个WSARecv吧。至于多个WSASend,如果真的和微软说的,WSASend正常情况下不会发送失败的话,我认为多个也无妨,但假设会失败,那你发送了N个WSASend,其中如果一个出了问题,你再重发,那仍然导致了包乱序的问题。反正就一个宗旨:服务端是不能出现任何问题的。那就多写几句放心的代码吧。
多线程VS单线程
当初没想到这个问题,直到看到一个博客,觉得说的也很有道理:
在绝大多数讲解IOCP的文章中都会建议使用多个工作线程来处理IO事件,并且把工作线程数设置为CPU核心数的2倍。根据我的印象,这种说法的出处来自于微软早期的官方文档。不过,在我看来这完全是一种误导。IOCP的设计初衷就是用尽可能少的线程来处理IO事件,因此使用单线程处理本身是没有问题的,这可以使实现简化很多。反之,用多线程来处理的话,必须处处小心线程安全的问题,同时也会涉及到加锁的问题,而不恰当的加锁反而会使性能急剧下降,甚至不如单线程程序。有些同学可能会认为使用多线程可以发挥多核CPU的优势,但是目前CPU的速度足够用来处理IO事件,一般现代CPU的单个核心要处理一块千兆网卡的IO事件是绰绰有余的,最多的可以同时处理2块网卡的IO事件,瓶颈往往在网卡上。如果是想通过多块网卡提升IO吞吐量的话,我的建议是使用多进程来横向扩展,多进程不但可以在单台物理服务器上进行扩展,并且还可以扩展到多台物理服务器上,其伸缩性要比多线程更强。当时微软提出的这个建议我想主要是考虑到在IO线程中除了IO处理之外还有业务逻辑需要处理,使用多线程可以解决业务逻辑阻塞的问题。但是将业务逻辑放在IO线程里处理本身不是一种好的设计模式,这没有很好的做到IO和业务解耦,同时也限制了服务器的伸缩性。良好的设计应该将IO和业务解耦,使用多进程或者多线程将业务逻辑放在另外的进程或者线程里进行处理,而IO线程只需要负责最简单的IO处理,并将收到的消息转发到业务逻辑的进程或者线程里处理就可以了。
原文链接:IOCP编程小结确实,使用单线程的话真会解放很多不必要的麻烦。但我的项目里仍然用了多线程,具体看下面的锁的问题。锁的问题:用还是不用
废话,当然能不用就不用了!起初我项目里至少用了3个锁,分别用来锁两个池(SocketContext和IOContext池)和一个Socket队列。因为有些逻辑在工作线程中处理了,比如AcceptIO返回时,得向Socket队列中加入一个新Socket,从两个池中申请一个IOContext和一个SocketContext继续处理,所以这个函数甚至同时会用到3个锁,另外还有心跳线程遍历时也会锁住Socket队列,这些都会导致运行效率的低下。后来我突然醒悟,小猪的代码可能有点误导,他在工作线程中处理了这些东西,那为何不统一到一个线程中处理呢?换个思维,工作线程只是用来传递数据,然后我们新开辟一个线程(也可以是主线程)来操作两个池、操作和遍历Socket队列、统一调用WSASend不就都解决了?所以我花了一周时间来重写了这块,工作线程所有的操作全都使用事件来发送给主线程来处理(当然你也可以重新开个线程),所以可以去掉所有的锁了。目前运行很顺利,效率就更不用说了。另外因为我用的是Qt,很方便的使用信号槽来给不同线程发送信号(事件),经测试信号槽发送不会因为队列数据过多而发送失败(只会一直涨内存),而以前测试的MFC使用PostMessage时,队列过多会返回FALSE,我也懒得研究过时的东西了,大家只需注意保证成功给处理线程发送事件即可。
※(二):几个重要问题分析(中)[16]
优雅的处理连接断开
据我目前遇到的断开类型共有4种:客户端主动断开、客户端异常断开、服务器主动断开和网络出现问题断开。只要系统检测到连接断开后,你在这个socket上投递的所有IOContext都会从队列中返回,只是返回值会不同。
- 客户端主动断开
一般是客户端调用closesocket函数,这种断开服务器会收到断开的标志,所以服务器上处理很简单:每个你在此socket上投递的IOContext都会从GetQueuedCompletionStatus返回,且函数本身返回TRUE,你传递的dwBytesTransfered会设置为0。
2. 客户端异常断开
一般是客户端异常退出、或者进程被杀导致的断开,但由于网络是畅通的,所以服务端仍然能收到断开信号,只是和客户端主动断开不同的是GetQueuedCompletionStatus会返回FALSE,GetLastError函数会返回 ERROR_NETNAME_DELETED 错误。
3. 服务器主动断开
服务器在调用closesocket后导致的断开,和客户端异常断开不同的是,GetQueuedCompletionStatus会返回FALSE,GetLastError函数会返回 ERROR_CONNECTION_ABORTED 错误。
4. 网络问题断开
这个就比较麻烦了,因为这种断开服务端是无法检测到的(比如网络断开、网络切换等无法发送断开信号时),这种断开检测只能使用心跳机制(服务端发送数据后客户端肯定要有回应,如果谁也不发数据,连接就会一直存在),我看过TCP协议,协议本身是有心跳机制的,可惜windows下好像并没有实现,我自己在网上搜过(setsockopt设置SO_RCVTIMEO,KEEPALIVE),不起任何作用,大家也不用浪费时间了,自己实现一个心跳吧,也不是很难,每过一段时间发送一个字节就行,只要是网络断开了,一般经过15到30秒服务端就能检测到,GetQueuedCompletionStatus会返回FALSE,GetLastError函数会返回 ERROR_SEM_TIMEOUT (信号灯超时)错误。
5. 如何处理
这里要注意小猪的IOCP源码有个错误,因为所有投递到此socket的IOContext都会返回(包括RecvIOContext和SendIOContext),小猪源码里把关闭时所有的IOContext所对应的SocketContext都会回收一次,资源重复释放,直接挂掉。所以,我们只需要在RecvContext里释放对应的SocketContext资源即可,如果判断是SendIOContext,就不能再处理了。对了,别忘了释放掉自身IOContext资源。还有一点非常注意,不管什么类型的断开,服务端必须要调用一次closesocket,否则资源会一直占用(亲测)!在我的源码里处理断开有点麻烦,除了要注意上面的以外,还有一点:
GetQueuedCompletionStatus返回的RecvIOContext,只是会调用closesocket且给业务工作线程发送一个disconnect事件,并不会释放SocketContext资源,这是因为我自己的业务里,SocketContext还保存了一个客户数据指针,所以业务处理完毕前不能释放,只有业务线程收到disconnect事件后,再去释放SocketContext资源。否则,会产生野指针问题。如何标志每一个连接(SocketContext)
这个问题也是非常容易忽略,出现概率也比较小,但是不能不注意。如果你的工程中,处理各种IO和SocketContext的线程和业务线程是同一个,且其他线程不会使用某个SocketContext时,则不用担心这个问题,因为你永远都不会给一个断开的socket发送信息(正常情况下),但如果不是同一个,则要注意了:上面说过一点,断开的socket会一直占用系统资源(包括socket句柄),你调用closesocket后才会释放掉,且会被系统重用!!!重用!!!重用!!!问题就产生于这里,据我测试重用的概率是非常高的(有一次居然隔了三四个socket就会重用之前的句柄),举个例子比较容易懂:假设你的业务线程很慢,有一个客户断开了,处理客户端开的线程非常快速的检测到并closesocket掉了,然后又新连接进来N个socket,正好占用了前面close掉的socket句柄,这时你的某个业务线程才开始处理之前客户的一些业务代码,处理完毕后会给此Socket发送结果。但此socket已经非彼socket了,你给一个无效的socket发送数据还好,系统顶多给你个ERROR,你给一个陌生的socket发送陌生的数据,又是一个大失误,问题你还不知道错误在哪里,根本无法找到!所以解决这个问题的方法是,你给每个SocketContext设置一个类似Cookie一样的东西,一般会想到GUID,但我的项目里用了一个自增uint类型的值,这也够用了,每次业务线程发送数据时只要携带这个唯一的Cookie,而IOCP进行Send时对队列中已存在的SocketContext的Cookie进行比较,一致时发送即可。粘包处理–包结构定义
这个问题其实不难,因为TCP是基于流的,系统收到的数据有可能经过优化,几个包的数据粘在一起,或者一个包分了几次才收到,所以要自己定义一个数据结构。我没有把粘包处理代码放在IOCP核心代码里,这样大家可以根据自己的喜好去定义包的格式。大家可以参考我定义的格式:
包识别码 | 包Flag | 包数据长度 | 包数据
包识别码:只有匹配到这个识别码,才表示这是个有效包。包Flag:相当于包的类型,我自己定义了几种:系统包、加密包、通信包、房间处理包、业务包等等。包数据长度:保存了后面包的数据长度。包数据:具体的数据(可能是加密的)。有了这个结构体,自然就很容易把粘包分开了。只是具体写代码时,要注意跳过无效的数据,还要注意包实际数据长度不够时,要把数据保存至缓冲区等待下一次数据的到来。
※(二):几个重要问题分析(下)[17]
※如何给IOCP工作线程发送自定义消息并处理
除了前面讲的投递三种IOContext都会从完成队列中返回(GetQueuedCompletionStatus),咱也可以投递自己的数据,让工作线程从队列中取出去处理,一般也没什么让工作线程去处理的,但要完美的关闭IOCP时还得用一下。给完成队列投递事件用PostQueuedCompletionStatus,参数和GetQueuedCompletionStatus的参数一一对应。具体可以看小猪的例程。有一点,小猪的例程里认为GetQueuedCompletionStatus有可能会出问题而多消耗一个退出事件(hShutdownEvent),所以在循环时用一个hShuntdownEvent来确保工作线程退出,但我个人觉得不好,因为这样会导致队列中剩余事件没有被梳理,所以只要咱把程序流程做对了,就不会有问题。系统从队列中取到我们投递的IOContext时,GetQueuedCompletionStatus的第2、3、4参数都有值,第2个参数是dwBytesTransfered,表示传输的字节数;第3个参数是我们在给Socket绑定完成端口时传递的参数,也就是SocketContext指针,在CreateIoCompletionPort的第3个参数时传递进去的;第4个参数就是我们投递的IOContext时都会携带一个overlapped,比如WSARecv的第6个参数,包含了IOContext结构体。所以我们在PostQueuedCompletionStatus时,只要让第3个或第4个值为NULL即可,表示这是我们自定义的事件(个人推荐还是让第3个参数为NULL),其他两个参数表示具体的自定义事件类型和值就好了。另外特别注意,咱多次调用PostQueuedCompletionStatus时是将事件发送到了完成端口队列中,取出时也是按队列方式先进先出的,但因为线程切换的不确定性,任务的执行仍然时不确定的,当初我就遇到过这种问题,这也是为什么不能在一个Socket上投递多个WSARecv来接收数据的重要原因。
※关于Accpet时客户端附带第一组数据的说明
我们在使用AcceptEx时,第4个参数如果不为0,那么建立连接时还得等待客户端发送第一组数据,AcceptIOContext才能从完成端口队列中返回。而且对应的,你还得在GetAcceptExSockAddrs的第2个参数也要修改,两个值要设置为一样的。有个问题是,咱设置的这个值,只是表示最大能接受的字节数,你真正发送数据时不能超过这个值,否则会覆盖掉后续的数据(有客户机的IP和端口数据),而且!!!这个函数不会返回真正收到数据的长度,这就麻烦了,首先你得发送一个固定格式或固定长度的数据才行,还有,你得确定这个数据没有分批发送,要一次性收到,因为AcceptEx也不是等数据满了才返回,哪怕你发送1个字节,它也会返回的。基于上述分析,个人推荐还是关闭为好(置为0),建立连接后AcceptEx立即返回。
※关于更优雅的关闭IOCP
就像小猪说的,一定要优雅的关闭IOCP,别退出时弹出一个无响应或者报错,会显得很low,更何况如果暴力关闭,会导致很多存在于队列中的IOContext、业务数据都没有处理,这也会导致一些无法预期的问题。首先是IOCP工作线程的关闭,小猪的文章也说的比较全,但是小猪因为在GetQueuedCompletionStatus前使用了hShutdownEvent,可能工作线程在队列中还有数据没处理时就退出了,所以我补充一下:Set关闭事件后,如果有Watch线程(心跳线程),先等待Watch线程结束;然后Close所有的Socket,让所有投递到队列中的Post和Recv的IOContext都返回(这些返回的IOContext会给业务线程投递关闭事件),然后给完成队列中投递退出事件(自定义事件)并等待所有线程退出(这样才会保证退出事件排在所有事件的后面),等所有工作线程返回后,就说明完成队列中的任务就真的空了。但是这样还没完!完成队列空了,业务线程并没有空,它还有一堆事件需要处理(比如所有的客户Close事件),说不定还得写数据库,不能这样随便退出,所以还得在最后,给业务线程发送一个退出事件,排在所有业务事件的最后(我用Qt实现方式是QMetaObject::invokeMethod调用Stop函数,而Stop函数用来关闭数据库、日志等),所有业务处理完毕后,最后再释放所有资源(包括释放Watch线程句柄、工作线程句柄、完成端口句柄、关闭ListenSocket、释放所有SocketContext和IOContext资源等)。真麻烦啊,我还是直接exit(0)吧,哈哈哈。
※(三):开发UDP的IOCP[18]
UDP和TCP不同,后者面向连接的,而前者并不需要连接,所以去掉了一个很重要的数据结构:SocketContext,代码也比TCP的简单很多,经过实际测试,也有一些坑需要跳一下,这里会一一说明。
- UDP和TCP区别
深入过协议的人应该很清楚,但我们只需要知道,TCP是面向连接的,基于数据流,会确保接收方收到的数据的顺序和正确性;而UDP是网络报文,报文是一个一个的发送,接收方收到的数据可能是乱序或丢失的(但收到的肯定是正确的,因为IP层会进行校验,如果错误直接丢掉)。优缺点也很明显,大体上说就是TCP消耗的资源比较多,使用起来稍微麻烦些(因为还有很多其他的功能),但不用咱来保证数据的一致性和流量控制等。UDP轻量、快速,使用简单,但根据需要可能要由咱来编写数据包的重组、重发等。根据特性,TCP适用于文本传输、文件传输、数据传输等不能有差错的地方,而UDP适用于实时视频聊天、语音聊天这种对数据一致性不是特别高的地方(缺一帧多一帧都无所谓那种)。
2. UDP报文长度
虽说协议中对UDP报文长度没什么要求,但实际使用时为了降低报文丢失率,我们尽可能让报文长度缩短,因为发送时IP层会对数据包进行拆分,收到时会进行重组,如果报文太大,IP层拆分的数据包如果有一个出错,就会把整个报文都丢掉了。所以,如果是局域网传输,UDP报文尽量设置为2500-8-20=2472字节,如果是Internet传输,尽量设置为576-8-20=548字节内。
3. IOContext结构体
因为UDP不会建立连接,所以TCP中的SocketContext结构体就用不到了,所以IOContext结构体就至关重要了,这里需要修改一下,我们的IOContext还得保存报文的IP和Port。同时去掉的还有AcceptIOContext,我们直接投递RecvIOContext就能收到数据,不需要投递AcceptIOContext。
4. 发送和接收
这部分我就不详细说了,把TCP的WSASend和WSARecv换成WSASendTo和WSARecvFrom即可,比TCP多了两个参数:用来接收的IP和Port(这两个再IOContext结构体内定义过了)。
Send也没那么复杂了,直接调用WSASendTo投递即可,不用关心什么多线程、发送乱序,反正都一样,哈哈~
5. 初始化
WSASocket(AF_INET, SOCK_DGRAM,IPPROTO_IP,NULL,0,WSA_FLAG_OVERLAPPED);
把流换成报文。
6. 其他
因为没有Socket,所以只需要投递一定量的RecvIOContext,关闭时回收;SendIOContext返回时,用完数据回收即可。
就这么多,注意一下就行,比TCP简单的多的多。
※完成端口(CompletionPort)详解 - 手把手教你玩转网络编程系列之三[19]
一. 完成端口的优点
我想只要是写过或者想要写C/S模式网络服务器端的朋友,都应该或多或少的听过完成端口的大名吧,完成端口会充分利用Windows内核来进行I/O的调度,是用于C/S通信模式中性能最好的网络通信模型,没有之一;甚至连和它性能接近的通信模型都没有。
完成端口和其他网络通信方式最大的区别在哪里呢?
(1) 首先,如果使用“同步”的方式来通信的话,这里说的同步的方式就是说所有的操作都在一个线程内顺序执行完成,这么做缺点是很明显的:因为同步的通信操作会阻塞住来自同一个线程的任何其他操作,只有这个操作完成了之后,后续的操作才可以完成;一个最明显的例子就是咱们在MFC的界面代码中,直接使用阻塞Socket调用的代码,整个界面都会因此而阻塞住没有响应!所以我们不得不为每一个通信的Socket都要建立一个线程,多麻烦?这不坑爹呢么?所以要写高性能的服务器程序,要求通信一定要是异步的。
(2) 各位读者肯定知道,可以使用使用“同步通信(阻塞通信)+多线程”的方式来改善(1)的情况,那么好,想一下,我们好不容易实现了让服务器端在每一个客户端连入之后,都要启动一个新的Thread和客户端进行通信,有多少个客户端,就需要启动多少个线程,对吧;但是由于这些线程都是处于运行状态,所以系统不得不在所有可运行的线程之间进行上下文的切换,我们自己是没啥感觉,但是CPU却痛苦不堪了,因为线程切换是相当浪费CPU时间的,如果客户端的连入线程过多,这就会弄得CPU都忙着去切换线程了,根本没有多少时间去执行线程体了,所以效率是非常低下的,承认坑爹了不?
(3) 而微软提出完成端口模型的初衷,就是为了解决这种"one-thread-per-client"的缺点的,它充分利用内核对象的调度,只使用少量的几个线程来处理和客户端的所有通信,消除了无谓的线程上下文切换,最大限度的提高了网络通信的性能,这种神奇的效果具体是如何实现的请看下文。
- 完成端口被广泛的应用于各个高性能服务器程序上,例如著名的Apache….如果你想要编写的服务器端需要同时处理的并发客户端连接数量有数百上千个的话,那不用纠结了,就是它了。
二. 完成端口程序的运行演示
我们可以发现一个令人惊讶的结果,采用了完成端口的Server程序(蓝色横线所示)所占用的CPU才为 3.82%,整个运行过程中的峰值也没有超过4%,是相当气定神闲的……哦,对了,这还是在Debug环境下运行的情况,如果采用Release方式执行,性能肯定还会更高一些,除此以外,在UI上显示信息也很大成都上影响了性能。
相反采用了多个并发线程的Client程序(紫色横线所示)居然占用的CPU高达11.53%,甚至超过了Server程序的数倍……
其实无论是哪种网络操模型,对于内存占用都是差不多的,真正的差别就在于CPU的占用,其他的网络模型都需要更多的CPU动力来支撑同样的连接数据。
虽然这远远算不上服务器极限压力测试,但是从中也可以看出来完成端口的实力,而且这种方式比纯粹靠多线程的方式实现并发资源占用率要低得多。
三. 完成端口的相关概念
在开始编码之前,我们先来讨论一下和完成端口相关的一些概念,如果你没有耐心看完这段大段的文字的话,也可以跳过这一节直接去看下下一节的具体实现部分,但是这一节中涉及到的基本概念你还是有必要了解一下的,而且你也更能知道为什么有那么多的网络编程模式不用,非得要用这么又复杂又难以理解的完成端口呢??也会坚定你继续学习下去的信心_
3.1 异步通信机制及其几种实现方式的比较
我们从前面的文字中了解到,高性能服务器程序使用异步通信机制是必须的。
而对于异步的概念,为了方便后面文字的理解,这里还是再次简单的描述一下:
异步通信就是在咱们与外部的I/O设备进行打交道的时候,我们都知道外部设备的I/O和CPU比起来简直是龟速,比如硬盘读写、网络通信等等,我们没有必要在咱们自己的线程里面等待着I/O操作完成再执行后续的代码,而是将这个请求交给设备的驱动程序自己去处理,我们的线程可以继续做其他更重要的事情,大体的流程如下图所示:
我可以从图中看到一个很明显的并行操作的过程,而“同步”的通信方式是在进行网络操作的时候,主线程就挂起了,主线程要等待网络操作完成之后,才能继续执行后续的代码,就是说要末执行主线程,要末执行网络操作,是没法这样并行的;
“异步”方式无疑比 “阻塞模式+多线程”的方式效率要高的多,这也是前者为什么叫“异步”,后者为什么叫“同步”的原因了,因为不需要等待网络操作完成再执行别的操作。
而在Windows中实现异步的机制同样有好几种,而这其中的区别,关键就在于图1中的最后一步“通知应用程序处理网络数据”上了,因为实现操作系统调用设备驱动程序去接收数据的操作都是一样的,关键就是在于如何去通知应用程序来拿数据。它们之间的具体区别我这里多讲几点,文字有点多,如果没兴趣深入研究的朋友可以跳过下一面的这一段,不影响的:)
(1) 设备内核对象,使用设备内核对象来协调数据的发送请求和接收数据协调,也就是说通过设置设备内核对象的状态,在设备接收数据完成后,马上触发这个内核对象,然后让接收数据的线程收到通知,但是这种方式太原始了,接收数据的线程为了能够知道内核对象是否被触发了,还是得不停的挂起等待,这简直是根本就没有用嘛,太低级了,有木有?所以在这里就略过不提了,各位读者要是没明白是怎么回事也不用深究了,总之没有什么用。
(2) 事件内核对象,利用事件内核对象来实现I/O操作完成的通知,其实这种方式其实就是我以前写文章的时候提到的《基于事件通知的重叠I/O模型》,链接在这里,这种机制就先进得多,可以同时等待多个I/O操作的完成,实现真正的异步,但是缺点也是很明显的,既然用WaitForMultipleObjects()来等待Event的话,就会受到64个Event等待上限的限制,但是这可不是说我们只能处理来自于64个客户端的Socket,而是这是属于在一个设备内核对象上等待的64个事件内核对象,也就是说,我们在一个线程内,可以同时监控64个重叠I/O操作的完成状态,当然我们同样可以使用多个线程的方式来满足无限多个重叠I/O的需求,比如如果想要支持3万个连接,就得需要500多个线程…用起来太麻烦让人感觉不爽;
(3) 使用APC( Asynchronous Procedure Call,异步过程调用)来完成,这个也就是我以前在文章里提到的《基于完成例程的重叠I/O模型》,链接在这里,这种方式的好处就是在于摆脱了基于事件通知方式的64个事件上限的限制,但是缺点也是有的,就是发出请求的线程必须得要自己去处理接收请求,哪怕是这个线程发出了很多发送或者接收数据的请求,但是其他的线程都闲着…,这个线程也还是得自己来处理自己发出去的这些请求,没有人来帮忙…这就有一个负载均衡问题,显然性能没有达到最优化。
(4) 完成端口,不用说大家也知道了,最后的压轴戏就是使用完成端口,对比上面几种机制,完成端口的做法是这样的:事先开好几个线程,你有几个CPU我就开几个,首先是避免了线程的上下文切换,因为线程想要执行的时候,总有CPU资源可用,然后让这几个线程等着,等到有用户请求来到的时候,就把这些请求都加入到一个公共消息队列中去,然后这几个开好的线程就排队逐一去从消息队列中取出消息并加以处理,这种方式就很优雅的实现了异步通信和负载均衡的问题,因为它提供了一种机制来使用几个线程“公平的”处理来自于多个客户端的输入/输出,并且线程如果没事干的时候也会被系统挂起,不会占用CPU周期,挺完美的一个解决方案,不是吗?哦,对了,这个关键的作为交换的消息队列,就是完成端口。
比较完毕之后,熟悉网络编程的朋友可能会问到,为什么没有提到WSAAsyncSelect或者是WSAEventSelect这两个异步模型呢,对于这两个模型,我不知道其内部是如何实现的,但是这其中一定没有用到Overlapped机制,就不能算作是真正的异步,可能是其内部自己在维护一个消息队列吧,总之这两个模式虽然实现了异步的接收,但是却不能进行异步的发送,这就很明显说明问题了,我想其内部的实现一定和完成端口是迥异的,并且,完成端口非常厚道,因为它是先把用户数据接收回来之后再通知用户直接来取就好了,而WSAAsyncSelect和WSAEventSelect之流只是会接收到数据到达的通知,而只能由应用程序自己再另外去recv数据,性能上的差距就更明显了。
最后,我的建议是,想要使用 基于事件通知的重叠I/O和基于完成例程的重叠I/O的朋友,如果不是特别必要,就不要去使用了,因为这两种方式不仅使用和理解起来也不算简单,而且还有性能上的明显瓶颈,何不就再努力一下使用完成端口呢?
3.2 重叠结构(OVERLAPPED)
我们从上一小节中得知,要实现异步通信,必须要用到一个很风骚的I/O数据结构,叫重叠结构“Overlapped”,Windows里所有的异步通信都是基于它的,完成端口也不例外。
至于为什么叫Overlapped?Jeffrey Richter的解释是因为“执行I/O请求的时间与线程执行其他任务的时间是重叠(overlapped)的”,从这个名字我们也可能看得出来重叠结构发明的初衷了,对于重叠结构的内部细节我这里就不过多的解释了,就把它当成和其他内核对象一样,不需要深究其实现机制,只要会使用就可以了,想要了解更多重叠结构内部的朋友,请去翻阅Jeffrey Richter的《Windows via C/C++》 5th 的292页,如果没有机会的话,也可以随便翻翻我以前写的Overlapped的东西,不过写得比较浅显……
这里我想要解释的是,这个重叠结构是异步通信机制实现的一个核心数据结构,因为你看到后面的代码你会发现,几乎所有的网络操作例如发送/接收之类的,都会用WSASend()和WSARecv()代替,参数里面都会附带一个重叠结构,这是为什么呢?因为重叠结构我们就可以理解成为是一个网络操作的ID号,也就是说我们要利用重叠I/O提供的异步机制的话,每一个网络操作都要有一个唯一的ID号,因为进了系统内核,里面黑灯瞎火的,也不了解上面出了什么状况,一看到有重叠I/O的调用进来了,就会使用其异步机制,并且操作系统就只能靠这个重叠结构带有的ID号来区分是哪一个网络操作了,然后内核里面处理完毕之后,根据这个ID号,把对应的数据传上去。
你要是实在不理解这是个什么玩意,那就直接看后面的代码吧,慢慢就明白了……
3.3 完成端口(CompletionPort)
对于完成端口这个概念,我一直不知道为什么它的名字是叫“完成端口”,我个人的感觉应该叫它“完成队列”似乎更合适一些,总之这个“端口”和我们平常所说的用于网络通信的“端口”完全不是一个东西,我们不要混淆了。
首先,它之所以叫“完成”端口,就是说系统会在网络I/O操作“完成”之后才会通知我们,也就是说,我们在接到系统的通知的时候,其实网络操作已经完成了,就是比如说在系统通知我们的时候,并非是有数据从网络上到来,而是来自于网络上的数据已经接收完毕了;或者是客户端的连入请求已经被系统接入完毕了等等,我们只需要处理后面的事情就好了。
各位朋友可能会很开心,什么?已经处理完毕了才通知我们,那岂不是很爽?其实也没什么爽的,那是因为我们在之前给系统分派工作的时候,都嘱咐好了,我们会通过代码告诉系统“你给我做这个做那个,等待做完了再通知我”,只是这些工作是做在之前还是之后的区别而已。
其次,我们需要知道,所谓的完成端口,其实和HANDLE一样,也是一个内核对象,虽然Jeff Richter吓唬我们说:“完成端口可能是最为复杂的内核对象了”,但是我们也不用去管他,因为它具体的内部如何实现的和我们无关,只要我们能够学会用它相关的API把这个完成端口的框架搭建起来就可以了。我们暂时只用把它大体理解为一个容纳网络通信操作的队列就好了,它会把网络操作完成的通知,都放在这个队列里面,咱们只用从这个队列里面取就行了,取走一个就少一个…。
关于完成端口内核对象的具体更多内部细节我会在后面的“完成端口的基本原理”一节更详细的和朋友们一起来研究,当然,要是你们在文章中没有看到这一节的话,就是说明我又犯懒了没写…在后续的文章里我会补上。这里就暂时说这么多了,到时候我们也可以看到它的机制也并非有那么的复杂,可能只是因为操作系统其他的内核对象相比较而言实现起来太容易了吧_
四. 使用完成端口的基本流程
说了这么多的废话,大家都等不及了吧,我们终于到了具体编码的时候了。
使用完成端口,说难也难,但是说简单,其实也简单 ---- 又说了一句废话=。=
大体上来讲,使用完成端口只用遵循如下几个步骤:
(1) 调用 CreateIoCompletionPort() 函数创建一个完成端口,而且在一般情况下,我们需要且只需要建立这一个完成端口,把它的句柄保存好,我们今后会经常用到它……
(2) 根据系统中有多少个处理器,就建立多少个工作者(为了醒目起见,下面直接说Worker)线程,这几个线程是专门用来和客户端进行通信的,目前暂时没什么工作;
(3) 下面就是接收连入的Socket连接了,这里有两种实现方式:一是和别的编程模型一样,还需要启动一个独立的线程,专门用来accept客户端的连接请求;二是用性能更高更好的异步AcceptEx()请求,因为各位对accept用法应该非常熟悉了,而且网上资料也会很多,所以为了更全面起见,本文采用的是性能更好的AcceptEx,至于两者代码编写上的区别,我接下来会详细的讲。
(4) 每当有客户端连入的时候,我们就还是得调用CreateIoCompletionPort()函数,这里却不是新建立完成端口了,而是把新连入的Socket(也就是前面所谓的设备句柄),与目前的完成端口绑定在一起。
至此,我们其实就已经完成了完成端口的相关部署工作了,嗯,是的,完事了,后面的代码里我们就可以充分享受完成端口带给我们的巨大优势,坐享其成了,是不是很简单呢?
(5) 例如,客户端连入之后,我们可以在这个Socket上提交一个网络请求,例如WSARecv(),然后系统就会帮咱们乖乖的去执行接收数据的操作,我们大可以放心的去干别的事情了;
(6) 而此时,我们预先准备的那几个Worker线程就不能闲着了, 我们在前面建立的几个Worker就要忙活起来了,都需要分别调用GetQueuedCompletionStatus() 函数在扫描完成端口的队列里是否有网络通信的请求存在(例如读取数据,发送数据等),一旦有的话,就将这个请求从完成端口的队列中取回来,继续执行本线程中后面的处理代码,处理完毕之后,我们再继续投递下一个网络通信的请求就OK了,如此循环。
关于完成端口的使用步骤,用文字来表述就是这么多了,很简单吧?如果你还是不理解,我再配合一个流程图来表示一下:
当然,我这里假设你已经对网络编程的基本套路有了解了,所以略去了很多基本的细节,并且为了配合朋友们更好的理解我的代码,在流程图我标出了一些函数的名字,并且画得非常详细。
另外需要注意的是由于对于客户端的连入有两种方式,一种是普通阻塞的accept,另外一种是性能更好的AcceptEx,为了能够方面朋友们从别的网络编程的方式中过渡,我这里画了两种方式的流程图,方便朋友们对比学习,图a是使用accept的方式,当然配套的源代码我默认就不提供了,如果需要的话,我倒是也可以发上来;图b是使用AcceptEx的,并配有配套的源码。
采用accept方式的流程示意图如下:
采用AcceptEx方式的流程示意图如下:
两个图中最大的相同点是什么?是的,最大的相同点就是主线程无所事事,闲得蛋疼……
为什么呢?因为我们使用了异步的通信机制,这些琐碎重复的事情完全没有必要交给主线程自己来做了,只用在初始化的时候和Worker线程交待好就可以了,用一句话来形容就是,主线程永远也体会不到Worker线程有多忙,而Worker线程也永远体会不到主线程在初始化建立起这个通信框架的时候操了多少的心……
图a中是由 _AcceptThread()负责接入连接,并把连入的Socket和完成端口绑定,另外的多个_WorkerThread()就负责监控完成端口上的情况,一旦有情况了,就取出来处理,如果CPU有多核的话,就可以多个线程轮着来处理完成端口上的信息,很明显效率就提高了。
图b中最明显的区别,也就是AcceptEx和传统的accept之间最大的区别,就是取消了阻塞方式的accept调用,也就是说,AcceptEx也是通过完成端口来异步完成的,所以就取消了专门用于accept连接的线程,用了完成端口来进行异步的AcceptEx调用;然后在检索完成端口队列的Worker函数中,根据用户投递的完成操作的类型,再来找出其中的投递的Accept请求,加以对应的处理。
读者一定会问,这样做的好处在哪里?为什么还要异步的投递AcceptEx连接的操作呢?
首先,我可以很明确的告诉各位,如果短时间内客户端的并发连接请求不是特别多的话,用accept和AcceptEx在性能上来讲是没什么区别的。
按照我们目前主流的PC来讲,如果客户端只进行连接请求,而什么都不做的话,我们的Server只能接收大约3万-4万个左右的并发连接,然后客户端其余的连入请求就只能收到WSAENOBUFS (10055)了,因为系统来不及为新连入的客户端准备资源了。
需要准备什么资源?当然是准备Socket了……虽然我们创建Socket只用一行SOCKET s= socket(…) 这么一行的代码就OK了,但是系统内部建立一个Socket是相当耗费资源的,因为Winsock2是分层的机构体系,创建一个Socket需要到多个Provider之间进行处理,最终形成一个可用的套接字。总之,系统创建一个Socket的开销是相当高的,所以用accept的话,系统可能来不及为更多的并发客户端现场准备Socket了。
而AcceptEx比Accept又强大在哪里呢?是有三点:
(1) 这个好处是最关键的,是因为AcceptEx是在客户端连入之前,就把客户端的Socket建立好了,也就是说,AcceptEx是先建立的Socket,然后才发出的AcceptEx调用,也就是说,在进行客户端的通信之前,无论是否有客户端连入,Socket都是提前建立好了;而不需要像accept是在客户端连入了之后,再现场去花费时间建立Socket。如果各位不清楚是如何实现的,请看后面的实现部分。
(2) 相比accept只能阻塞方式建立一个连入的入口,对于大量的并发客户端来讲,入口实在是有点挤;而AcceptEx可以同时在完成端口上投递多个请求,这样有客户端连入的时候,就非常优雅而且从容不迫的边喝茶边处理连入请求了。
(3) AcceptEx还有一个非常体贴的优点,就是在投递AcceptEx的时候,我们还可以顺便在AcceptEx的同时,收取客户端发来的第一组数据,这个是同时进行的,也就是说,在我们收到AcceptEx完成的通知的时候,我们就已经把这第一组数据接完毕了;但是这也意味着,如果客户端只是连入但是不发送数据的话,我们就不会收到这个AcceptEx完成的通知……这个我们在后面的实现部分,也可以详细看到。
最后,各位要有一个心里准备,相比accept,异步的AcceptEx使用起来要麻烦得多……
五. 完成端口的实现详解
又说了一节的废话,终于到了该动手实现的时候了……
这里我把完成端口的详细实现步骤以及会涉及到的函数,按照出现的先后步骤,都和大家详细的说明解释一下,当然,文档中为了让大家便于阅读,这里去掉了其中的错误处理的内容,当然,这些内容在示例代码中是会有的。
【第一步】创建一个完成端口
首先,我们先把完成端口建好再说。
我们正常情况下,我们需要且只需要建立这一个完成端口,代码很简单:
HANDLE m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );
呵呵,看到CreateIoCompletionPort()的参数不要奇怪,参数就是一个INVALID,一个NULL,两个0…,说白了就是一个-1,三个0……简直就和什么都没传一样,但是Windows系统内部却是好一顿忙活,把完成端口相关的资源和数据结构都已经定义好了(在后面的原理部分我们会看到,完成端口相关的数据结构大部分都是一些用来协调各种网络I/O的队列),然后系统会给我们返回一个有意义的HANDLE,只要返回值不是NULL,就说明建立完成端口成功了,就这么简单,不是吗?
有的时候我真的很赞叹Windows API的封装,把很多其实是很复杂的事整得这么简单……
至于里面各个参数的具体含义,我会放到后面的步骤中去讲,反正这里只要知道创建我们唯一的这个完成端口,就只是需要这么几个参数。
但是对于最后一个参数 0,我这里要简单的说两句,这个0可不是一个普通的0,它代表的是NumberOfConcurrentThreads,也就是说,允许应用程序同时执行的线程数量。当然,我们这里为了避免上下文切换,最理想的状态就是每个处理器上只运行一个线程了,所以我们设置为0,就是说有多少个处理器,就允许同时多少个线程运行。
因为比如一台机器只有两个CPU(或者两个核心),如果让系统同时运行的线程多于本机的CPU数量的话,那其实是没有什么意义的事情,因为这样CPU就不得不在多个线程之间执行上下文切换,这会浪费宝贵的CPU周期,反而降低的效率,我们要牢记这个原则。
【第二步】根据系统中CPU核心的数量建立对应的Worker线程
我们前面已经提到,这个Worker线程很重要,是用来具体处理网络请求、具体和客户端通信的线程,而且对于线程数量的设置很有意思,要等于系统中CPU的数量,那么我们就要首先获取系统中CPU的数量,这个是基本功,我就不多说了,代码如下:
1 | SYSTEM_INFO si; |
啊,等等!各位没发现什么问题么?为什么我8核的CPU却启动了16个线程?这个不是和我们第二步中说的原则自相矛盾了么?
哈哈,有个小秘密忘了告诉各位了,江湖上都流传着这么一个公式,就是:
我们最好是建立CPU核心数量*2那么多的线程,这样更可以充分利用CPU资源,因为完成端口的调度是非常智能的,比如我们的Worker线程有的时候可能会有Sleep()或者WaitForSingleObject()之类的情况,这样同一个CPU核心上的另一个线程就可以代替这个Sleep的线程执行了;因为完成端口的目标是要使得CPU满负荷的工作。
这里也有人说是建立 CPU“核心数量 * 2 +2”个线程,我想这个应该没有什么太大的区别,我就是按照我自己的习惯来了。
然后按照这个数量,来启动这么多个Worker线程就好可以了,接下来我们开始下一个步骤。
什么?Worker线程不会建?
Worker线程和普通线程是一样一样一样的啊~~~,代码大致上如下:
1 | // 根据CPU数量,建立*2的线程 |
其中,_WorkerThread是Worker线程的线程函数,线程函数的具体内容我们后面再讲。
【第三步】创建一个用于监听的Socket,绑定到完成端口上,然后开始在指定的端口上监听连接请求
最重要的完成端口建立完毕了,我们就可以利用这个完成端口来进行网络通信了。
首先,我们需要初始化Socket,这里和通常情况下使用Socket初始化的步骤都是一样的,大约就是如下的这么几个过程(详情参照我代码中的LoadSocketLib()和InitializeListenSocket(),这里只是挑出关键部分):
1 | // 初始化Socket库 |
需要注意的地方有两点:
(1) 想要使用重叠I/O的话,初始化Socket的时候一定要使用WSASocket并带上WSA_FLAG_OVERLAPPED参数才可以(只有在服务器端需要这么做,在客户端是不需要的);
(2) 注意到listen函数后面用的那个常量SOMAXCONN了吗?这个是在微软在WinSock2.h中定义的,并且还附赠了一条注释,Maximum queue length specifiable by listen.,所以说,不用白不用咯_
接下来有一个非常重要的动作:既然我们要使用完成端口来帮我们进行监听工作,那么我们一定要把这个监听Socket和完成端口绑定才可以的吧:
如何绑定呢?同样很简单,用 CreateIoCompletionPort()函数。
等等!大家没觉得这个函数很眼熟么?是的,这个和前面那个创建完成端口用的居然是同一个API!但是这里这个API可不是用来建立完成端口的,而是用于将Socket和以前创建的那个完成端口绑定的,大家可要看准了,不要被迷惑了,因为他们的参数是明显不一样的,前面那个的参数是一个-1,三个0,太好记了…
说实话,我感觉微软应该把这两个函数分开,弄个 CreateNewCompletionPort() 多好呢?
这里在详细讲解一下CreateIoCompletionPort()的几个参数:
1 | HANDLE WINAPI CreateIoCompletionPort( |
到此才算是Socket全部初始化完毕了。
初始化Socket完毕之后,就可以在这个Socket上投递AcceptEx请求了。
【第四步】在这个监听Socket上投递AcceptEx请求
这里的处理比较复杂。
这个AcceptEx比较特别,而且这个是微软专门在Windows操作系统里面提供的扩展函数,也就是说这个不是Winsock2标准里面提供的,是微软为了方便咱们使用重叠I/O机制,额外提供的一些函数,所以在使用之前也还是需要进行些准备工作。
微软的实现是通过mswsock.dll中提供的,所以我们可以通过静态链接mswsock.lib来使用AcceptEx。但是这是一个不推荐的方式,我们应该用WSAIoctl 配合SIO_GET_EXTENSION_FUNCTION_POINTER参数来获取函数的指针,然后再调用AcceptEx。
这是为什么呢?因为我们在未取得函数指针的情况下就调用AcceptEx的开销是很大的,因为AcceptEx 实际上是存在于Winsock2结构体系之外的(因为是微软另外提供的),所以如果我们直接调用AcceptEx的话,首先我们的代码就只能在微软的平台上用了,没有办法在其他平台上调用到该平台提供的AcceptEx的版本(如果有的话), 而且更糟糕的是,我们每次调用AcceptEx时,Service Provider都得要通过WSAIoctl()获取一次该函数指针,效率太低了,所以还不如我们自己直接在代码中直接去这么获取一下指针好了。
获取AcceptEx函数指针的代码大致如下:
1 |
|
具体实现就没什么可说的了,因为都是固定的套路,那个GUID是微软给定义好的,直接拿过来用就行了,WSAIoctl()就是通过这个找到AcceptEx的地址的,另外需要注意的是,通过WSAIoctl获取AcceptEx函数指针时,只需要随便传递给WSAIoctl()一个有效的SOCKET即可,该Socket的类型不会影响获取的AcceptEx函数指针。
然后,我们就可以通过其中的指针m_lpfnAcceptEx调用AcceptEx函数了。
AcceptEx函数的定义如下:
1 | BOOL AcceptEx ( |
参数1–sListenSocket, 这个就是那个唯一的用来监听的Socket了,没什么说的;参数2–sAcceptSocket, 用于接受连接的socket,这个就是那个需要我们事先建好的,等有客户端连接进来直接把这个Socket拿给它用的那个,是AcceptEx高性能的关键所在。参数3–lpOutputBuffer,接收缓冲区, 这也是AcceptEx比较有特色的地方,既然AcceptEx不是普通的accpet函数,那么这个缓冲区也不是普通的缓冲区,这个缓冲区包含了三个信息:一是客户端发来的第一组数据,二是server的地址,三是client地址,都是精华啊…但是读取起来就会很麻烦,不过后面有一个更好的解决方案。参数4–dwReceiveDataLength,前面那个参数lpOutputBuffer中用于存放数据的空间大小。如果此参数=0,则Accept时将不会待数据到来,而直接返回,如果此参数不为0,那么一定得等接收到数据了才会返回…… 所以通常当需要Accept接收数据时,就需要将该参数设成为:sizeof(lpOutputBuffer) - 2*(sizeof sockaddr_in +16),也就是说总长度减去两个地址空间的长度就是了,看起来复杂,其实想明白了也没啥……
参数5–dwLocalAddressLength,存放本地址地址信息的空间大小;参数6–dwRemoteAddressLength,存放本远端地址信息的空间大小;参数7–lpdwBytesReceived,out参数,对我们来说没用,不用管;参数8–lpOverlapped,本次重叠I/O所要用到的重叠结构。
这里面的参数倒是没什么,看起来复杂,但是咱们依旧可以一个一个传进去,然后在对应的IO操作完成之后,这些参数Windows内核自然就会帮咱们填满了。
但是非常悲催的是,我们这个是异步操作,我们是在线程启动的地方投递的这个操作, 等我们再次见到这些个变量的时候,就已经是在Worker线程内部了,因为Windows会直接把操作完成的结果传递到Worker线程里,这样咱们在启动的时候投递了那么多的IO请求,这从Worker线程传回来的这些结果,到底是对应着哪个IO请求的呢?。。。。
聪明的你肯定想到了,是的,Windows内核也帮我们想到了:用一个标志来绑定每一个IO操作,这样到了Worker线程内部的时候,收到网络操作完成的通知之后,再通过这个标志来找出这组返回的数据到底对应的是哪个Io操作的。
这里的标志就是如下这样的结构体:
1 | typedef struct _PER_IO_CONTEXT{ |
这个结构体的成员当然是我们随便定义的,里面的成员你可以随意修改(除了OVERLAPPED那个之外……)。
但是AcceptEx不是普通的accept,buffer不是普通的buffer,那么这个结构体当然也不能是普通的结构体了……
在完成端口的世界里,这个结构体有个专属的名字“单IO数据”,是什么意思呢?也就是说每一个重叠I/O都要对应的这么一组参数,至于这个结构体怎么定义无所谓,而且这个结构体也不是必须要定义的,但是没它……还真是不行,我们可以把它理解为线程参数,就好比你使用线程的时候,线程参数也不是必须的,但是不传还真是不行……
除此以外,我们也还会想到,既然每一个I/O操作都有对应的PER_IO_CONTEXT结构体,而在每一个Socket上,我们会投递多个I/O请求的,例如我们就可以在监听Socket上投递多个AcceptEx请求,所以同样的,我们也还需要一个“单句柄数据”来管理这个句柄上所有的I/O请求,这里的“句柄”当然就是指的Socket了,我在代码中是这样定义的:
1 |
|
这也是比较好理解的,也就是说我们需要在一个Socket句柄上,管理在这个Socket上投递的每一个IO请求的_PER_IO_CONTEXT。
当然,同样的,各位对于这些也可以按照自己的想法来随便定义,只要能起到管理每一个IO请求上需要传递的网络参数的目的就好了,关键就是需要跟踪这些参数的状态,在必要的时候释放这些资源,不要造成内存泄漏,因为作为Server总是需要长时间运行的,所以如果有内存泄露的情况那是非常可怕的,一定要杜绝一丝一毫的内存泄漏。
至于具体这两个结构体参数是如何在Worker线程里大发神威的,我们后面再看。
以上就是我们全部的准备工作了,具体的实现各位可以配合我的流程图再看一下示例代码,相信应该会理解得比较快。
完成端口初始化的工作比起其他的模型来讲是要更复杂一些,所以说对于主线程来讲,它总觉得自己付出了很多,总觉得Worker线程是坐享其成,但是Worker自己的苦只有自己明白,Worker线程的工作一点也不比主线程少,相反还要更复杂一些,并且具体的通信工作全部都是Worker线程来完成的,Worker线程反而还觉得主线程是在旁边看热闹,只知道发号施令而已,但是大家终究还是谁也离不开谁,这也就和公司里老板和员工的微妙关系是一样的吧……
【第五步】我们再来看看Worker线程都做了些什么
_Worker线程的工作都是涉及到具体的通信事务问题,主要完成了如下的几个工作,让我们一步一步的来看。
(1) 使用 GetQueuedCompletionStatus() 监控完成端口
首先这个工作所要做的工作大家也能猜到,无非就是几个Worker线程哥几个一起排好队队来监视完成端口的队列中是否有完成的网络操作就好了,代码大体如下:
1 |
|
各位留意到其中的GetQueuedCompletionStatus()函数了吗?这个就是Worker线程里第一件也是最重要的一件事了,这个函数的作用就是我在前面提到的,会让Worker线程进入不占用CPU的睡眠状态,直到完成端口上出现了需要处理的网络操作或者超出了等待的时间限制为止。
一旦完成端口上出现了已完成的I/O请求,那么等待的线程会被立刻唤醒,然后继续执行后续的代码。
至于这个神奇的函数,原型是这样的:
1 |
|
所以,如果这个函数突然返回了,那就说明有需要处理的网络操作了 — 当然,在没有出现错误的情况下。
然后switch()一下,根据需要处理的操作类型,那我们来进行相应的处理。
但是如何知道操作是什么类型的呢?这就需要用到从外部传递进来的loContext参数,也就是我们封装的那个参数结构体,这个参数结构体里面会带有我们一开始投递这个操作的时候设置的操作类型,然后我们根据这个操作再来进行对应的处理。
但是还有问题,这个参数究竟是从哪里传进来的呢?传进来的时候内容都有些什么?
这个问题问得好!
首先,我们要知道两个关键点:
(1) 这个参数,是在你绑定Socket到一个完成端口的时候,用的CreateIoCompletionPort()函数,传入的那个CompletionKey参数,要是忘了的话,就翻到文档的“第三步”看看相关的内容;我们在这里传入的是定义的PER_SOCKET_CONTEXT,也就是说“单句柄数据”,因为我们绑定的是一个Socket,这里自然也就需要传入Socket相关的上下文,你是怎么传过去的,这里收到的就会是什么样子,也就是说这个lpCompletionKey就是我们的PER_SOCKET_CONTEXT,直接把里面的数据拿出来用就可以了。
(2) 另外还有一个很神奇的地方,里面的那个lpOverlapped参数,里面就带有我们的PER_IO_CONTEXT。这个参数是从哪里来的呢?我们去看看前面投递AcceptEx请求的时候,是不是传了一个重叠参数进去?这里就是它了,并且,我们可以使用一个很神奇的宏,把和它存储在一起的其他的变量,全部都读取出来,例如:
PER_IO_CONTEXT* pIoContext = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, m_Overlapped);
这个宏的含义,就是去传入的lpOverlapped变量里,找到和结构体中PER_IO_CONTEXT中m_Overlapped成员相关的数据。
你仔细想想,其实真的很神奇……
但是要做到这种神奇的效果,应该确保我们在结构体PER_IO_CONTEXT定义的时候,把Overlapped变量,定义为结构体中的第一个成员。
只要各位能弄清楚这个GetQueuedCompletionStatus()中各种奇怪的参数,那我们就离成功不远了。
既然我们可以获得PER_IO_CONTEXT结构体,那么我们就自然可以根据其中的m_OpType参数,得知这次收到的这个完成通知,是关于哪个Socket上的哪个I/O操作的,这样就分别进行对应处理就好了。
在我的示例代码里,在有AcceptEx请求完成的时候,我是执行的_DoAccept()函数,在有WSARecv请求完成的时候,执行的是_DoRecv()函数,下面我就分别讲解一下这两个函数的执行流程。
【第六步】当收到Accept通知时 _DoAccept()
在用户收到AcceptEx的完成通知时,需要后续代码并不多,但却是逻辑最为混乱,最容易出错的地方,这也是很多用户为什么宁愿用效率低下的accept()也不愿意去用AcceptEx的原因吧。
和普通的Socket通讯方式一样,在有客户端连入的时候,我们需要做三件事情:
(1) 为这个新连入的连接分配一个Socket;
(2) 在这个Socket上投递第一个异步的发送/接收请求;
(3) 继续监听。
其实都是一些很简单的事情但是由于“单句柄数据”和“单IO数据”的加入,事情就变得比较乱。因为是这样的,让我们一起缕一缕啊,最好是配合代码一起看,否则太抽象了……
(1) 首先,_Worker线程通过GetQueuedCompletionStatus()里会收到一个lpCompletionKey,这个也就是PER_SOCKET_CONTEXT,里面保存了与这个I/O相关的Socket和Overlapped还有客户端发来的第一组数据等等,对吧?但是这里得注意,这个SOCKET的上下文数据,是关于监听Socket的,而不是新连入的这个客户端Socket的,千万别弄混了……
(2) 所以,AcceptEx不是给咱们新连入的这个Socket早就建好了一个Socket吗?所以这里,我们需要再用这个新Socket重新为新客户端建立一个PER_SOCKET_CONTEXT,以及下面一系列的新PER_IO_CONTEXT,千万不要去动传入的这个Listen Socket上的PER_SOCKET_CONTEXT,也不要用传入的这个Overlapped信息,因为这个是属于AcceptEx I/O操作的,也不是属于你投递的那个Recv I/O操作的……,要不你下次继续监听的时候就悲剧了……
(3) 等到新的Socket准备完毕了,我们就赶紧还是用传入的这个Listen Socket上的PER_SOCKET_CONTEXT和PER_IO_CONTEXT去继续投递下一个AcceptEx,循环起来,留在这里太危险了,早晚得被人给改了……
(4) 而我们新的Socket的上下文数据和I/O操作数据都准备好了之后,我们要做两件事情:一件事情是把这个新的Socket和我们唯一的那个完成端口绑定,这个就不用细说了,和前面绑定监听Socket是一样的;然后就是在这个Socket上投递第一个I/O操作请求,在我的示例代码里投递的是WSARecv()。因为后续的WSARecv,就不是在这里投递的了,这里只负责第一个请求。
但是,至于WSARecv请求如何来投递的,我们放到下一节中去讲,这一节,我们还有一个很重要的事情,我得给大家提一下,就是在客户端连入的时候,我们如何来获取客户端的连入地址信息。
这里我们还需要引入另外一个很高端的函数,GetAcceptExSockAddrs(),它和AcceptEx()一样,都是微软提供的扩展函数,所以同样需要通过下面的方式来导入才可以使用……
1 | WSAIoctl( |
和导出AcceptEx一样一样的,同样是需要用其GUID来获取对应的函数指针 m_lpfnGetAcceptExSockAddrs 。
说了这么多,这个函数究竟是干嘛用的呢?它是名副其实的“AcceptEx之友”,为什么这么说呢?因为我前面提起过AcceptEx有个很神奇的功能,就是附带一个神奇的缓冲区,这个缓冲区厉害了,包括了客户端发来的第一组数据、本地的地址信息、客户端的地址信息,三合一啊,你说神奇不神奇?
这个函数从它字面上的意思也基本可以看得出来,就是用来解码这个缓冲区的,是的,它不提供别的任何功能,就是专门用来解析AcceptEx缓冲区内容的。例如如下代码:
1 |
|
解码完毕之后,于是,我们就可以从如下的结构体指针中获得很多有趣的地址信息了:
inet_ntoa(ClientAddr->sin_addr) 是客户端IP地址
ntohs(ClientAddr->sin_port) 是客户端连入的端口
inet_ntoa(LocalAddr ->sin_addr) 是本地IP地址
ntohs(LocalAddr ->sin_port) 是本地通讯的端口
pIoContext->m_wsaBuf.buf 是存储客户端发来第一组数据的缓冲区
自从用了“AcceptEx之友”,一切都清净了….
【第七步】当收到Recv通知时, _DoRecv()
在讲解如何处理Recv请求之前,我们还是先讲一下如何投递WSARecv请求的。
WSARecv大体的代码如下,其实就一行,在代码中我们可以很清楚的看到我们用到了很多新建的PerIoContext的参数,这里再强调一下,注意一定要是自己另外新建的啊,一定不能是Worker线程里传入的那个PerIoContext,因为那个是监听Socket的,别给人弄坏了……:
int nBytesRecv = WSARecv(pIoContext->m_Socket, pIoContext ->p_wbuf, 1, &dwBytes, 0, pIoContext->p_ol, NULL);
这里,我再把WSARev函数的原型再给各位讲一下
1 |
|
其实里面的参数,如果你们熟悉或者看过我以前的重叠I/O的文章,应该都比较熟悉,只需要注意其中的两个参数:
LPWSABUF lpBuffers;
这里是需要我们自己new 一个 WSABUF 的结构体传进去的;
如果你们非要追问 WSABUF 结构体是个什么东东?我就给各位多说两句,就是在ws2def.h中有定义的,定义如下:
1 |
|
而且好心的微软还附赠了注释,真不容易….
看到了吗?如果对于里面的一些奇怪符号你们看不懂的话,也不用管他,只用看到一个ULONG和一个CHAR*就可以了,这不就是一个是缓冲区长度,一个是缓冲区指针么?至于那个什么 FAR……让他见鬼去吧,现在已经是32位和64位时代了……
这里需要注意的,我们的应用程序接到数据到达的通知的时候,其实数据已经被咱们的主机接收下来了,我们直接通过这个WSABUF指针去系统缓冲区拿数据就好了,而不像那些没用重叠I/O的模型,接收到有数据到达的通知的时候还得自己去另外recv,太低端了……这也是为什么重叠I/O比其他的I/O性能要好的原因之一。
LPWSAOVERLAPPED lpOverlapped
这个参数就是我们所谓的重叠结构了,就是这样定义,然后在有Socket连接进来的时候,生成并初始化一下,然后在投递第一个完成请求的时候,作为参数传递进去就可以,
1 | OVERLAPPED* m_pol = new OVERLAPPED; |
在第一个重叠请求完毕之后,我们的这个OVERLAPPED 结构体里,就会被分配有效的系统参数了,并且我们是需要每一个Socket上的每一个I/O操作类型,都要有一个唯一的Overlapped结构去标识。
这样,投递一个WSARecv就讲完了,至于_DoRecv()需要做些什么呢?其实就是做两件事:
(1) 把WSARecv里这个缓冲区里收到的数据显示出来;
(2) 发出下一个WSARecv();
Over……
至此,我们终于深深的喘口气了,完成端口的大部分工作我们也完成了,也非常感谢各位耐心的看我这么枯燥的文字一直看到这里,真是一个不容易的事情!!
【第八步】如何关闭完成端口
休息完毕,我们继续……
各位看官不要高兴得太早,虽然我们已经让我们的完成端口顺利运作起来了,但是在退出的时候如何释放资源咱们也是要知道的,否则岂不是功亏一篑……
从前面的章节中,我们已经了解到,Worker线程一旦进入了GetQueuedCompletionStatus()的阶段,就会进入睡眠状态,INFINITE的等待完成端口中,如果完成端口上一直都没有已经完成的I/O请求,那么这些线程将无法被唤醒,这也意味着线程没法正常退出。
熟悉或者不熟悉多线程编程的朋友,都应该知道,如果在线程睡眠的时候,简单粗暴的就把线程关闭掉的话,那是会一个很可怕的事情,因为很多线程体内很多资源都来不及释放掉,无论是这些资源最后是否会被操作系统回收,我们作为一个C++程序员来讲,都不应该允许这样的事情出现。
所以我们必须得有一个很优雅的,让线程自己退出的办法。
这时会用到我们这次见到的与完成端口有关的最后一个API,叫 PostQueuedCompletionStatus(),从名字上也能看得出来,这个是和 GetQueuedCompletionStatus() 函数相对的,这个函数的用途就是可以让我们手动的添加一个完成端口I/O操作,这样处于睡眠等待的状态的线程就会有一个被唤醒,如果为我们每一个Worker线程都调用一次PostQueuedCompletionStatus()的话,那么所有的线程也就会因此而被唤醒了。
PostQueuedCompletionStatus()函数的原型是这样定义的:
1 | BOOL WINAPI PostQueuedCompletionStatus( |
我们可以看到,这个函数的参数几乎和GetQueuedCompletionStatus()的一模一样,都是需要把我们建立的完成端口传进去,然后后面的三个参数是 传输字节数、结构体参数、重叠结构的指针.
注意,这里也有一个很神奇的事情,正常情况下,GetQueuedCompletionStatus()获取回来的参数本来是应该是系统帮我们填充的,或者是在绑定完成端口时就有的,但是我们这里却可以直接使用PostQueuedCompletionStatus()直接将后面三个参数传递给GetQueuedCompletionStatus(),这样就非常方便了。
例如,我们为了能够实现通知线程退出的效果,可以自己定义一些约定,比如把这后面三个参数设置一个特殊的值,然后Worker线程接收到完成通知之后,通过判断这3个参数中是否出现了特殊的值,来决定是否是应该退出线程了。
例如我们在调用的时候,就可以这样:
1 | for (int i = 0; i < m_nThreads; i++) |
为每一个线程都发送一个完成端口数据包,有几个线程就发送几遍,把其中的dwCompletionKey参数设置为NULL,这样每一个Worker线程在接收到这个完成通知的时候,再自己判断一下这个参数是否被设置成了NULL,因为正常情况下,这个参数总是会有一个非NULL的指针传入进来的,如果Worker发现这个参数被设置成了NULL,那么Worker线程就会知道,这是应用程序再向Worker线程发送的退出指令,这样Worker线程在内部就可以自己很“优雅”的退出了……
学会了吗?
但是这里有一个很明显的问题,聪明的朋友一定想到了,而且只有想到了这个问题的人,才算是真正看明白了这个方法。
我们只是发送了m_nThreads次,我们如何能确保每一个Worker线程正好就收到一个,然后所有的线程都正好退出呢?是的,我们没有办法保证,所以很有可能一个Worker线程处理完一个完成请求之后,发生了某些事情,结果又再次去循环接收下一个完成请求了,这样就会造成有的Worker线程没有办法接收到我们发出的退出通知。
所以,我们在退出的时候,一定要确保Worker线程只调用一次GetQueuedCompletionStatus(),这就需要我们自己想办法了,各位请参考我在Worker线程中实现的代码,我搭配了一个退出的Event,在退出的时候SetEvent一下,来确保Worker线程每次就只会调用一轮 GetQueuedCompletionStatus() ,这样就应该比较安全了。
另外,在Vista/Win7系统中,我们还有一个更简单的方式,我们可以直接CloseHandle关掉完成端口的句柄,这样所有在GetQueuedCompletionStatus()的线程都会被唤醒,并且返回FALSE,这时调用GetLastError()获取错误码时,会返回ERROR_INVALID_HANDLE,这样每一个Worker线程就可以通过这种方式轻松简单的知道自己该退出了。当然,如果我们不能保证我们的应用程序只在Vista/Win7中,那还是老老实实的PostQueuedCompletionStatus()吧。
最后,在系统释放资源的最后阶段,切记,因为完成端口同样也是一个Handle,所以也得用CloseHandle将这个句柄关闭,当然还要记得用closesocket关闭一系列的socket,还有别的各种指针什么的,这都是作为一个合格的C++程序员的基本功,在这里就不多说了,如果还是有不太清楚的朋友,请参考我的示例代码中的 StopListen() 和DeInitialize() 函数。
六. 完成端口使用中的注意事项
终于到了文章的结尾了,不知道各位朋友是基本学会了完成端口的使用了呢,还是被完成端口以及我这么多口水的文章折磨得不行了……
最后再补充一些前面没有提到了,实际应用中的一些注意事项吧。
- Socket的通信缓冲区设置成多大合适?
在x86的体系中,内存页面是以4KB为单位来锁定的,也就是说,就算是你投递WSARecv()的时候只用了1KB大小的缓冲区,系统还是得给你分4KB的内存。为了避免这种浪费,最好是把发送和接收数据的缓冲区直接设置成4KB的倍数。
- 关于完成端口通知的次序问题
这个不用想也能知道,调用GetQueuedCompletionStatus() 获取I/O完成端口请求的时候,肯定是用先入先出的方式来进行的。
但是,咱们大家可能都想不到的是,唤醒那些调用了GetQueuedCompletionStatus()的线程是以后入先出的方式来进行的。
比如有4个线程在等待,如果出现了一个已经完成的I/O项,那么是最后一个调用GetQueuedCompletionStatus()的线程会被唤醒。平常这个次序倒是不重要,但是在对数据包顺序有要求的时候,比如传送大块数据的时候,是需要注意下这个先后次序的。
– 微软之所以这么做,那当然是有道理的,这样如果反复只有一个I/O操作而不是多个操作完成的话,内核就只需要唤醒同一个线程就可以了,而不需要轮着唤醒多个线程,节约了资源,而且可以把其他长时间睡眠的线程换出内存,提到资源利用率。
- 如果各位想要传输文件…
如果各位需要使用完成端口来传送文件的话,这里有个非常需要注意的地方。因为发送文件的做法,按照正常人的思路来讲,都会是先打开一个文件,然后不断的循环调用ReadFile()读取一块之后,然后再调用WSASend ()去发发送。
但是我们知道,ReadFile()的时候,是需要操作系统通过磁盘的驱动程序,到实际的物理硬盘上去读取文件的,这就会使得操作系统从用户态转换到内核态去调用驱动程序,然后再把读取的结果返回至用户态;同样的道理,WSARecv()也会涉及到从用户态到内核态切换的问题 — 这样就使得我们不得不频繁的在用户态到内核态之间转换,效率低下……
而一个非常好的解决方案是使用微软提供的扩展函数TransmitFile()来传输文件,因为只需要传递给TransmitFile()一个文件的句柄和需要传输的字节数,程序就会整个切换至内核态,无论是读取数据还是发送文件,都是直接在内核态中执行的,直到文件传输完毕才会返回至用户态给主进程发送通知。这样效率就高多了。
- 关于重叠结构数据释放的问题
我们既然使用的是异步通讯的方式,就得要习惯一点,就是我们投递出去的完成请求,不知道什么时候我们才能收到操作完成的通知,而在这段等待通知的时间,我们就得要千万注意得保证我们投递请求的时候所使用的变量在此期间都得是有效的。
例如我们发送WSARecv请求时候所使用的Overlapped变量,因为在操作完成的时候,这个结构里面会保存很多很重要的数据,对于设备驱动程序来讲,指示保存着我们这个Overlapped变量的指针,而在操作完成之后,驱动程序会将Buffer的指针、已经传输的字节数、错误码等等信息都写入到我们传递给它的那个Overlapped指针中去。如果我们已经不小心把Overlapped释放了,或者是又交给别的操作使用了的话,谁知道驱动程序会把这些东西写到哪里去呢?岂不是很崩溃……
暂时我想到的问题就是这么多吧,如果各位真的是要正儿八经写一个承受很大访问压力的Server的话,你慢慢就会发现,只用我附带的这个示例代码是不够的,还得需要在很多细节之处进行改进,例如用更好的数据结构来管理上下文数据,并且需要非常完善的异常处理机制等等,总之,非常期待大家的批评和指正。
谢谢大家看到这里!!!
※WinSock IOCP 模型总结(附一个带缓存池的IOCP类)[20]
※前言
本文配套代码:https://github.com/TTGuoying/IOCPServer
由于篇幅原因,本文假设你已经熟悉了利用Socket进行TCP/IP编程的基本原理,并且也熟练的掌握了多线程编程技术,太基本的概念我这里就略过不提了,网上的资料应该遍地都是。
IOCP全称IOCP全称I/O Completion Port,中文译为I/O完成端口。IOCP是一个异步I/O的Windows I/O模型,它可以自动处理I/O操作,并在I/O操作完成后将完成通知发送给用户。本文主要介绍基于IOCP的网络I/O操作(即socket的Accept、Send、Recv和Close等)。Windows提供了6种网络通信模型,分别是:
- 阻塞模型:accept、recv和send操作会阻塞线程,直到操作完成,极其低效。
- 选择(select)模型:轮询方式探测socket上是否有收发的操作,再调用accept、recv和send操作,核心是select()函数,比阻塞模型高效一点,缺点是一次只能探测64个socket,需要手动调用recv和send进行收发数据。
- 异步选择(WSAAsyncSelect)模型:利用Windows窗口消息机制响应socket操作,即当socket上有Accept、Send、Recv和Close操作发生时发送一条自定义消息给指定窗口,在窗口中响应socket操作,需要手动调用recv和send进行收发数据。与select模型相比,不需要轮询方式探测socket,socket上有操作发生即发送通知给窗口窗口,缺点是需要一个窗口对象处理socket的消息,需要手动调用recv和send进行收发数据。
- 事件选择(WSAEventSelect)模型:原理基本同WSAAsyncSelect模型,但是不需要窗口,利用事件(Event)机制来获取socket上发生的I/O操作。缺点是一次只能等待64个事件,需要手动调用recv和send进行收发数据。
- 重叠 I/O(Overlapped I/O)模型:利用重叠数据结构(WSAOVERLAPPED),一次投递一个或多个Winsock I/O请求,等这些请求完成后,应用程序会收到通知,用户可以直接使用 I/O操作返回的数据。简单的说:投递一个WSASend请求和接受数据的缓冲区,系统在接收完成后在通知用户,用户可以直接使用收到的数据,WSASend操作同理。有两种方式来管理重叠IO请求的完成情况(就是说接到重叠操作完成的通知):
- 事件对象通知(event object notification)
- 完成例程(completion routines) ,注意,这里并不是完成端口
- 优点是不用管收发过程,直接提供(发送时)/使用(接收时)数据。缺点是实现略复杂。
- IOCP(I/O Completion Port)模型:本文要介绍的模型,见下文。
以上I/O模型由1-6理解难度依次提高,性能也相应地依次提高,我个人觉得重叠 I/O(Overlapped I/O)模型和IOCP(I/O Completion Port)模型并不是实现难度大,而是理解其运行机制的难度,5和6的使用比前面几种所需代码更少,更简单。下面开始正式介绍IOCP(I/O Completion Port)模型。
※相关概念
1、异步通信
我们知道外部设备I/O(比如磁盘读写,网络通信等)速度和CPU速度比起来是很慢的,如果我们进行外部I/O操作时在线程中等待I/O操作完成的话,此线程就会被阻塞住,相当于强迫CPU适应I/O设备的速度,这样会造成极大的CPU资源浪费。我们没必要在线程中等待I/O操作完成再执行后续的代码,而是将I/O操作请求交给设备驱动去处理,我们线程可以继续做其他事情,然后等待I/O操作完成的通知,大体的流程如下图所示:
我们可以从图中看到一个很明显的并行操作的过程,这就是异步调用,而“同步”的通信方式是再进行网络操作的时候主线程就挂起等待直到网络操作完成之后才可以执行后续的代码。同步方式流程如下图:
“异步”方式无疑比“阻塞+多线程”的方式效率要高得多。在Windows中实现异步的机制有好几种,主要区别是图一中的最后一步“通知主线程”的方式。实现操作系统调用驱动程序去收发数据的操作都是一样的,关键是“如何通知主线程取数据”。有兴趣的朋友可以搜索关键字“设备内核对象”、“事件内核对象”、APC(synchronous Procedure Call,异步过程调用)和IOCP(完成端口)。
2、重叠结构(OVERLAPPED)
在Windows中要实现异步通信,必须要用到重叠结构(OVERLAPPED),Windows中所有的异步通信都是基于它的。至于为什么叫Overlapped?Jeffrey Richter的解释是因为“执行I/O请求的时间与线程执行其他任务的时间是重叠(overlapped)的”,从这个名字我们也可能看得出来重叠结构发明的初衷了,对于重叠结构的内部细节我这里就不过多的解释了,就把它当成和其他内核对象一样,不需要深究其实现机制,只要会使用就可以了,想要了解更多重叠结构内部的朋友,请去翻阅Jeffrey Richter的《Windows via C/C++》 5th 的292页。
3、完成端口
“完成端口”这个名词中的“端口”和我们网络通信中的“端口”(0-65535)是不同的,个人感觉应该叫“完成队列”更直观一点。之所以叫“完成”端口,是因为系统在IO操作“完成”后再通知我们,也就是说当系统通知我们时,IO操作已经完成,比如说进行网络操作,系统通知我们时,并非时有数据从网络到来,而是数据已经接受完毕了,或者是socket接入已经完成等,我们只需处理后面的事情即可。
所谓的完成端口,其实就是一个“内核对象”,我们不需要深究其实现原理,只需使用相关的API把完成端口框架搭建起来,投递IO请求,然后就等待IO完成的通知。
※使用完成端口的基本流程
总的来说,使用完成端口只要遵循如下几个步骤:
- 调用CreateIoCompletionPort() 函数创建一个完成端口。
- 建立和处理器的核数相等的工作线程(WorkerThread),这些线程不断地通过GetQueuedCompletionStatus() 函数扫描完成端口中是否有IO操作完成,如果有的话,将已经完成了的IO操作取出处理,处理完成后,再投递一个IO请求即可(下文有WorkerThread的流程图)。
- 初始化监听socket,调用bind(),listen()进行绑定监听。
- 调用CreateIoCompletionPort() 绑定listen socket 到 完成端口,并投递一个或多个AcceptEx请求。此处的AcceptEx是WinSock2 的扩展函数,作用是投递一个accept请求,当有socket接入是可以再2中的线程中处理。
以上即为完成端口的初始化和监听socket的初始化。下面介绍WorkerThread的工作流程:
- 不断地通过GetQueuedCompletionStatus() 函数扫描完成端口中是否有IO操作完成,如果有的话,将已经完成了的IO操作取出处理。
- 判断IO操作的类型:
- 1、如果为accept操作,调用CreateIoCompletionPort() 绑定新接入的socket 到 完成端口,向新接入的socket 投递一个WSARecv请求。
- 2、如果为WSARecv操作,处理接收到的数据,向这个socket 再投递一个WSARecv请求。
流程图如下:
※完成端口的实现(配合代码阅读更佳)
※1、创建一个完成端口
HANDLE completionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
CreateIoCompletionPort的参数如下:
1 | //功能:创建完成端口和关联完成端口 |
CreateIoCompletionPort函数有两个功能:创建一个完成端口将一个句柄关联到完成端口
我们创建时给的参数是(INVALID_HANDLE_VALUE, NULL, 0, 0)就是创建完成端口,下面会介绍关联完成端口。
※2、建立Worker线程
1 | SYSTEM_INFO si; |
我们最好是建立CPU核心数量*2那么多的线程,这样更可以充分利用CPU资源,因为完成端口的调度是非常智能的,比如我们的Worker线程有的时候可能会有Sleep()或者WaitForSingleObject()之类的情况,这样同一个CPU核心上的另一个线程就可以代替这个Sleep的线程执行了;因为完成端口的目标是要使得CPU满负荷的工作。
WorkerThreadProc是Worker线程的线程函数,线程函数的具体内容我们后面再讲。
※3、创建监听socket
1 | BOOL IOCPBase::InitializeListenSocket() |
用 CreateIoCompletionPort()函数把这个监听Socket和完成端口绑定,bind(),listen(),然后提取扩展函数AcceptEx和GetAcceptSockAddrs的指针,因为AcceptEx 实际上是存在于Winsock2结构体系之外的(因为是微软另外提供的),所以如果我们直接调用AcceptEx的话,首先我们的代码就只能在微软的平台上用了,没有办法在其他平台上调用到该平台提供的AcceptEx的版本(如果有的话), 而且我们每次调用AcceptEx时,Service Provider都得要通过WSAIoctl()获取一次该函数指针,效率太低了,所以我们自己获取函数指针。然后投递AcceptEx请求。
投递AcceptEx请求的代码
1 | BOOL IOCPBase::PostAccept(SocketContext * sockContext, IOContext * ioContext) |
AcceptEx 函数说明:参数1–sListenSocket, 这个就是那个唯一的用来监听的Socket了,没什么说的;参数2–sAcceptSocket, 用于接受连接的socket,这个就是那个需要我们事先建好的,等有客户端连接进来直接把这个Socket拿给它用的那个,是AcceptEx高性能的关键所在。参数3–lpOutputBuffer,接收缓冲区,这也是AcceptEx比较有特色的地方,既然AcceptEx不是普通的accpet函数,那么这个缓冲区也不是普通的缓冲区,这个缓冲区包含了三个信息:一是客户端发来的第一组数据,二是server的地址,三是client地址。参数4–dwReceiveDataLength,前面那个参数lpOutputBuffer中用于存放数据的空间大小。如果此参数=0,则Accept时将不会待数据到来,而直接返回,如果此参数不为0,那么一定得等接收到数据了才会返回,这里设为0直接返回,防止拒绝服务攻击参数5–dwLocalAddressLength,存放本地址地址信息的空间大小;参数6–dwRemoteAddressLength,存放本远端地址信息的空间大小;参数7–lpdwBytesReceived,out参数,对我们来说没用,不用管;参数8–lpOverlapped,本次重叠I/O所要用到的重叠结构。
因为每投递一次网络IO请求都要求提供一个WSABuf和WSAOVERLAPPED的参数,所以我们自定义一个IOContext类,每次投递附带这个类的变量,但要注意这个变量的生命周期,防止内存泄漏。
1 | class IOContext |
对于每一个socket也定义了一个SocketContext的类和一个IOContextPool的缓冲池类的具体请查看代码。
1 | 1 class SocketContext |
1 | 1 // 空闲的IOContext管理类(IOContext池) |
※4、Worker线程
这个工作线程所要做的工作就是几个Worker线程哥几个一起排好队队来监视完成端口的队列中是否有完成的网络操作就好了,代码大体如下:
1 | DWORD IOCPBase::WorkerThreadProc(LPVOID lpParam) |
其中的GetQueuedCompletionStatus()就是Worker线程里第一件也是最重要的一件事了,会让Worker线程进入不占用CPU的睡眠状态,直到完成端口上出现了需要处理的网络操作或者超出了等待的时间限制为止。
一旦完成端口上出现了已完成的I/O请求,那么等待的线程会被立刻唤醒,然后继续执行后续的代码。
至于这个神奇的函数,原型是这样的:
1 | BOOL WINAPI GetQueuedCompletionStatus( |
如果这个函数突然返回了,那就说明有需要处理的网络操作了 — 当然,在没有出现错误的情况下。 然后switch()一下,根据需要处理的操作类型,那我们来进行相应的处理。
那我们如何直到需要处理的操作类型呢?这个就要用到我们定义的IOContext类,里面有一个WSAOVERLAPPED的变量和操作类型(参见第3步)。那有如何吧IOContext变量传进来呢?同样参见第三步我们投递AcceptEx请求时传入了一个&ioContext->overLapped参数。我们可以使用PER_IO_CONTEXT这个宏来通过ioContext->overLapped取得ioContext的地址,如此我们便取得操作类型和ioContext中的WSAbuf。数据就存放在WSABuf中。
另外,我们注意到关联socket到完成端口时,我们给CreateIoCompletionPort()函数的第三个参数ULONG_PTR CompletionKey参数传递了listenSockContext变量,我们可以在GetQueuedCompletionStatus的第三个参数取得这个传进来的变量。如此我们就通过完成端口穿进去了两个变量,理解这两个变量的传递时理解完成端口模式的关键,我之前就时卡着这里。
WorkerThreadProc线程中还有一些错误处理函数,自行查看。
※5、收到accept通知时调用DoAccept()
在用户收到AcceptEx的完成通知时,需要后续代码并不多,我们把代码放到DoAccept()中:需要做三件事情:
为新接入的socket分配资源。向新接入的socket投递一个WSARecv请求向监听socket投递继续Accept请求
1 | 1 BOOL IOCPBase::DoAccpet(SocketContext * sockContext, IOContext * ioContext) |
1 | 1 BOOL IOCPBase::PostAccept(SocketContext * sockContext, IOContext * ioContext) |
1 | 1 BOOL IOCPBase::PostRecv(SocketContext * sockContext, IOContext *ioContext) |
此处要注意理清第4步中说的两个变量的传入。
DoAccept中还调用了OnConnectionEstablished()函数,这是一个虚函数,派生类重载这个函数即可处理连接接入的通知。具体看代码里的例程。
※6、收到recv通知时调用DoRecv()
在用户收到recv的完成通知时,需要后续代码并不多,我们把代码放到DoRecv()中:需要做两件事情:
处理WSABuf中的数据向此socket重新投递一个WSARecv请求
1 |
|
此处要注意理清第4步中说的两个变量的传入。
※7、关闭完成端口
Worker线程一旦进入了GetQueuedCompletionStatus()的阶段,就会进入睡眠状态,INFINITE的等待完成端口中,如果完成端口上一直都没有已经完成的I/O请求,那么这些线程将无法被唤醒,这也意味着线程没法正常退出。
熟悉或者不熟悉多线程编程的朋友,都应该知道,如果在线程睡眠的时候,简单粗暴的就把线程关闭掉的话,那是会一个很可怕的事情,因为很多线程体内很多资源都来不及释放掉,无论是这些资源最后是否会被操作系统回收,我们作为一个C++程序员来讲,都不应该允许这样的事情出现。
所以我们必须得有一个很优雅的,让线程自己退出的办法。
这时会用到我们这次见到的与完成端口有关的最后一个API,叫 PostQueuedCompletionStatus(),从名字上也能看得出来,这个是和 GetQueuedCompletionStatus() 函数相对的,这个函数的用途就是可以让我们手动的添加一个完成端口I/O操作,这样处于睡眠等待的状态的线程就会有一个被唤醒,如果为我们每一个Worker线程都调用一次PostQueuedCompletionStatus()的话,那么所有的线程也就会因此而被唤醒了。
PostQueuedCompletionStatus()函数的原型是这样定义的:
1 | BOOL WINAPI PostQueuedCompletionStatus( |
我们可以看到,这个函数的参数几乎和GetQueuedCompletionStatus()的一模一样,都是需要把我们建立的完成端口传进去,然后后面的三个参数是 传输字节数、结构体参数、重叠结构的指针.
注意,这里也有一个很神奇的事情,正常情况下,GetQueuedCompletionStatus()获取回来的参数本来是应该是系统帮我们填充的,或者是在绑定完成端口时就有的,但是我们这里却可以直接使用PostQueuedCompletionStatus()直接将后面三个参数传递给GetQueuedCompletionStatus(),这样就非常方便了。
例如,我们为了能够实现通知线程退出的效果,可以自己定义一些约定,比如把这后面三个参数设置一个特殊的值,然后Worker线程接收到完成通知之后,通过判断这3个参数中是否出现了特殊的值,来决定是否是应该退出线程了。
例如我们在调用的时候,就可以这样:
1 | for (int i = 0; i < workerThreadNum; i++) |
谢谢大家看到这里!!!(完)
※MFC高性能网络编程:完成端口[21][22]
※前言
今天讨论的完成端口是套接字通讯一般来讲是高性能服务器编程才会用到的一种方式,是一种一般面向大量客户端和大量数据的通讯方式,比如说自动售货机或者是ofo这种东西,而且单片机编程需要网络编程的话一般都是套接字通讯,其实就连http通讯都是套接字通讯的一种封装,其实说大一点一旦涉及到网络编程说穿了就是socket编程。
说到socket通讯的话,目前世界上最好的模型就是微软的完成端口,没有之一。无论从响应速度还是资源占用,完成端口都是最好的。可以说一旦在windows服务器上搞网络编程没有比完成端口更好的选择。我自己实际测试的结果是即便达到数十万的长连接情况上完成端口模型下的程序几乎不会占用超过5%的CPU资源,相当NB。但是完成端口也不是没有缺点,最大的缺点可以说也是唯一的缺点就是开发难度很大,稍微不注意就会出错。我之前刚刚开始研制这个模型的时候上CSDN发了一个帖子请教各路大神,统一得到一种回复:勿在浮沙筑高台。全部是劝没有这个水平就千万不要用。但是我觉得模型本身只要理解清楚了也不会有什么问题,只要你有一定的基础写一个可以完美运行的服务端程序还是没有太大的问题。废话结束我们直接开始较为详细的讲解一下这个东西是怎么回事。机制
完成端口的其实全称是IO完成端口,英文缩写是IOCP。本质上来讲其实是等待IO空闲之后便执行下一个IO操作,其实从原来上来讲任何IO操作的程序都可以使用完成端口,比如说串口通讯等等。整个机制简单描述一下流程就是使用一个微软封装的十分NB的工具实例化一个IOCP类,同时搞很多个工作线程出来,每一个工作线程相互独立,使用两个结构体指针传递和同步数据(这两个结构体十分重要之后我们详细会讲),这些线程会被休眠起来,一但我们向IOCP类投递一个请求之后,一旦这个请求被执行那么,马上会有一个工作线程被唤醒去执行相对于的操作,然后我们只需在操作结束之后再一次投递这个请求就行了。由于IOCP这种机制这就是速度快的原因性能高的原因,它既不阻塞线程(只是休眠)同时是一个并行的操作,有很多个工作线程都在休眠一旦请求来了就执行所以它可以毫无压力的操作十分大量的连接。
以上就是对IOCP一个十分十分十分基础的理解,但是对于开发而言绝壁够了,只要你不去挖这个模型是否深处的东西仅仅用于开发,那么最好保持这个理解。基础理解就这样,同时我们还需要有一些基础知识才能进入实际流程的分析和拆解:
基础MFC编程
socket编程
基础多线程编程知识
较为扎实的C++基础
※流程
1、调用 CreateIoCompletionPort() 函数创建一个完成端口m_hIOCompletionPort,而且在一般情况下,我们需要且只需要建立这一个完成端口,把它的句柄保存好。这个函数的参数一看就很简单-1,0,0,0四个值就搞定了。
HANDLE m_hIOCompletionPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0 );
2、建立工作线程,通过得到系统CPU数量然后建立这个数值两倍的线程。为什么是两倍我也不知道,大概是网上很多高手都推荐的一个数量,还有是2倍加2的,这个最好就按照标准来两倍就可以了。
1 | SYSTEM_INFO si; |
4、接下来就是一个很关键的一步了我们要让完成端口来帮助我们监听,那么这个socket必须和IOCP有一定的关系,一旦socket有连接请求过来了之后需要IOCP响应那么第一步就是把socket与IOCP绑定起来,这里有一个奇怪的地方,它还是使用的CreateIoCompletionPort() 函数与创建完成端口句柄是通一个函数,不要弄错了,他们仅仅是参数不同。这里就是直接调用就可以了,我将这个函数参数说说就可以了。返回NULL说明操作失败。
1 | HANDLE WINAPI CreateIoCompletionPort( |
5、投递接收连接的请求,一旦有客户端连接之后工作线程会自己响应,这里第二个结构体就出来了。我们一步一步的来,首先说说两个结构体是什么东西。第一是套接字结构体,其实它仅仅是将套接字传入工作线程,也就是投递进去。申明也很简单仅仅只有一个SOCKET。
1 | typedef struct _PER_SOCKET_CONTEXT |
第二个结构体就较为复杂一点,里面记录的是一些自定义数据和一个重叠量,第一个参数是必须要有的这个就是重叠量,它算是一个身份辨识,表面我是重叠结构体这个结构体指针的量会全部传进去。SOCKET是对应结束客户端连接时新建的套接字,WSABUH也是必须要有的东西,这个记录了这一次响应对应得到的数据。下面的char和int其实就是WSABUH里面的buffer和len,不同的指针指向同一个东西,这是可以自己自定义的,我这样写的原因是取值的时候方便一点,下面两个是我定义的int类型一个表示这一次响应对应的是接收消息还是接受连接。另一个是记录是哪一类套接字,比如说我有三种不同类型的客户端每一种对应操作不同我到时候就可以用这个来区分。
1 | typedef struct _PER_IO_CONTEXT |
之后我们就开看怎么投递接受连接情况进入IOCP,首先就是AcceptEX()函数,这个东西性能比好,不阻塞线程但是用起来很麻烦,我也当时也认为很伤,很多开发者都不愿意使用这个高性能函数就是因为它很麻烦,但是一旦理解机制之后都很好办了。首先是使用WSAIoctl寻找AcceptEX()函数指针,为了性能才这样做的,然后构造一个PPER_IO_CONTEXT结构体指针这里要初始化并申请全局空间,进入IOCP之后仅仅会改变WSABUF值其他的我们现在怎么给之后在工作线程里面拿到的时候它就是什么值。GuidAcceptEx是一个GUID,m_lpfnAcceptEx是一个LPFN_ACCEPTEX,因为我使用的是类成员变量方便在线程里面二次投递。还有一点就是AcceptEX()函数性能高的原因在于在接收之前就已经准备好一个SOCKET,只要响应的之后把客户端对应连接的套接字值给他就可以了,等于说内存操作是提前完成的。
1 | GuidAcceptEx = WSAID_ACCEPTEX; |
6、接下来就是最激动人心的时候了,我们来解析工作线程到底干了什么我先把代码给贴出了,这个地方由于我是直接把我项目的代码复制过来了,同时也不能让你们看出来这个项目在干嘛我就会删除几段,但是不影响逻辑。
1 | DWORD thisBytesTransferred; |
我们来详细讲一下这是在干嘛,首先是GetQueuedCompletionStatus()函数,这个就是让线程进入休眠的函数,一旦整个模型有动静它就会返回,我们可以看见PPER_SOCKET_CONTEXT和PPER_IO_CONTEXT结构体会被他返回回来,没错这就是之前我们传入的,整个流程仅仅是将接收的消息放到WSABUF里面,把接受的连接给到SOCKET里面,而PPER_SOCKET_CONTEXT就没有变化。具体的解析我们没有必要讲,需要将的是如果这一次响应是一个接受连接,那么我们会申请两个新结构体和对应的全局空间,然后就是老套路绑定到完成端口,然后投递接受消息请求给完成端口然后等待响应。注意我们接受一个连接之后对应必须重新投递一次接受请求给完成端口,这个时候我们不用申请新的空间,我们只需要给老结构体一个SOCKET给进去,毕竟之前那个已经有一个对应的连接了。如果是接收消息的话就是下面一个if的话就没有什么好说的,接收到之后二次投递请求就可以了,空间继续使用它的空间就好。其实我写的代码完全可以忽略大部分,只看看关键部分就可以了。无论GetQueuedCompletionStatus失败与否只要响应没有数据那要不然是客户端发了一个空,但是这个基本不可能,那只能是客户端对应的socket退出了或者关闭了,无论是程序崩溃还是怎么的,一旦客户端退出这边就会进入第一个if这个时候一定要释放空间。服务端程序一旦出现内存溢出是很要命的事情,我测试了整整两天确定没有溢出才敢往我们公司的git上提交。
※注意
整个过程有几点需要注意一下第一是线程操作,仅仅是完成端口内部几个工作线程的通讯的话不需要做什么线程保护,但是一旦与其他线程有通讯一定不要忘记线程保护,而且我建议千万不要用互斥锁,因为互斥锁不会阻塞线程一旦资源被锁住函数会立即返回向下执行,这有可能会引起完成端口失效。我项目使用的是临界区来完成线程保护,经过测试没有什么大的问题。第二是内存释放的问题,每一次申请了内存之后一定要考虑这个申请会执行几次,什么时候释放,如上文所说一旦发生内存泄露那么往后debug是很困难的,同时这个服务端程序根本不能稳定使用。第三是问题规避,由于我们最终的程序是长时间运行的7×24这种,如果某一个函数返回错误,那么这个错误不能影响这个模型的运行,那么对应每一次都需要判断是否返回错误这个错误怎么化解才能不影响整个IOCP。第四,退出的时候不能直接停止线程释放内存因为线程是休眠的,常规方法是不能退出工作线程的,在退出的时候一定要优雅的来,具体方法大家去网上找找就知道了,这里不赘述。
※总结
完成端口我仅仅开发两个项目的时候使用过,虽然会花费大量的时间,但是结果绝对是值得的,性能强,稳定性好并发连接根本就不虚。同时上千个连接来了IOCP都可以气定神闲的完成操作而且速度非常快,同时服务器也没有太大的压力。
还有就是这篇博文写的很干,原理没有讲到太多基本直接上代码了,希望大家不要直接就抄去用,首先需要把机制给彻底弄清楚,不然解析什么的都可能会出错。最后毕竟我还是比较菜的,写代码的风格也有一点野路子在里面,我写的博文如果有什么大问题希望各位高手指正,最好不要误人子弟。(我相信应该有小问题,没有大问题,哈哈哈)
※补全[22:1]
※问题描述
我们都知道socket编程之中对监听的socket有一个函数是accept,这个函数的作用是阻塞方式,当有链接来的时候便会返回。它的性能其实不高,原因在于当我们accept的时候对于客户端的连接回去创建一个新的socket,这个时候便会去申请内容完成构造。我们使用AcceptEX方式是预先创建完成一个socket当连接来的时候便将这个socket给客户端,所以他的性能很好。同时AcceptEX不会阻塞而是直接放回,所以需要将接受到的socket投递给完成端口好让完成端口进行响应。之前我写的创建方式是这样的代码:
1 | GuidAcceptEx = WSAID_ACCEPTEX; |
大家可以看到m_lpfnAcceptEx是AcceptEX的函数指针,我们先来分析一下AcceptEX的参数到底是一些什么东西在将问题讲出来:
1 | BOOL AcceptEx( |
第一个参数是监听的socket,第二是接受的socket这两个没有什么好说的,第三个参数连接数据的缓冲区包含服务器的本地地址,客户机的远程地址,以及在新建连接上发送的第一个数据块三种数据,第四个参数是接收数据的长度,也就是缓冲区的长度,这个就是一个问题了,接收数据长度如果设置的值大于零的话那么完成端口是不会响应连接的。意思是当一个连接上来之后没有发送数据那么不仅仅IOCP没有能力识别到这个事件同时这个连接会将接受给占用,直到它发送第一条数据,如果将这个值设置为0则IOCP会立刻响应新连接,这就是问题。后面几个参数上一篇文件有我也就不多说了。
简单来讲问题就是:当客户端建立了连接但是没有向服务端发送任何数据的时候会将我们准备的accept给占用了,这时新的连接来了之后就没有东西来接它了。虽然在队里里面等待这但是没有入口IOCP也不会响应。
※解决方案
经过上面的分析解决方案有两个,第一是改变AcceptEX的缓冲区大小使完成端口立刻响应连接并进入处理。问题是我现在写好的程序解析与管理部分都已经经过多重测试,如果这样修改的话后续的结构基本上都会发送变动。第二是找一个办法能够监测accept的连接时长如果当时间超过一个具体的值之后把它干掉重新投递就可以解决了。我使用的就是这种方式,简单来说就是创建一个新的线程进行监管超时之后处理一下就行了。这里便需要使用一个NB的函数getsockopt:
1 |
|
这些参数里面最重要的就是套接字的选项,这个参数指明你需要获取socket的什么东西。在众多的选项之中有一个就是SO_CONNECT_TIME,他表示的是socket的连接时间,如果没有连接的话optval将等于-1,如果已经连接的话这个值便是连接的时间单位为秒。所以我的处理方案便是:
1 | void IO_CompletionPort::CheckAccept() { |
这里的临界区是为了保证多线程情况下函数操作的线程安全,但是理论上来讲socket就是线程安全,我这样写无非就是为了以防万一。当closesocket执行的时候完成端口可以捕捉到对应的响应然后处理一下重新投递就可以了。其中的Accept对象就是创建的待接收socket,这个东西我是放在全局更新的,虽然这样做基本上毁了AcceptEX的高性能,但是无所谓了。
※RIO
Code that explores the Windows Registered I/O Networking Extensions
※IOCPServer
A IOCP Server Class on Windows!
本类配套文章:http://www.cnblogs.com/tanguoying/p/8439701.html
一个基于完成端口网络服务类,自带缓存池和心跳包监测!
- 这个类IOCP是本代码的核心类,用于说明WinSock服务器端编程模型中的完成端口(IOCP)的使用方法
- 其中的IOContext类是封装了用于每一个重叠操作的参数
- 具体说明了服务器端建立完成端口、建立工作者线程、投递Recv请求、投递Accept请求的方法,所有的客户端连入的Socket都需要绑定到IOCP上,所有从客户端发来的数据,都会调用回调函数。
- 用法:派生一个子类,重载回调函数
https://blog.csdn.net/Summer_night_star/article/details/120803104 ↩︎
https://blog.csdn.net/Summer_night_star/article/details/122420277 ↩︎
https://www.cnblogs.com/onlytiancai/archive/2009/04/06/1430256.html ↩︎
http://www.cppblog.com/johndragon/archive/2008/09/16/21845.html ↩︎
http://www.cppblog.com/niewenlong/archive/2007/08/17/30224.html ↩︎
Address:Department of Natural/Social Philosophy & Infomation Sciences, CHINA
Biography...
Like this article? Support the author with