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