一. 前言
自TCP诞生以来就改变了网络通信的格局,而TCP协议随着网络基础设施的发展也在一路演进,形成了如今庞大复杂的TCP协议簇。如何深入理解TCP的设计理念以及几十年以来TCP协议的演进,有利于更好地了解网络编程。很多人不懂TCP,很多人了解并会用TCP但不知道其设计理念,但是这些才是网络编程的精粹所在。本文旨在从设计思路出发,更多的分析为什么要这么做而不是TCP是怎么做的,但是碍于自身水平所限,可能视野较为狭隘,疏漏错误之处请不吝指正。本文阅读的前提要求至少学习过《计算机网络》,看过《TCP/IP详解》及相关的RFC文档更佳。
二. 分层
当两台电脑被一根线连接起来的时候,网络就诞生了。随着几十年间的发展,网络变得越来越复杂。如同管理国家,企业一样,对于复杂到无法单点直接控制的情况,采取模块化以及分层的策略才是解决复杂问题的合理方法,有趣的是这也是Linux的一大设计哲学:复杂的问题可以通过抽象出中间层的方法分解为几个简单的问题。因此,我们有了网络分层。
常见的网络协议模型由两种:OSI标准七层模型,业界标准TCP/IP模型。二者其实表示的是一个意思,只不过对于我们开发者来说不需要特别细化的后三层,其结构如下图所示。
每层的核心作用可以概括如下:
- 物理层负责物理设备的接入,如网卡,网线等,涉及设备驱动的注册及虚拟文件系统的挂载
- 数据链路层负责单个网络内通过MAC地址通信,包括了常见的ARP协议以及CSMA/CD
- 网络层负责通过路由算法和IP地址实现多个网络间的通信,如IP, ICMP
- 传输层则在网络层的基础上更为细化的对其通信进行分解,将数据包分发给各个应该接收到的进程/线程,如TCP和UDP
- 应用层则在传输层基础上进行了扩展,实现了更为丰富精彩的功能,如FTP, TELNET, SMTP, NFS, HTTP
在实际使用中,我们也是采取层层封装/拆封的方式传递数据,如下图所示
由此一来,每层各司其职,也可以各自进行扩展、优化、重构,从而构成了如今庞大复杂而又不失美感的网络协议模型。网络协议模型从下往上功能增加,需要实现的设备减少的同时设备的复杂性却在增加,这样保证了成本的最小化,至于性能则可以通过逐层逐步优化的方式慢慢改进,这也符合架构设计和重构的基本哲学。TCP协议就是这样的典型代表,实际上最开始的时候TCP并不考虑性能、效率、公平性,正是考虑了这些,TCP协议才复杂了起来。
三. 端对端
正如其名字所展示的那样,TCP的作用是传输控制,也就是控制端到端的传输,那为何这种控制不在IP协议中实现呢?答案很简单,那就是这会增加IP协议的复杂性,而IP协议需要的就是简单。网络层往下是繁多的链路层协议,这些链路提供了截然不同且相差很远的语义,为了互联这些异构的网络,我们需要一个网络层协议起码要提供一些适配的功能,这种思想其实和通过虚拟文件系统抽象各种字符设备和块设备一样,所以必须简单,因为简单所以稳定而强大。这也是KISS原则的体现。
初级面试官常爱问TCP和UDP的区别,答案模板网上一搜一大把,往往问过答过也就过去了,但是你真的理解了其本质吗?
- 所谓的连接,容易让人误以为使用 TCP 会使得两端之间的通路和使用 UDP 不一样,那我们会在沿途建立一条线表示这个连接吗?
- 从中国访问美国网站,中间这么多环节,怎么保证连接不断呢?中间有个网络管理员拔了一根网线不就断了吗?我不能控制它,它也不会通知我,我一个个人电脑怎么能够保持连接呢?
- 既管不了中间的链路,也管不了对端的服务器,TCP如何做流量控制和拥塞控制?
- 按照网络分层,TCP 和 UDP 都是基于 IP 协议的,IP 都不能保证可靠,说丢就丢,TCP 怎么能够保证呢?
以上问题实质上可以说是同一个问题:如何理解TCP是端到端的协议。首先,从上图数据包的传递我们可以得出一个结论:中间传递过程不关心四层及以上,仅仅通过下三层完成多条路由,无论是TCP还是UDP都是一样的。下面我们来看看TCP的端到端特性。
- TCP连接的建立本质上只是TCP状态机中的一个状态,即客户端和服务端维持连接的数据结构接收到特定的信息后将其标记位设置为已连接。
- 当两边因为收到一些消息导致状态机状态对应不上,或者逻辑判断出现问题,则称之为连接断开
- 流量控制和拥塞控制其实就是根据收到的对端的网络包,调整两端数据结构的状态。TCP 协议的设计理论上认为,这样调整了数据结构的状态,就能进行流量控制和拥塞控制了,其实在通路上是不是真的做到了,谁也管不着。
- 所谓的可靠,也是两端的数据结构做的事情。不丢失其实是数据结构在“点名”,顺序到达其实是数据结构在“排序”,面向数据流其实是数据结构将零散的包,按照顺序捏成一个流发给应用层。总而言之,“连接”两个字让人误以为功夫在通路,其实功夫在两端。
这就是TCP端到端协议的本质:通过两端的数据结构、状态机、复杂的逻辑判断流程图对不可靠的链路和下层协议做到了可靠的传输保证。由于IP协议不提供保证,TCP也不能提供依赖于IP下层链路的这种保证,比如带宽,比如时延,这些都是链路层决定的,既然IP协议无法修补,TCP也不能,然而它却能修正始于IP层的一些“不可保证性质”,这些性质包括IP层的不可靠,IP层的不按顺序,IP层的无方向/无连接。
确切的说,TCP协议有两重身份
- 作为网络协议,它弥补了IP协议尽力而为服务的不足,实现了有连接、可靠传输、报文按序到达。
- 作为一个主机软件,它和UDP以及左右的传输层协议隔离了主机服务和网络,它们可以被看做是一个多路复用/解复用器,将诸多的主机进程数据复用/解复用到IP层。
可以看出,不管从哪个角度,TCP都作为一个接口存在,作为网络协议,它和对端的TCP接口,实现TCP的控制逻辑,作为多路复用/解复用器,它和下层IP协议接口,实现协议栈的功能,而这正是分层网络协议模型的基本定义(两类接口,一类和下层接口,另一类和对等层接口)。
四. 可靠性
TCP被称之为可靠的传输协议,是因为其设计理念中强调了对可靠性的实现,我们可以归结为连接的可靠性以及数据报文传输的可靠性。
4.1 连接可靠性
4.1.1 三次握手和四次挥手
为了理解连接的可靠性,我们需要用到《TCP/IP详解》卷一中的经典TCP状态转移图。
何为可靠连接?直白的说就是我们可以知道连接是否建立,是否断开,表现为图中即从CLOSE
到ESTABLISHED
则表明连接建立,从ESTABLISHED
回到CLOSE
则连接断开。表现在协议里,则是三次握手和四次挥手。
这里又是初级面试官最爱问的问题之一,为什么是三次握手和四次挥手?答案其实很简单,所谓的握手挥手无非是通过一个SYN
和一个ACK
的来回确认彼此在线并初始化序列号。三次握手最重要的作用是传递了序列号,该序列号是可靠传输的保证,为此需要使双方均保证已经收到。而之所以建立之始可以只用三次,是因为初始两次握手并没有传递数据,所以可以将服务端发回客户端的ACK
和SYN
合并在一起发送。而连接断开的时候,如果当前还有数据传输,则该单向通道不可以立即关闭,因此只发送ACK
而没有FIN
,这才变成了三次握手和四次挥手。
4.1.2 TIME_WAIT
上面说了三次握手和四次挥手,这里我们再来考虑一个情景:终止连接时的被动方发送了一个FIN
,主动方回复了的ACK
丢失,这会造成被动方重发FIN
,这个FIN可能会在互联网上存活MSL
时间(MSL,即报文最大存活时间,根据光速以及TTL等综合计算得到,详细算法可见维基和RFC文档)。如果没有TIME_WAIT
的话,假设连接1已经断开,然而其被动方最后重发的那个FIN
(或者FIN之前发送的任何TCP分段)还在网络上,然而连接2重用了连接1的5元组(源IP,目的IP,TCP,源端口,目的端口),连接1迟到的FIN到达了,这个FIN
将以比较低但是确实可能的概率终止掉连接2。
为何说是概率比较低呢?这涉及到一个匹配问题,迟到的FIN
分段的序列号必须落在连接2的一方的期望序列号范围之内。虽然这种巧合很少发生,但确实会发生,毕竟初始序列号是随机产生了。因此终止连接的主动方必须在接受了被动方FIN
且回复了ACK
之后,等待2*MSL
时间才能进入CLOSE
状态,之所以乘以2是因为最坏情况下,回复给被动方的ACK
在以最长路线(经历一个MSL
)丢包,再次收到被动方重发的FIN
也需要一个MSL
,合起来就是2倍MSL
时间。如果超过2MSL
也没有回复,则判断为被动方收到了该ACK
,如果是FIN
没有收到,那也不用担心上述的问题了,因为已经超过了一个数据包的存活时间了。
综上,TIME_WAIT状态的出现是因为
- 每次建立连接的时候序列号都是随机产生的,并且这个序列号是32位的,会回绕
- 被动关闭方可能因为收不到
ACK
而重发FIN
- 主动关闭方无法知晓被动方是否收到了最后的
ACK
4.2 传输可靠性
传输可靠性主要表现为解决了IP层数据包的乱序和丢包问题,其实现简单的说就是给每个包加上序号,按照序号进行排序,如果有缺失则要求重发。但是由于端对端的协议我们只能控制两端而不能控制链路,因此存在以下问题
- 如何判断是否超时,即丢包:超时机制
- 如果丢包如何重发:停等,GBN,选择性重传
- 如果频繁丢包如何进行流量控制:通过窗口解决效率问题
4.2.1 超时机制
为了确保每个包都收到并有序,TCP设计了序列号。为了确保该序列号收到,TCP设计了ACK
机制。但是如果ACK
没有收到呢?为此TCP设计了超时机制。超时机制最大的问题在于如何判断对端是否收到了消息,即对端是否发送了ACK。为此我们需要想办法测算出正常情况下TCP发送一个数据包并收到ACK回复的时间,如果超过该时间还未收到ACK,则可认为是超时了。这就是RTT(Round Trip Time),也叫RTD(Round Trip Delay)。RTT的计算根据实际的协议会有着不同的定义方式,但是大致均采用滑动平均的方式过滤掉偶尔剧烈的波动,具体可以参考不同的RFC协议文档。实际我们使用的时候还会用到RTO(Retransmission TimeOut)。二者间动态变化关系可查看RFC6298,更多时候这些滑动平衡的系数来自于经验数值,并没有什么值得言道的地方。
很显然,对每一个TCP分段都生成一个计时器是最直接的方式:每个计时器在RTT时间后到期,如果没有收到确认则重传。然而这只是理论上的合理,对于大多数操作系统而言,这将带来巨大的内存开销和调度开销,因此采取每一个TCP连接单一计时器的设计则成了一个默认的选择。可是单一的计时器怎么管理如此多的发出去的TCP分段呢?又该如何来设计单一的计时器呢。
设计单一计时器有两个原则:1.每一个报文在长期收不到确认都必须可以超时;2.原则1中定义的长期不能和测量的RTT相隔太远。RFC2988定义的逻辑如下:
- 发送TCP分段时,如果还没有重传定时器开启,那么开启它。
- 发送TCP分段时,如果已经有重传定时器开启,不再开启它。
- 收到一个非冗余
ACK
时,如果有数据在传输中,重新开启重传定时器。 - 收到一个非冗余
ACK
时,如果没有数据在传输中,则关闭重传定时器。
其中包含一条设计哲学:一个ACK
到来了,说明后续的ACK
很可能会依次到来,也就是说丢失的可能性并不大,另外,即使真的有后发的TCP分段丢失现象发生,也会在最多2倍定时器超时时间的范围内被重传(假设该报文是第一个报文发出启动定时器之后马上发出的,丢失了,第一个报文的ACK
到来后又重启了定时器,又经过了一个超时时间才会被重传)。网络拥塞会引起丢包,丢包会引起重传,过度重传反过来加重网络拥塞,这样设计可以缓解过多的重传,毕竟将启动定时器之后发送的数据的重传超时时间拉长了最多一倍左右。
4.2.2 ARQ
当超时真的发生的时候,我们就需要重传了,重传的依据就是序列号。发送端会给每个报文加上一个序列号,而接收端会保存当前连续收到的最后一个序列号,并以此发送ACK。发送端若长期未收到ACK,即超时发生,则出发该序列号的包重传。另外,发送端重发了一个TCP报文并且接收到该TCP分段的确认号,并不能说明这个重发的报文被接收了,也可能是数据早就被接收了,只是由于其ACK丢失或者其ACK延迟到达导致了超时。值得说明的是,接收端会丢弃任何重复的数据,但是即使丢弃了重复的数据其ACK还是会照发不误的。
重传算法通常称之为ARQ(Auto Repeated Request),主要包括
- 停等重传:最早期的ARQ协议,收到一个ACK才会继续发送新的包,效率极低,在发明了流水线发送之后就很少使用,仅在某些特定场景适用
- GBN协议:GBN(Go Back N)是继停等式ARQ之后的一种常用重传协议,在早期TCP中有使用,发送方持续的发送多个数据报文,假如发现已发送的报文中某个序列号未收到,则从该序列号开始后面所有的包均重发。该方法其实存在很大的问题:很多乱序的数据属于冗余发送,容易引起网络拥塞的加重。
- 基于SACK的选择性重传:在GBN的基础上增加对乱序报文的缓存,仅仅令发送端发送缺少的报文。优点在于数据冗余量小,缺点在于复杂度高。在此基础上,后续又有了D-SACK。D-SACK扩展允许TCP接受端在利用SACK选项来通报收到重复的数据包,从而TCP源端能够正确地推断出接受端是否收到了重复地数据包。因此,D-SACK扩展使得TCP源端在重发后一个RTT时间内正确地推断出重发是否必要。如果源端认为重发是不必要的,那么拥塞窗口减半也就没必要了,源端就会将拥塞窗口大小和慢启动阈值分别恢复到原来的值,这样拥塞窗口恢复到原来的大小只需1RTT时间而不是W/2 RTT时间了。
4.2.3 流控
端到端的流量控制使用滑动窗口来实现,滑动窗口本质上其实就是一个生产者消费者队列模型。滑动窗口会根据当前ACK的速率设置大小,尽量匹配收发两端的速率。仅从速率来看,该设计思路无可厚非,可以保证一个收发相对稳定可靠的队列模型。但是随着网络的发展,流量的爆炸式增加,除了速率以外我们会越来越重视效率。这引发了三个不同的问题:
- 接收端处理慢导致接收窗口被填满:收发端的速率不匹配导致了接收方发送的ACK可能很久才只有1个,这导致了发送方的窗口一直在0到1之间变化,即糊涂窗口综合征。这个问题极大的浪费了网络带宽,降低了网络利用率。
- 接收端处理很快,但是发送端持续发送小包:这种现象来自于发送端,其实也很常见,比如有些人打字就喜欢一句话拆成N个单词分开发送,太多的小包也会和上一个问题一样导致了网络利用率的降低。
ACK
的单独回复本身也会占用大量的带宽
为此,TCP设计了几种不同的方案解决以上问题。
- 对于糊涂窗口综合征,TCP的解决思路是当窗口达到一定程度再继续发送报文,其具体实现可以通过坚持计时器(persistent timer)进行探测,也可以控制接收发的
ACK
发送时机。 - 针对发送端的大量小包,TCP的解决思路是对其进行合并,这就是经典的Nagle算法。
- 针对
ACK
的流量浪费,我们将ACK
和发回的数据包试图合并,如果等了一段可以接受的时间还是没有数据要发往发送端,此时就需要单独发送一个ACK
了,然而即使如此,这个延迟的ACK
也可能等到了后续到来的TCP分段,这样它们就可以取最大者一起返回了,要知道,TCP的确认号是收到的按序报文的最后一个字节的后一个字节。这就是延时ACK。RFC建议,延迟的ACK
最多等待两个分段的积累确认。
这三个问题的解决方案是几乎同一时期提出的,各自为了解决不同的问题,但是其混杂在一起却造成了一些别的影响。
首先,我们可以分析出来Nagle算法和延时ACK对糊涂窗口综合征显然都是有利的:二者可以尽量拖延时间使得接收方尽可能多的处理数据从而增大窗口。但是延时ACK
和Nagle结合的时候就会出现一些不利因素:Nagle算法仅允许一个小包在路上,如果后续有多个小包而未积攒至足够大则不会发送。若此时延时ACK
也在工作并且接收端并无消息发送,则该ACK
只能等待超时后再传回。在这种场景下,Nagle和延时ACK
的叠加会导致性能的下降而无任何收益。对Nagle来说,其ACK
收到的时间延长了,对于延时ACK
来说,其期望的网络效率提高未实现。
因此,我们需要根据业务需要开启合适的流控算法,甚至对其进行修改优化,从而满足业务需求,提高网络传输的速率和效率。
五. 拥塞控制
端到端的TCP只能看到两个节点,那就是自己和对方,它们是看不到任何中间的路径的。可是IP网络却是一跳一跳的,它们的矛盾之处在于TCP的端到端流量控制必然会导致网络拥堵:每条TCP连接的一端只知道它对端还有多少空间用于接收数据,它们并不管到达对端的路径上是否还有这么大的容量。在早期网络数据较少的时候还未有这种烦恼,但是随着近30年网络的飞速发展,拥塞控制成了TCP的研究热点和痛点所在。
拥塞控制的本质依然是一种流控,但是和传统流控着眼于解决可靠性、网络速率、网络利用率不同,拥塞会直接导致整个网络的瘫痪。早期的TCP设计并无拥塞控制,遇到丢包则采用重传机制,而重传机制则在一定程度上加剧了网络的拥塞,导致了网络更加的不可用。由此,TCP拥塞控制应运而生。为了解决拥塞问题,拥塞控制必须设计成满足以下需求:
- 公平性
- 拥塞后退让出流量
这两点也正是各种拥塞控制算法的出发点,其算法本身均是这两个需求的体现。最早期的经典拥塞控制算法为Tahoe, Reno和New Reno,即大名鼎鼎的慢开始、拥塞控制、快重传和快恢复。后续针对Reno的缺点,诞生出了Bic和更为高级的Cubic算法。除此之外,还有未解决特定场景而定制的一系列算法。如数据中心中的Vegas和New Vegas,无线网络的West Wood。这些算法均是延续着最早的设计需求和理念而发明,在前人的肩膀上拓展,Cubic至今也是Linux中的主流拥塞控制算法。但是值得一提的是,谷歌公司提出的BBR和BBR2.0则在一定程度上打破了常规拥塞控制算法的设计理念,真正的实现了拥塞控制的需求,让人们看到了拥塞控制未来的发展之路。后续会专门写一篇文章分析BBR源码及其思想。
5. 1 慢开始和拥塞避免
下面我们来好好分析一下慢开始和拥塞避免的设计哲学。首先提出几个问题,如果知道的可以跳过该部分了。
- 对于一个网络连接来说,何种情况算是满载?
ssthresh
是如何设置的?- 为什么慢开始是指数增长而拥塞避免是递增?
- 为什么遇到拥塞
ssthresh
需要减半?
为了回答这些问题,我们先要理解网络连接的容量。如下图所示为一个经典的发送、接收双方的链路抽象图。其中两端肥肥的部分代表的是发送、接收方的缓冲区,而中间细长管道则是网络链路。
先单看发送方,管道的容量取决于什么呢?有人肯定会说,这个问题太白痴了,管道的容量当然是取决于该管道实际的大小,即带宽时延乘积(BDP),但是实际不是这样的。一个管道或者说队列的容量,取决于其出口的速度大小,当发出速度和接受速度相等并填满管道时,则该管道满载。这其实是一道小学数学题:游泳池一边放水一边装水会空掉还是装满溢出?同样的,对于端到端的TCP连接来说,接收端处理的速度决定了该连接的最大容量。如果接收端处理无限快,我们可以认为该连接容量无穷大,可以以任意速度发包。
接着看看收发双端,相信大家都知道CPU指令流水线的原理,下图则是一个典型的慢开始阶段的收发段示意图(图片来源于《TCP/IP详解》,由于PDF质量差因此图片较为模糊)。由图可见,因为接收端速率和发送端相同,因此在一个RTT时间里,实际最多能发送管道容量N的两倍,即2 * N个数据包。
这2 * N个数据段发送的开始时间点是第1个数据段发送的时间,结束时间点是第一个数据段的ACK回到发送端的时间,正好是一个RTT,设发送速率为r,那么以下的等式显而易见:2 * N = r * RTT
下面我们再来分析一下上述流水线发包的一些关键点
- 当包少于N个(图中为4个)时,因为管道未满,我们甚至不需要考虑接收方的处理速度,直接发包即可
- 当包数量在N到2 * N个之间时,因为未达到管道满载,我们可以继续增加。但是这时候需要考虑接收方的发送速度,即按照接收方的速度来调整发包的速度增加的幅度。
- 当包数量达到甚至超过2 * N的时候,则铁定已经满载并拥塞,需要进行调整。
这三个关键点,用大家最常见的描述即,慢开始,拥塞避免,拥塞时窗口减半。这么一看是不是感觉突然知道了慢开始和拥塞避免的设计思路了呢?
- 在慢开始阶段可以快速增长以达到N,即
ssthresh
- 达到
ssthresh
后,需要按照接收方的处理速度慢慢递增以免出现拥塞,因此该阶段称之为拥塞避免 - 当出现拥塞时,说明当前探测的管道容量即窗口大小C大于等于2 * N,所以新的
ssthresh
就应该等于当前窗口大小C的一半,又因为ssthresh
本身等于C(逐渐递增),因此ssthresh
取自身一半即为当前管道的实际N值(并不准)。
5.2 慢开始的优化
我们知道,ssthresh
的设置是以丢包作为反馈信号的,现在问题是,连接刚刚建立的时候,没有丢包作为反馈信号的时候,如何来设置ssthresh
?
一般而言,默认的实现都是将其设置为一个巨大的值如65536,然后最快的速度历经一次丢包,然后设置ssthresh
为丢包时窗口的一半,然后像ssthresh
的2倍缓慢逼近。但是这会带来问题,由于没有ssthresh
作为阈值限制,用丢包作为代价,太高昂。因此在慢启动过程中如果可以探测到ssthresh
的值,那就可以随时退出慢启动状态了。
根据上述公式 2 * N = r * RTT, 其中速率 r 为单位时间内发出的数据包数,假设在T时间内发出M个包,则公式可变为 T = (M / N) * (RTT / 2)。当 T 等于 RTT的一半时,则刚好可有M等于N,即达到了ssthresh
。由于我们无法单独探测M个数据段到达接收端并计时,我们可以变相等价使用ACK来计算,以一个窗口的第一个数据段作为计时开始Tstart
,每收到一个ACK即更新以下数值:
RTTmin
:采样周期内最小的RTT,以最大限度地表示A和B之间的理想往返时延。Tcurr
:当前时间
如果下列条件成立,则可以退出慢启动了:Tcurr - Tstart >= RTTmin / 2
然而现实并不是理想的,大多数情况下,以上的算法并没有带来比较好的效果,为什么呢?因为整个带宽不是一个TCP连接独享的,而是全世界的所有TCP连接甚至包括UDP共享的,因此以上的公式基本上无法表示任何真实的情况,所以实际当中,更倾向于使用RTT来预估网络已经被塞满。使用RTT来估算网络容量ssthresh
更加实际一些,因为它充分考虑了拥塞时的排队延时,因此在该方法下,退出慢启动的条件便成了:Tcurr_rtt > RTTmin + fixed_value
以上旨在解决首次慢启动在还没有ssthresh
值的时候预测ssthresh
的方式,其实在此后的任何时候,只要是慢启动,都可以用以上的算法来预测当前的ssthresh
,而不是说必须要用拥塞算法给出的ssthresh
或者说仅仅是1/2丢包窗口。
5. 3 快重传和快恢复
只有慢开始和拥塞避免的拥塞控制算法是不完美的,因为它太过于严苛了:
- 任意丢包均视为拥塞发生
- 每次拥塞发生窗口降至1重新开始
对于第一点,快重传进行了尝试解决。对于第二点,则是快恢复。慢开始、拥塞避免、快重传和快恢复结合起来,就是我们所舒适的Reno算法代表的经典拥塞控制算法。
5.4 AIMD和公平性
理解了上述设计思路,大家就不难理解何为加性增乘性减(AIMD)了,其指的就是拥塞避免阶段加法形式的窗口增加和拥塞时的乘法性窗口递减过程。而为什么说这带来了TCP的公平性呢?下图是一副经典的说明图片。对于两个TCP连接R1和R2,假设网络总带宽为R,则二者必然会在某一个位置触发拥塞。拥塞之后,由于都是乘性减,因此对于占用窗口较大的一方会减少的更多,而之后窗口增加过程中由于系数相同,因此会同比增加,之后再次拥塞,再次减少,如图所示则为红线,随着不断地拥塞和窗口重置,该震荡曲线最终会收敛至y = x的曲线,即二者窗口相等。这就是TCP拥塞控制算法带来的公平性的体现。
如果降窗比例不相等,或者RTT时间不同,或者增加速度不同,则无法达到该效果,可见下图所示
5.5 拥塞控制的发展和走向
该部分其实在前面已经有提到,而且也不打算在此展开讲,因为展开讲内容太多太多了,每一个算法都是几十页的论文加上几千上万行的源码,真有兴趣的同学可以先看《TCP/IP详解卷1》大致了解,再想深究可以就RFC文档、论文和源码进行深入研究。在这里之所以赘述,是为了更清晰的说明拥塞控制算法的分类和其特点。
- 丢包驱动类型
早期的Tahoe,完善版的Reno,以及更完善的New Reno基本上奠定了以丢包驱动的拥塞控制算法类型,而后提出的BIC和CUBIC更是将该种拥塞控制算法发展到了巅峰(数学模型上的优化、进步以及多年经验积累)。至今,CUBIC依旧是TCP的默认拥塞控制算法。
- 特殊类型
从很早开始人们就对TCP拥塞控制算法进行了各种各样的优化,其中对于特殊使用场景的优化更是十分重要。这里面不得不提的就是数据中心等为代表的高速专用链路的通信。这些通道其特点不适用于丢包驱动,而是以时延驱动更为妥当(详细判断介绍可参考相应论文)。由此,Vegas和Fast成为了数据中心的主流拥塞控制算法。而Westwood和Westwood+则成了无线通信中的常用拥塞控制算法(更为常用的则是QUIC或者自定义的可靠UDP,性能更为出色)。
- BDP驱动
这里的主角就是BBR算法了,BBR全称Bottleneck Bandwidth and Round-trip propagation time
,简单的说就是带宽和时延乘积驱动的拥塞控制算法,即BDP驱动的拥塞控制算法。为什么该算法一经发出即引起广大轰动,不是因为它是谷歌提出来的(的确也是一部分原因),而是因为它的思想打破了长久以来的思维定式:管道填满和丢包是密切相关的。该算法提出:在现今的网络中,由于大量的缓冲区的存在,丢包发生的时候实际已经远远超过了实际的网络带宽,因此我们不应该等到丢包再进行拥塞控制,而是通过计算BDP来控制发送速率和窗口大小,从而使得网络一直处于将满未满的状态,即最完美的状态。BBR算法1.0尚显稚嫩,发展至2.0已经显现出了其无与伦比的潜力和作用,相信这是未来TCP拥塞控制算法的大势所趋。更为可怕的是,从谷歌的一些行动、博客和论文可以推测出,谷歌的野心在于通过可靠UDP的QUIC和BBR的结合,完美的取代已有的TCP/UDP,将网络传输层的API彻底收归己友,而事实上的确也是有着极强的竞争力。建议同学们有空一定要多多了解BBR和QUIC。
六. 性能瓶颈
6.1 理论上的连接性能瓶颈
性能瓶颈的思考来源于之前被大佬问过的一道题:不考虑硬件性能的情况下,单机客户端、服务端最多能支持多少个连接?
这道题的答案主要考察了对连接五元组的理解,即源地址,源端口,目的地址,目的端口和协议类型。
- 对于客户端,其限制因素在于源地址固定、源端口上限为65535,协议类型可以是UDP/TCP,因此极限为2的17次方,但是实际上由于很多端口为系统使用端口,因此不可能达到该限制。
- 对于服务端来说,本地地址和端口固定,因此其上限在于客户端的源地址,源端口和协议类型的组合,理论为2的49次方。
6.2 C10K问题
C10K问题来源于Dan Kegel的个人博文《The C10K problem》,该文章建议深读。该作者在1999年提出了此问题,那时的服务器还只是 32 位系统,运行着 Linux 2.2 版本(后来又升级到了 2.4 和 2.6,而 2.6 才支持 x86_64),只配置了很少的内存(2GB)和千兆网卡。怎么在这样的系统中支持并发 1 万的请求呢?从资源上来说,对 2GB 内存和千兆网卡的服务器来说,同时处理 10000 个请求,只要每个请求处理占用不到 200KB(2GB/10000)的内存和 100Kbit (1000Mbit/10000)的网络带宽就可以。所以,物理资源是足够的,接下来自然是软件的问题,特别是网络的 I/O 模型问题。
在 C10K 以前,Linux 中网络处理都用同步阻塞的方式,也就是每个请求都分配一个进程或者线程。请求数只有 100 个时,这种方式自然没问题,但增加到 10000 个请求时,10000 个进程或线程的调度、上下文切换乃至它们占用的内存,都会成为瓶颈。既然每个请求分配一个线程的方式不合适,那么,为了支持 10000 个并发请求,这里就有两个问题需要解决。
- 第一,怎样在一个线程内处理多个请求,也就是要在一个线程内响应多个网络 I/O。以前的同步阻塞方式下,一个线程只能处理一个请求,到这里不再适用,是不是可以用非阻塞 I/O 或者异步 I/O 来处理多个网络请求呢?
- 第二,怎么更节省资源地处理客户请求,也就是要用更少的线程来服务这些请求。是不是可以继续用原来的 100 个或者更少的线程,来服务现在的 10000 个请求呢?
解决方案主要来源于I/O模型的优化和工作模型的优化。
I/O模型从传统的阻塞请求方式改变为非阻塞
工作模型优化主要包括两种
主进程加多个worker子进程。主进程执行
bind() + listen()
后,创建多个子进程;然后,在每个子进程中,都通过accept()
或epoll_wait()
,来处理套接字。比如,最常用的反向代理服务器 Nginx 就是这么工作的。它也是由主进程和多个 worker 进程组成。主进程主要用来初始化套接字,并管理子进程的生命周期;而 worker 进程,则负责实际的请求处理。这里要注意,accept()
和epoll_wait()
调用,还存在一个惊群的问题。换句话说,当网络 I/O 事件发生时,多个进程被同时唤醒,但实际上只有一个进程来响应这个事件,其他被唤醒的进程都会重新休眠。其中,accept()
的惊群问题,已经在 Linux 2.6 中解决了;而epoll
的问题到了Linux 4.5
,才通过EPOLLEXCLUSIVE
解决。监听到相同端口的多进程模型。在这种方式下,所有的进程都监听相同的接口,并且开启 SO_REUSEPORT 选项,由内核负责将请求负载均衡到这些监听进程中去。这一过程如下图所示。由于内核确保了只有一个进程被唤醒,就不会出现惊群问题了。比如,Nginx 在 1.9.1 中就已经支持了这种模式。
6.3 C10M问题
随着摩尔定律带来的服务器性能提升以及互联网的普及,C10K已经被远远的甩开,而C10M问题迎面而来。当各种软件、硬件的优化很可能都已经做到头了,特别是当升级完硬件(比如足够多的内存、带宽足够大的网卡、更多的网络功能卸载等)后,你可能会发现,无论你怎么优化应用程序和内核中的各种网络参数,想实现 1000 万请求的并发,都是极其困难的,这就是C10M问题。
究其根本,还是 Linux 内核协议栈做了太多太繁重的工作。从网卡中断带来的硬中断处理程序开始,到软中断中的各层网络协议处理,最后再到应用程序,这个路径实在是太长了,就会导致网络包的处理优化,到了一定程度后,就无法更进一步了。要解决这个问题,最重要就是跳过内核协议栈的冗长路径,把网络包直接送到要处理的应用程序那里去。这里有两种常见的机制,DPDK 和 XDP。
DPDK是用户态网络的标准。它跳过内核协议栈,直接由用户态进程通过轮询的方式,来处理网络接收。说起轮询,你肯定会下意识认为它是低效的象征,但是进一步反问下自己,它的低效主要体现在哪里呢?是查询时间明显多于实际工作时间的情况下吧!那么,换个角度来想,如果每时每刻都有新的网络包需要处理,轮询的优势就很明显了。比如:在 PPS 非常高的场景中,查询时间比实际工作时间少了很多,绝大部分时间都在处理网络包。而跳过内核协议栈后,就省去了繁杂的硬中断、软中断再到 Linux 网络协议栈逐层处理的过程,应用程序可以针对应用的实际场景,有针对性地优化网络包的处理逻辑,而不需要关注所有的细节。此外,DPDK 还通过大页、CPU 绑定、内存对齐、流水线并发等多种机制,优化网络包的处理效率。
XDP(eXpress Data Path)则是 Linux 内核提供的一种高性能网络数据路径。它允许网络包在进入内核协议栈之前就进行处理,也可以带来更高的性能。XDP 底层和 bcc-tools 一样都是基于 Linux 内核的 eBPF
机制实现的。
总结
短短一篇文章(其实很长)想弄懂TCP的全部内容显然是不现实的,本文希望能给同学们提供一个全新的思路去学习、掌握TCP,并通过理解其设计理念去进行优化、改进甚至自己创造更好的通信协议。如果真的能起到那么一点点帮助,那这篇文章就有其价值了,希望大家都能有所收获。
参考资料
[1] wiki
[3] woboq
[4] Linux-insides
[5] 深入理解Linux内核
[6] Linux内核设计的艺术
[7] 极客时间 趣谈Linux操作系统
[8] 深入理解Linux网络技术内幕
[9] CSDN dog250多篇博文
[10] TCP/IP详解