IOCP/TCP实现(一)

IOCP(完成端口)机制是Windows提供的高效的异步通知机制。本系列将探讨利用IOCP机制实现TCP通信的一些实现细节。事实上,我在写这些文字之前已经初步完成了IOCP/TCP的Delphi实现(https://github.com/Alohahiahi/IOCP-TCP-with-Delphi-10.git)。写下这些东西的目的一是整理知识细节,做个阶段性学习小结;二是把一些我觉得有价值的东西开源出来,于人于己我觉得都是再好不过了。

不过作为IOCP/TCP实现的开篇,我暂时还不想涉及实现的细节。如果你正准备构思一个IOCP/TCP实现,在动手开始敲代码之前,我觉得我们可以沏杯茶,思考几个问题:

  1. TCP/IP协议族关于TCP协议都有哪些特性?
  2. Windows操作系统提供了哪些可用的基础设施?如何使用?
  3. 我们对即将开始的实现有什么期望(或者说需求)?

如果你对上述三个问题早已有答案,我想后续的内容就不用看了,对你来说那就是些陈芝麻烂谷子的旧事。

1 TCP协议特性

TCP是面向连接的、可靠的字节流协议。提供可靠性传输,顺序控制、重发控制等机制。关于TCP协议提供了哪些特性这个问题虽然我列出来了,但是如果在这里详细展开的话,恐怕会无端地增加本文的篇幅,关键是我可能也讲不清楚。这方面的文章或书籍有不少,例如图解TCP/IPUNIX网络编程 卷1 套接字联网API。所有特性事实上都是围绕可靠字节流等关键字展开的。这里,我就个人理解捡重点说几点。

1.1 面向连接

TCP作为面向连接的协议,只有在确认通信对端存在时才会发送数据。这涉及到TCP连接的建立与断开序列,也就是我们常说的三路握手和四路挥手。通常TCP建立连接需要3个分节,而断开连接需要4个分节(如图1[1])。

由于连接的建立与断开涉及两端信息交换,TCP协议还定义11个相关的状态,图2[2]展示了其中10种常见的状态及其转换条件,其中实线表示客户端套接字正常状态转换,虚线表示服务端套接字正常状态转换。

图1 TCP连接建立与断开时序图
图2 TCP状态转换图

通过建立连接的分组信息交换,不仅避免了不必要的流量浪费(如对端未启动等),而且交换了很多有用的信息,如初始序列号、最大分节大小(MSS)等,为可靠性传输,顺序控制以及避免IP层分片操作等需求提供了数据支撑。

断开连接中,TIME_WAIT状态是最需要关注的。执行主动关闭的一方最后会进入TIME_WAIT状态,那么什么是TIME_WAIT状态?为什么需要TIME_WAIT状态?为什么这个状态的持续时间是2MSL(最长分节生命期的2倍)?

一个TCP连接由本地IP、本地端口、远端IP、远端端口唯一标识。这么说可能有点模糊,我们知道当客户端通过connect函数发起连接时,需显式指定对端地址信息,如果调用connect前未显式通过bind函数指定本地地址,内核会为该通信套接字隐式指定本地IP和本地端口(保证本地端口的唯一性)。所以,当某套接字处于TIME_WAIT状态时,其本地端口不可用于建立新的连接。姑且把这当成是对TIME_WAIT状态的解释吧。

那么为什么需要这个状态呢?其存在的目的主要是为了解决以下两个问题:

  1. 可靠地实现TCP全双工连接的终止;
  2. 允许老的重复分节在网络中消逝。

如果主动执行关闭动作的端最后发送的ACK分节在网络中丢失,基于重发机制,对端会重新发送FIN分节。显然对端在收到最后的ACK之前,仍将认为连接是有效的。如果不继续维持某种临界状态(如TIME_WAIT),万幸的情况是在该端口被用于新的连接前,客户机以RST分节响应了对端重发的FIN分节,对端以连接上有错误通知上层应用从而结束本连接;否则的话,以该端口新建的连接将会收到错误的FIN分节,从而引发一系列连锁反应。这实际上引出了第2个理由。

任何TCP实现都必须为MSL(maximum segment lifetime, 最长分节生命期)选定一个值,虽然各实现可能采用不同的值,但这个值是确定的。因此,当TIME_WAIT状态持续时间为2MSL时,足以保证两个方向上的分组在TIME_WAIT状态结束前在网络中消逝。如此,保证了每个新建立的连接都是干净的。

1.2 传输层缓冲区

每个TCP套接字都有一对缓冲区:输入缓冲和输出缓冲(如图3)。

图3 传输层缓冲区

传输层缓冲区作为应用程序和内核之间数据交换的桥梁,为许多TCP特性的实现带来了便利。我们知道TCP是面向流的协议。理想状态下,在TCP连接建立后的任何时间应用层都可以发送任意大小的数据。然而,由于通信两端之间复杂的网络拓扑,数据传输的实际速率却是恒定且不够的。引入传输层缓冲区后,可将应用程序的IO请求同底层的数据传输隔离。在实际应用场景中,通过一些辅助手段,例如,通知IO、重叠IO,几乎可以达到任何时间发送任意大小数据的理想状态。

TCP还是一个可靠的协议。这意味着要实现如重发控制、窗口通知、拥塞控制等机制。这些复杂的控制逻辑基本上都是围绕上层应用提供的数据展开的。通过传输层缓冲区,将上层应用逻辑和底层控制逻辑隔离,起到了很好的解耦作用,传输层只需专注自己的本职工作就好了。这种思想其实在我们自己的开发实践中经常的用到,虽然其在整体上略微增加了项目的复杂度,但是在项目管理、代码调试以及后期扩展、维护等方面带来的便利是无法估量的。

在可靠性的前提下,效率也是至关重要的。通常IP层会将来自传输层的TCP分节整个封装在IP数据报中,然后交由链路层发送。但是,如果IP数据报过长则无法封装在链路层的单个帧中,需进行分片处理后封装到多个帧中进行发送,而目的端则需进行重组操作。因此,为了尽可能地避免IP分片/重组操作,TCP会根据MSS和MTU限定每个分节的大小。显然,当有缓冲区存在时,上层应用并不需要知道这些,传输层会为我们做好这些事情。

2 系统可用基础设施

传输层逻辑通常由操作系统实现,操作系统以API的方式提供相关功能调用。虽然TCP/IP协议族从诞生到现在经过了许多迭代,不过在写这篇文字时,就如同我这个年龄的人一样,已多年未变。各操作系统的实现也是大同小异。不过,仅仅靠TCP/IP是不够的,操作系统提供的很多辅助设施也是编写基于TCP/IP应用的必不可少的一部分。

本系列主要探讨基于Windows IOCP机制的TCP应用实现,为此就IOCP及相关技术略微写点文字。

2.1 通知I/O与I/O通知(重叠I/O)

首先,我想先说一说同步I/O与异步I/O这两个概念,其指的是I/O操作函数的返回时机。如果是同步I/O函数调用,则直到I/O操作完成才返回;如果是异步I/O则对应调用立即返回,但I/O不一定成功(或没有完全成功)。对于同步I/O,进行IO的过程中无法执行其它任务;而异步I/O无论数据是否完成传输都立刻返回,因而可以执行其它的任务。显然,异步方式比同步方式能更有效地使用CPU。

对TCP通信来说,I/O操作完成指用户缓冲中数据全部写入传输层缓冲,或从传输层缓冲中读到数据。

通知I/O
I/O应该关注的是何时能进行I/O操作、I/O操作何时完成及完成状况。对何时能进行I/O操作,也就是什么时候传输层输出缓冲有闲置空间可写,或传输层输入缓冲中有数据可读,这个需求引出了通知I/O。通知I/O的核心是监视套接字的状态(有连接请求,可写入数据,可读取数据等等),当发生相应的网络事件时通知用户执行相应的I/O操作。系统提供的相应设施有selectWSAAsyncSelectWSAEventSelect。但其本质上还属于同步I/O范畴。

重叠I/O
重叠I/O是Windows特有的异步I/O机制,其核心类型是OVERLAPPED。当执行重叠I/O时,操作系统会接管用户的I/O缓冲,因此,重叠I/O操作关注的核心是I/O操作何时完成以及完成的结果,而不用理会调用I/O操作时套接字的状态。常见的完成通知有两种: 一种是使用OVERLAPPED结构体的hEvent成员,并使用等待函数等待事件受信(即I/O操作完成);另一种方法是使用完成端口机制(IOCP)进行完成通知。

围绕重叠I/O模式,Windows对常规的套接字接口进行了扩展,增加了一系列以WSA开头或以Ex结尾的扩展接口。

对于接收数据,应用程序使用WSARecv函数提供一个或多个缓冲以接收数据。如果这些缓冲在网络数据到来之前提供,当网络数据实际到来时,数据会被直接写到用户缓冲中。因此,避免了发生在recv函数调用中的拷贝操作。如果在提交接收缓冲时网络数据已在传输层缓冲中,则立即进行拷贝操作。

在具体设计应用程序时,要充分利用上述特征。合理的设计应保证大部分网络数据被直接写入用户提供的接收缓冲中,以避免额外的拷贝操作。

在发送端,应用程序使用WSASend提供一个或多个已填充信息的缓冲以待发送,但需保证在传输层消耗掉所有这些缓冲之前不会修改这些缓冲。

重叠函数调用会立即返回。如果返回值为0表示I/O操作被立即完成且对应的完成通知已经触发,也就是说相关的事件对象已处于受信状态或某个完成例程已入队并等待调用(调用线程处于可提醒等待状态时调用已入队的完成例程);如果返回值为SOCKET_ERROR且扩展错误码为WSA_IO_PENDING时,表示重叠操作已成功初始化。当发送缓冲完全被消耗后或当有数据到来时会得到后续完成通知。注意,对流类型的套接字,只要输入数据被接收缓冲完全消耗就会触发完成通知,而不管接收缓冲是否已填满。

如果事先已知对端发送数据的大小,可在调用WSARecv时指定MSG_WAITALL标志强制传输层必须在填满接收缓冲后才能触发完成通知。
在某些应用场景中,例如代理,事先不可能知道对端发送数据大小,因此在调用WSARecv时究竟指定多大的接收缓冲是无法得知的。如果从节省内存资源的角度考虑,可指定较小的接收缓冲,但这必然会增加WSARecv的调用次数,从而降低整体的执行效率;反之,如果指定较大的接收缓冲,传输层并不保证完成通知发生在接收缓冲填满时(事实上,也不可能得到这样的保证,因为可能网络数据本身就不足以填满用户提供的接收缓冲)。也就是说,较大的接收缓冲并不一定意味较少的WSARecv调用次数。如此来看,合理的值应在具体的应用场景中通过数值试验进行估计。

发送和接收操作都可以是重叠的。接收函数可以调用多次以提交多个接收缓冲;发送函数也可以调用多次以入队多个待发送缓冲。对发送操作,虽然多次提交的发送缓冲会按照提交顺序依次被发送,但对应的完成通知的顺序是不能保证的;同样,在接收端,多次提交的接收缓冲会依次被填充,但对应的完成通知的顺序同样是不能被保证的。

2.2 IOCP(IO Completion Port)[3]

任何应用程序,抛开其背后的数学、物理或其它背景及精妙的结构设计,通常都可以抽象为以下两种模型:

  • 串行模型(serial model)
    串行并非意味着单线程,其强调事件发生的顺序。例如,仅当事件A发生后,事件B发生:
图4 串行模式
  • 并发模型(concurrent model)
    并发必然意味着多线程,其强调事件发生的独立性。例如,事件A和事件B的发生没有依赖关系,可能事件A先于事件B发生,可能事件A后于事件B发生,也可能事件A和事件B同时发生:
图5 并行模式

在TCP通信中,如果服务应用程序采用串行模型,当两个客户同时发出请求时,那么一次只能处理一个请求,第二个请求必须等待第一个请求处理结束。显然这种模式不能满足实际的应用需求,也没有充分发挥多处理器机器的优势;
而常规的并发模型中,通常每个客户请求都会由一个新创建的线程来对其进行处理。如此避免了串行模式的不足,但同时也引入了新的问题:

  • 创建线程产生额外的开销
  • 线程切换上下文产生额外的开销

尤其是当同时处理大量的客户请求时,产生的额外开销和实际业务开销的比值变大,大量的CPU时间被用于处理上述两种非业务需求。

CPU高居不下,但并没有做多少有意义的事。俗话说的“事倍功半”就是这个意思。

完成端口正是Windows为解决这个问题而提出的比较成功的方案。

那么完成端口是如何解决上述问题的呢?首先简单看看Windows的线程模式。

Windows是抢占式多线程操作系统(preemptive multithreaded operating system):

  • 系统线程调度程序每隔一定时间(约20ms)会查看当前存在的可调度的线程内核对象。如果存在等待运行的可调度线程,调度程序会根据调度算法、线程优先级以及关联性等选择一个等待中的可调度线程并分配CPU以运行线程。如果有闲置CPU则直接将该线程上次保存的线程上下文载入CPU寄存器并运行;否则,调度程序会终止某CPU上正在执行的线程,保存其线程上下文。同时,载入即将运行的线程的上下文并运行。这个操作称之为上下文切换(context switch)

如果当前系统中的线程数少于或等于CPU核数,显然,是不可能出现上下文切换的。除非用户主动挂起线程或调用了sleep系列函数。可见,线程获得CPU后运行时长可能大于20ms。

Windows完成端口机制主要涉及以下几个函数:

  • CreateIoCompletionPort
  • GetQueuedCompletionStatus
  • PostQueuedCompletionStatus

在使用任何工具之前都有必要好好研究下工具本身。如果你对这些函数的用法还不是很清楚的话,可参考MSDN。

完成端口为多处理器系统上的多异步I/O请求提供了一个高效的线程模式。当进程创建一个I/O完成端口时,系统会创建相关的一系列队列(如图6[4])。结合一个预先初始化的线程池,进程通过使用完成端口可以更快,更高效地处理大量并发的异步I/O请求。

图6 I/O完成端口的内部运作

完成端口最重要的属性是最大并发数。完成端口的最大并发数在其通过CreateIoCompletionPort创建时由NumberOfConcurrentThreads参数指定。这个参数限制与完成端口关联的线程的可运行数。如果同完成端口关联的正在运行的线程数已达到指定的最大并发值,系统将阻止其它关联线程被唤醒,直到正在运行的线程数小于最大并发数。

最高效的情形是,当I/O完成队列中有完成封包等待时,由于完成端口上正在运行的线程数已达到其最大并发数而不会唤醒任何其它线程。在这种情况下,如果完成端口的完成队列中总是有正在等待的完成封包,当正在运行的线程处理完上一个封包,然后调用GetQueuedCompletionStatus时,其不会阻塞而是立即获得下一个完成封包并处理。这时没有线程上下文切换,因为运行中的线程是连续地获得完成封包的,同时其它线程仍然不能运行。

在上面的例子中,额外的线程似乎没什么用,因为它们从来不运行。但是上面的情况是假设运行线程从来不会因为其它机制而进入等待状态。

显然,合适的最大并发数是机器的CPU数。如果线程处理的事务需要长时间运算,更大的并发数将允许更多线程得以运行。有些完成封包可能需要较长的时间进行处理,但多数完成封包的处理时间是差不多的。可以通过数值试验获取最佳的最大并发数。

如果与完成端口关联的正在运行的线程因为其它原因进入等待状态,例如,调用了SuspendThread函数,系统会允许因调用GetQueuedCompletionStatus而等待运行的线程处理完成封包。当之前进入等待的线程又开始运行时,可能有一个短暂的时间实际运行的线程数大于最大并发数。但是系统会通过禁止唤醒其它等待线程而快速减小这个实际并发数。这就是为什么应用程序要将线程池线程数设置的比完成端口最大并发数大的原因。

3 期望与现实

我们即将进行的IOCP/TCP实现不应该涉及某个具体的业务需求。事实上,我们需要做的事情很明确:及时、准确地发送本端数据;及时、无遗漏地接收对端数据。如此,在保证上述需求的前提下,我们的焦点将集中在并发数、效率、易用性、可扩展性等方面。

3.1 并发数

并发数往往是服务端应用程序关注的指标。为了尽可能地提高并发数,采用多线程模式进行开发肯定是首选方案。幸好,正如前面所述,Windows为我们提供了高性能的IOCP线程机制,其在一定程度上避免了多线程应用中大量的线程上下文切换问题,更合理的利用了CPU的处理能力。虽然操作系统已为我们提供了如此优秀的基础设施,但我们在设计实现时还是有许多需要考虑的事情。例如,线程间的同步问题:如何设计数据结构以避免引入资源竞争或减少资源竞争的代价?锁、临界区、原子操作等同步手段该如何选择?等等。这就是要说的第二个焦点:效率。

3.2 效率

任何程序都不会将效率需求抛之脑后,这是一个永恒的话题。多线程、原子操作、各种池、Hash表、红黑树等等,都是追求效率的产物。这让我立刻想到了算法与数据结构。再大的项目都可分解成一个一个小的功能模块,也许某个快速排序算法就能让程序的执行效率提升一个数量级。不幸的是,并没有完全通用的算法,每个算法都有其应用场景,甚至是某个不恰当的参数因子都能让算法的性能大打折扣。显然我们不能过分依赖某些语言或系统提供的库,如有必要则必须进行定制开发。

就IOCP/TCP实现来说,我们其实已经有了一些需求。例如套接字复用、IO缓冲复用、接收缓冲的排序等等,这些都值得我们花点时间好好规划下。

3.3 易用性、可扩展性

我们的实现仅仅关注的是用户数据的传输,显然,我们必须把上层应用(如某个具体的业务实现)考虑进来。有很多书籍进行了这方面的论述,例如代码大全程序员修炼之道UNIX 编程艺术以及各种有关设计模式的书籍,等等。我想我就不说了吧。

最后,我想再次重申下,写下这篇文字的目的在于个人学习小结与交流。我会尽我可能保证描述的准确性,如果有错误或遗漏欢迎批评指正,本人感激不尽。


  1. 图解TCP/IP(第5版), 208页 ↩

  2. UNIX网络编程 卷1 套接字联网API(第3版), 35页 ↩

  3. https://docs.microsoft.com/en-us/windows/desktop/fileio/i-o-completion-ports ↩

  4. Windows核心编程(第5版),309页 ↩

你可能感兴趣的:(IOCP/TCP实现(一))