上周和同事互水了一下 TCP协议中的三次握手和四次挥手,一个同同事互水的技术话题(经典面试题目),这周我们讨论了一下 TCP协议中的核心知识点:
滑动窗口
可靠的数据重传
拆包粘包
单tcp连接多请求
TCP重传
好记性不如烂笔头
,笔者还是觉得查阅各种资料并且整理成笔记,方便以后查阅(其实也是为了以后面试做笔记)。
应用层
(跟具体用户应用相关,包括HTTP、MQTT
等应用层协议)传输层
(负责维持正常的数据通信过程,主要代表是TCP、UDP
)网络层
(负责逻辑地址以及路由分发,包括IP、ICMP、ARP、RARP
等协议)网络访问层
(相当于电脑设备的网卡以及驱动程序,管理物理地址(MAC
))分析网络协议,绝对不要忽略某一层,每一层都非常重要,是一个整体链路。
HTTP
(业务相关,比如我们需要访问什么网页,需要网页返回什么内容)TCP
(负责把上层协议内容(HTTP协议内容)正确地发送到目标服务端口)IP
(负责路由分发,把上层协议内容(TCP协议内容)转发到目标服务IP)数据链路层
(负责把数据转发到最终的网卡)HTTP依赖TCP,TCP又依赖IP,IP最终会把数据扔给数据链路层,数据链路层在把数据转成电信号发送到目标地址。
没有IP地址
的,那是IP层上的事。但是有源端口
和目标端口
。同一个连接(src_ip, src_port, dst_ip, dst_port)
Sequence Number
是包的序号,用来解决网络包乱序(reordering)
问题,也就是数据最终会通过它来重组成正确的顺序。Acknowledgement Number
就是ACK——用于确认收到,用来解决不丢包
的问题。Window
又叫Advertised-Window,也就是著名的滑动窗口(Sliding Window),用于解决流控
的。TCP Flag
,也就是包的类型,主要是用于操控TCP的状态机(在上篇内容中,博主介绍了TCP通信过程中的状态转换)
的。此部分内容从 TCP 的那些事儿 搬运过来,加上自己的一些理解。
整个TCP通信过程分为了三个阶段:
建立连接的三次握手阶段
数据传输过程
(涉及到我们本篇标题的知识点)断开连接的四次挥手阶段
具体内容参考:TCP协议中的三次握手和四次挥手,一个同同事互水的技术话题(经典面试题目)
三次握手的过程主要是为了初始化 Sequence Number
的初始值。通信的双方要互相通知对方自己的初始化的Sequence Number
(缩写为ISN:Inital Sequence Number)——所以叫SYN,全称Synchronize Sequence Numbers。也就上图中的 x 和 y。这个号要作为以后的数据通信的序号,以保证应用层接收到的数据不会因为网络上的传输的问题而乱序(TCP会用这个序号来拼接数据
)。
个人感觉,其实三次握手就类似于对暗号的过程(交换彼此的初始化序列号,只有序列号对上了数据才进入后续传输环节),在电影《智取威虎山3D》中就有这么一句经典对白:
A:
天王盖地虎
B:宝塔镇河妖
A:暗号对上了,咱们喝酒去。
三次握手的过程也有不少需要知道的知识点。
关于建连接时SYN超时
关于SYN Flood攻击
关于ISN的初始化
试想一下,如果server端接到了client发的SYN
后回了SYN-ACK
后client掉线了
,server端没有收到client回来的ACK,那么,这个连接处于一个中间状态,即没成功,也没失败
。于是,server端如果在一定时间内没有收到的TCP会重发SYN-ACK。在Linux下,默认重试次数为5次
,重试的间隔时间从1s开始每次都翻倍,5次的重试时间间隔为1s, 2s, 4s, 8s, 16s,总共31s,第5次发出后还要等32s都知道第5次也超时了,所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 2^6 -1 = 63s
,TCP才会把断开这个连接。
一些恶意的人就为此制造了SYN Flood攻击
——给服务器发了一个SYN后,就下线了,于是服务器需要默认等63s才会断开连接
,这样,攻击者就可以把服务器的syn连接的队列耗尽
,让正常的连接请求不能处理。于是,Linux下给了一个叫tcp_syncookies
的参数来应对这个事——当SYN队列满了后,TCP会通过源地址端口、目标地址端口和时间戳打造出一个特别的Sequence Number发回去(又叫cookie),如果是攻击者则不会有响应,如果是正常连接,则会把这个 SYN Cookie发回来,然后服务端可以通过cookie建连接(即使你不在SYN队列中)。请注意,请先千万别用tcp_syncookies来处理正常的大负载的连接的情况。因为,synccookies是妥协版的TCP协议
,并不严谨。
对于正常的请求,你应该调整三个TCP参数可供你选择,
第一个是:tcp_synack_retries
可以用他来减少重试次数;
第二个是:tcp_max_syn_backlog
,可以增大SYN连接数;
第三个是:tcp_abort_on_overflow
处理不过来干脆就直接拒绝连接了。
一个TCP session 由 source IP + source Port + destination IP + destination Port
唯一决定,一般也称为 TCP socket,所以即使每个TCP session 的ISN(Initial Sequence Number )
都是相同的也无妨,TCP可以将不同socket的数据提交给不同的应用进程,而不会造成混淆。
初始建立TCP连接的时候的系列号(ISN)是随机选择的,那么这个系列号为什么不采用一个固定的值呢?主要有两方面的原因:
Bogus TCP Reset
大型防火墙的工作原理就是一旦发现有访问blocked website
,则需要重置这个连接,步骤如下:伪造一个TCP reset,包含IP字段、TCP字段的伪造,发送给客户端,因为所有字段都是可以接受的,所以客户端接受这个reset消息并重置TCP连接。因为流量途径防火墙,所以包括IP Header 、TCP Header的信息都能得到,所以很容易伪造。
对于不能接触到流量的任何第三方能否也可以伪造一个TCP Reset呢?理论上也是可以的,对于静态的字段如 source/destination IP/Port 这个比较容易伪造,最难的就是 TCP sequence number
,至少伪造的序列号位于对方的 slide window 窗口之内,而如果采用静态ISN,则相对容易构造一个TCP Reset,然后将一个TCP session 重置了,这很显然不利于安全。
Ambignity of TCP Port Reused
由于允许一个刚释放的TCP Port重用
,如果已释放的TCP session 与 新建立的TCP session 四原组完全一致,则存在老的session 的数据依然在路上,新的session 也在路上,这样对方就会被弄迷糊,而无法判断谁是真正的合法数据。如采用动态增长的ISN
,则避免相邻的两个TCP session 的 sequence number 的重叠,不会造成误会。
TCP初始化序列号不能设置为一个固定值,因为这样容易被攻击者猜出后续序列号,从而遭到攻击。
RFC1948中提出了一个较好的初始化序列号ISN随机生成算法(其实就是为了不让伪造攻击者能预测到我们的序列号)。
ISN = M + F(localhost, localport, remotehost, remoteport).
M
是一个计时器,这个计时器每隔4毫秒加1。
F
是一个Hash算法,根据源IP、目的IP、源端口、目的端口生成一个随机数值。要保证hash算法不能被外部轻易推算得出,用MD5算法
是一个比较好的选择。
具体内容参考:TCP协议中的三次握手和四次挥手,一个同同事互水的技术话题(经典面试题目)
四次挥手的过程也有不少需要知道的知识点。
关于 MSL 和 TIME_WAIT
通过上面的ISN的描述,相信你也知道MSL是怎么来的了。我们注意到,在TCP的状态图中,从TIME_WAIT状态到CLOSED状态
,有一个超时设置,这个超时设置是 2*MSL
(RFC793定义了MSL为2分钟,Linux设置成了30s)为什么要这有TIME_WAIT?为什么不直接给转成CLOSED状态呢?主要有两个原因:
1)TIME_WAIT确保有足够的时间让对端收到了ACK,如果被动关闭的那方没有收到Ack,就会触发被动端重发Fin,一来一去正好2个MSL。
2)有足够的时间让这个连接不会跟后面的连接混在一起(你要知道,有些自做主张的路由器会缓存IP数据包,如果连接被重用了,那么这些延迟收到的包就有可能会跟新连接混在一起)。你可以看看这篇文章《TIME_WAIT and its design implications for protocols and scalable client server systems》
关于TIME_WAIT数量太多
从上面的描述我们可以知道,TIME_WAIT是个很重要的状态,但是如果在大并发的短链接
下,TIME_WAIT 就会太多,这也会消耗很多系统资源。
只要搜一下,你就会发现,十有八九的处理方式都是教你设置两个参数,一个叫tcp_tw_reuse
,另一个叫tcp_tw_recycle
的参数,这两个参数默认值都是被关闭的,后者recyle比前者resue更为激进,resue要温柔一些。
另外,如果使用tcp_tw_reuse,必需设置tcp_timestamps=1,否则无效。这里,你一定要注意,打开这两个参数会有比较大的坑——可能会让TCP连接出一些诡异的问题(因为如上述一样,如果不等待超时重用连接的话,新的连接可能会建不上。正如官方文档上说的一样“It should not be changed without advice/request of technical experts”)。
Again,使用tcp_tw_reuse和tcp_tw_recycle来解决TIME_WAIT的问题是非常非常危险的
,因为这两个参数违反了TCP协议(RFC 1122)
其实,TIME_WAIT表示的是你主动断连接
,所以,这就是所谓的“不作死不会死”。试想,如果让对端断连接,那么这个破问题就是对方的了,呵呵。另外,如果你的服务器是于HTTP服务器,那么设置一个HTTP的KeepAlive
有多重要(浏览器会重用一个TCP连接来处理多个HTTP请求
),然后让客户端去断链接(你要小心,浏览器可能会非常贪婪,他们不到万不得已不会主动断连接)。
数据传输过程中,会涉及到几个知识点;
Sequence Number
和 Acknowledgement Number
拆包粘包
IP分片与重组
滑动窗口 Window
TCP重传
拥塞窗口
TCP是一种面向连接
的、可靠
的、基于字节流
的传输层通信协议,它会保证数据不丢包
、不乱序
。
要想做到 数据不丢包(也就是需要知道哪些包有没有丢、丢了重传)、不乱序(也就是数据能重组到正确的顺序给到上层),我们需要给数据进行编号。
编号的工作主要由TCP协议头的两个字段进行标识:
Sequence Number
数据字节流
,它标识在这个报文段中的第一个数据字节。假设,有一个400字节的数据段A,由于最大100字节的分段限制,我们就会分为a1、a2、a3、a4:
a1:1-100Seq = 1
a2:101-200Seq = 101
a3:201-300Seq = 201
a4:301-400Seq = 301
Acknowledgement Number
确认已经收到的序号并下次想收到的序号
。以上面的案例为讲解:
1、假设接收方收到了a1、a2,并且接收方告诉发送方 ack = 201,那么发送方就知道了接收方收到了a1、a2,接下来继续发送 a3、a4
2、假设a4先到达了,a3由于延迟还没有到达,接收方还是只会告诉发送方 ack = 201,不会说ack = 401.
知识点:
Acknowledgement of delay
首先,TCP本来就是基于字节流而不是消息包的协议
,它自己说的清清楚楚:我会把你的数据变成字节流发到对面去,而且保证顺序不会乱,但是你要自己搞定字节流解析。
简单概括:TCP只是一个传输层协议,不关心发送的具体内容,只是确保数据能正确到达接收方。也就是上层协议给我什么数据我就发送什么数据,不会对数据进行解析
。
重点内容说三遍:
TCP没有包的概念
TCP没有包的概念
TCP没有包的概念
拆包粘包
是TCP协议文档中压根没有存在过的词汇。而是国内开发人员在使用TCP中遇到,并吐槽成“拆包粘包”问题。其实就是“如何设计应用层协议的问题”。
先来说“拆包
”:
以HTTP应用层为例,如果HTTP报文数据大,会在TCP层进行分段Segment。
TCP 维持一个变量,它等于最大报文段长度 MSS(通常来说,为了不让IP继续分片,可以设置为路径MTU,保证整个传输转发过程中都不会再次拆分
)。只要缓存中存放的数据达到 MSS 字节时,就组装成一个 TCP 报文段发送出去。
比如HTTP报文H,传到传输层时,在TCP分段为H1、H2、H3…HN
那么,先来第一个疑问点——接收方怎么知道 H1、H2、H3…HN 都是属于H这个包?
TCP 只负责同一个应用层数据的正确传输,而不会去区分数据段标识。也就是假设有两个上层应用数据A和B,它们分别分段为A1、A2…AN和B1、B2…BN,那么如果确保A1不会混在B中呢?
当然,有人就说了,我可以先发完A得到响应之后再继续发B,的确这是一个思路。不过我们这里只是讨论如何区分A和B。TCP区分不了,那么我们就看看IP分片与重组。
再来说“粘包
”(其实,根本不存在所谓粘包一说,不过国人为了说明这一类问题,命名为粘包):
产生“粘包”的情况:
nagle算法
,会将较小的内容拼接成大的内容,一次性发送到服务器端,因此造成粘包在数据传输过程中,通常会遇到一些小分组的传输(比如 41 bit的数据分组,除去TCP首部和IP首部真正传输的数据只有1 bit),像这种小分组多的话,在网络上传输就加大了造成网络拥塞的可能。为了提传输效率,所以提出了Nagle算法。
这个算法要求一个TCP连接最多只能有一个未被确认的未完成的小分组,在该分组到达之前不能发送其他的小分组。然后,TCP会收集这些小分组,并在确认到来时以一个分组的方式发送出去,这样就可以有效的减少了小分组。
在一些实时性要求比较高的场景下,采用了Nagle算法会让用户感觉到时延,所以我们可以选择关闭Nagle算法,Socket API 可以用 TCP_NODELAY 选项来关闭,nginx上的 tcp_nodely也是采用的这个系统调用。
recv(buffer_size)
方法中的buffer_size较小,不能一次性完全接收全部内容,因此在下一次请求到达时,接收的内容依然是上一次没有完全接收完的内容,因此造成粘包现象。也就是说:接收方不知道该接收多大的数据才算接收完毕,造成粘包
。
说白了就是解决粘包问题,就是如何定义好数据协议,严格区分前后两个数据的分割线(其实,就是应用层协议
的工作)。
TCP是流协议,根本不存在所谓粘包一说。
简单地说,TCP保证发送方以什么顺序发字节流,接收方就一定能按这个顺序接收到,或者因为网络超时返回错误。这个是操作系统保证的,应用程序根本不用管也控制不了。
发送方应该以什么格式发送数据,接收方能正确解析出数据,这个叫应用层协议,你自己定,跟TCP完全无关。
如果是发文件,最简单的你可以用http协议封装,如果你发的http协议数据是100%正确的,无论哪个接收方(nginx/tomcat/iis)保证能一字节不差地收下,因为http协议本身就带header和body,header里有Content-Length: 12345指定了body的大小,body才是文件本身。
你不用http协议,直接发文件数据,那么问题来了,接收方怎么知道应该收多少字节后文件结束?
关键点:
标识
用来唯一地标识主机发送的每一份数据报。通过它来表示分段数据是否属于同一个包。
标志位
标志一份数据报是否要求分段
段偏移量
如果一份数据报要求分段的话,此字段指明该段偏移距原始数据报开始的位置。
正常来说,如果传输到IP层的数据大于IP MTU,就会进行IP分片。但是如果我们在TCP上就针对MTU进行分段,是不是意味着IP就不需要分片了呢?然后利用好IP的标识来处理好分段的数据重组成一个完整的包。
得出结论:
IP负责整个分片的数据重组
,IP区分了不同传输层协议之间的重组。TCP负责上层数据的顺序重排
,TCP区分了不同应用层协议数据之间的重排。首先,我们得看看滑动窗口到底是为了解决什么问题?
我们假设如果没有滑动窗口会发生什么问题。
提出问题:在我们滑动窗口协议之前,我们如何来保证发送方与接收方之间,每个包都能被收到。并且是按次序的呢?
发送方发送一个包1,这时候接收方确认包1。发送包2,确认包2。就这样一直下去,知道把数据完全发送完毕,这样就结束了。那么就解决了丢包,出错,乱序等一些情况!同时也存在一些问题。
问题:吞吐量非常非常低
,在网络慢的情况下会自闭的。
我们发完包1,一定要等确认包1.我们才能发送第二个包。
提出问题:那么我们就不能先连发几个包等他一起确认吗?这样的话,我们的速度会不会更快,吞吐量更高些呢?
如图,这个就是我们把两个包一起发送,然后一起确认。可以看出我们改进的方案比之前的好很多,所花的时间只是一个来回的时间。从一定程度上改善了吞吐量的问题。但是还不是最优解。
问题:我们每次需要发多少个包过去呢?发送多少包是最优解呢?
我们能不能把第一个和第二个包发过去后,收到第一个确认包就把第三个包发过去呢?而不是去等到第二个包的确认包才去发第三个包。这样就很自然的产生了我们"滑动窗口"的实现。
在图中,我们可看出灰色1号2号3号包已经发送完毕,并且已经收到Ack。这些包就已经是过去式。4、5、6、7号包是黄色的,表示已经发送了。但是并没有收到对方的Ack,所以也不知道接收方有没有收到。8、9、10号包是绿色的。是我们还没有发送的。这些绿色也就是我们接下来马上要发送的包。 可以看出我们的窗口正好是11格。后面的11-16还没有被读进内存。要等4号-10号包有接下来的动作后,我们的包才会继续往下发送。
滑动窗口协议是传输层进行流控
的一种措施,接收方通过通告发送方自己的窗口大小
,从而控制发送方的发送速度,从而达到防止发送方发送速度过快而导致自己被淹没的目的。
对ACK的再认识,ack通常被理解为收到数据后给出的一个确认ACK,ACK包含两个非常重要的信息:
一是期望接收到的下一字节的序号n,该n代表接收方已经接收到了前n-1字节数据,此时如果接收方收到第n+1字节数据而不是第n字节数据,接收方是不会发送序号为n+2的ACK的。
举个例子,假如接收端收到1-1024字节,它会发送一个确认号为1025的ACK,但是接下来收到的是2049-3072,它是不会发送确认号为3072的ACK,而依旧发送1025的ACK。
二是当前的窗口大小m
,如此发送方在接收到ACK包含的这两个数据后就可以计算出还可以发送多少字节的数据给对方,假定当前发送方已发送到第x字节,则可以发送的字节数就是y=m-(x-n)
.这就是滑动窗口控制流量的基本原理。
发送方根据收到ACK当中的期望收到的下一个字节的序号n以及窗口m,还有当前已经发送的字节序号x,算出还可以发送的字节数。所以需要区分收到ACK之后的滑动窗口的移动变化(原滑动窗口->现滑动窗口)
已发送,已收到ACK
)—— 发送窗外 缓冲区外已发送,未收到ACK
)—— 发送窗内 缓冲区内未发送,但准备发送
)—— 发送窗内 缓冲区内未发送,也不允许发送
)—— 发送窗外 缓冲区内其中类型 已发送,未收到ACK
和 未发送,但准备发送
都属于发送窗口
。
接收窗口:
对于TCP的接收方,在某一时刻在它的接收缓存内存在3种。
其中“未接收准备接收
”称之为接收窗口
。
TCP并不是每一个报文段都会回复ACK的,可能会对两个报文段发送一个ACK,也可能会对多个报文段发送1个ACK【累计ACK
】,比如说发送方有1/2/3 3个报文段,先发送了2,3 两个报文段,但是接收方期望收到1报文段,这个时候2,3报文段就只能放在缓存中等待报文1的空洞被填上,如果报文1,一直不来,报文2/3也将被丢弃,如果报文1来了,那么会发送一个ACK对这3个报文进行一次确认。
发送并没有确认
切到发送已经确认
,提出窗口,这个时候窗口向右移动
就是不断重复着上述的过程,随着窗口不断滑动,将真个数据流发送到接收端,实际上接收端的Window Size通告也是会变化的,接收端根据这个值来确定何时及发送多少数据,从对数据流进行流控。原理图如下图所示:
下面我们来看一个接受端控制发送端的图示:
上图,我们可以看到一个处理缓慢的Server(接收端)是怎么把Client(发送端)的TCP Sliding Window给降成0的。此时,你一定会问,如果Window变成0了,TCP会怎么样?
是不是发送端就不发数据了?是的,发送端就不发数据了,你可以想像成“Window Closed”,那你一定还会问,如果发送端不发数据了,接收方一会儿Window size 可用了,怎么通知发送端呢?
解决这个问题,TCP使用了Zero Window Probe
技术,缩写为ZWP,也就是说,发送端在窗口变成0后,会发ZWP的包给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,第次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了
。
注意:只要有等待的地方都可能出现DDoS攻击,Zero Window也不例外
,一些攻击者会在和HTTP建好链发完GET请求后,就把Window设置为0,然后服务端就只能等待进行ZWP,于是攻击者会并发大量的这样的请求,把服务器端的资源耗尽。
什么是 DDos攻击?参考 这里
可能我举个例子会更加形象点。我开了一家有五十个座位的重庆火锅店,由于用料上等,童叟无欺。平时门庭若市,生意特别红火,而对面二狗家的火锅店却无人问津。二狗为了对付我,想了一个办法,叫了五十个人来我的火锅店坐着却不点菜,让别的客人无法吃饭。
上面这个例子讲的就是典型的 DDoS 攻击,全称是 Distributed Denial of Service,翻译成中文就是分布式拒绝服务
。一般来说是指攻击者利用“肉鸡”对目标网站在较短的时间内发起大量请求,大规模消耗目标网站的主机资源,让它无法正常服务
。在线游戏、互联网金融等领域是 DDoS 攻击的高发行业。
Silly Window Syndrome翻译成中文就是“糊涂窗口综合症
”。正如你上面看到的一样,如果我们的接收方太忙了,来不及取走Receive Windows里的数据,那么,就会导致发送方越来越小。到最后,如果接收方腾出几个字节并告诉发送方现在有几个字节的window,而我们的发送方会义无反顾地发送这几个字节。
要知道,我们的TCP+IP头有40个字节,为了几个字节,要达上这么大的开销,这太不经济了。
另外,你需要知道网络上有个MTU,对于以太网来说,MTU是1500字节,除去TCP+IP头的40个字节,真正的数据传输可以有1460,这就是所谓的MSS(Max Segment Size)注意,TCP的RFC定义这个MSS的默认值是536,这是因为 RFC 791里说了任何一个IP设备都得最少接收576尺寸的大小(实际上来说576是拨号的网络的MTU,而576减去IP头的20个字节就是536)。
如果你的网络包可以塞满MTU,那么你可以用满整个带宽,如果不能,那么你就会浪费带宽
。(大于MTU的包有两种结局,一种是直接被丢了,另一种是会被重新分块打包发送) 你可以想像成一个MTU就相当于一个飞机的最多可以装的人,如果这飞机里满载的话,带宽最高,如果一个飞机只运一个人的话,无疑成本增加了,也而相当二。
所以,Silly Windows Syndrome
这个现像就像是你本来可以坐200人的飞机里只做了一两个人。 要解决这个问题也不难,就是避免对小的window size做出响应,直到有足够大的window size再响应
,这个思路可以同时实现在sender和receiver两端。
另外,Nagle算法默认是打开的,所以,对于一些需要小包场景的程序——比如像telnet或ssh这样的交互性比较强的程序,你需要关闭这个算法。你可以在Socket设置TCP_NODELAY
选项来关闭这个算法(关闭Nagle算法没有全局参数,需要根据每个应用自己的特点来关闭)
另外,网上有些文章说TCP_CORK
的socket option是也关闭Nagle算法,这不对。TCP_CORK其实是更新激进的Nagle算汉,完全禁止小包发送,而Nagle算法没有禁止小包发送,只是禁止了大量的小包发送。最好不要两个选项都设置
。
总结几点:
发送窗口
只有收到对端对于本段发送窗口内字节的ACK确认,才会移动发送窗口的左边界。接收窗口
只有在前面所有的段都确认的情况下才会移动左边界。动态调整
,主要是根据接收端的接收情况,动态去调整Window Size
,然后来控制发送端的数据流量。TCP要保证所有的数据包都可以到达,所以,必需要有重传机制。
那么重传机制怎么实现呢?
普通思路:
重传计时器
。计时器有一个初始值并随时间递减。如果在片段接收到确认之前计时器超时,就重传片段。重传队列
的数据结构中,此时启动重传计时器。TCP只会重传一定数量的次数
,并判断出现故障终止连接。重传机制又分为:
注意,
接收端给发送端的Ack确认只会确认最后一个连续的包
,比如,发送端发了1,2,3,4,5一共五份数据,接收端收到了1,2,于是回ack 3,然后收到了4(注意此时3没收到),此时的TCP会怎么办?我们要知道,因为正如前面所说的,SeqNum和Ack是以字节数为单位,所以ack的时候,不能跳着确认,只能确认最大的连续收到的包
,不然,发送端就以为之前的都收到了。
一种是不回ack,死等3,当发送方发现收不到3的ack超时后,会重传3。一旦接收方收到3后,会ack 回 4——意味着3和4都收到了。
但是,这种方式会有比较严重的问题,那就是因为要死等3,所以会导致4和5即便已经收到了,而发送方也完全不知道发生了什么事,因为没有收到Ack,所以,发送方可能会悲观地认为也丢了,所以有可能也会导致4和5的重传。
对此有两种选择:
这两种方式有好也有不好。第一种会节省带宽,但是慢,第二种会快一点,但是会浪费带宽,也可能会有无用功。但总体来说都不好。因为都在等timeout,timeout可能会很长
于是,TCP引入了一种叫Fast Retransmit
的算法,不以时间驱动,而以数据驱动重传
。也就是说,如果,包没有连续到达,就ack最后那个可能被丢了的包,如果发送方连续收到3次相同的ack,就重传。Fast Retransmit的好处是不用等timeout了再重传。
比如:如果发送方发出了1,2,3,4,5份数据,第一份先到送了,于是就ack回2,结果2因为某些原因没收到,3到达了,于是还是ack回2,后面的4和5都到了,但是还是ack回2,因为2还是没有收到,于是发送端收到了三个ack=2的确认,知道了2还没有到,于是就马上重转2。然后,接收端收到了2,此时因为3,4,5都收到了,于是ack回6。示意图如下:
Fast Retransmit只解决了一个问题,就是timeout的问题,它依然面临一个艰难的选择,就是,是重传之前的一个还是重传所有的问题
。对于上面的示例来说,是重传#2呢还是重传#2,#3,#4,#5呢?因为发送端并不清楚这连续的3个ack(2)是谁传回来的?也许发送端发了20份数据,是#6,#10,#20传来的呢。这样,发送端很有可能要重传从2到20的这堆数据(这就是某些TCP的实际的实现)。可见,这是一把双刃剑。
另外一种更好的方式叫:Selective Acknowledgment (SACK)(参看RFC 2018),这种方式需要在TCP头里加一个SACK的东西,ACK还是Fast Retransmit的ACK,SACK则是汇报收到的数据碎版。参看下图:
这样,在发送端就可以根据回传的SACK来知道哪些数据到了,哪些没有到。于是就优化了Fast Retransmit的算法。当然,这个协议需要两边都支持
。在 Linux下,可以通过tcp_sack
参数打开这个功能(Linux 2.4后默认打开)。
这里还需要注意一个问题——接收方Reneging,所谓Reneging的意思就是接收方有权把已经报给发送端SACK里的数据给丢了
。这样干是不被鼓励的,因为这个事会把问题复杂化了,但是,接收方这么做可能会有些极端情况,比如要把内存给别的更重要的东西。所以,发送方也不能完全依赖SACK,还是要依赖ACK,并维护Time-Out,如果后续的ACK没有增长,那么还是要把SACK的东西重传,另外,接收端这边永远不能把SACK的包标记为Ack。
注意:SACK会消费发送方的资源,试想,如果一个攻击者给数据发送方发一堆SACK的选项,这会导致发送方开始要重传甚至遍历已经发出的数据,这会消耗很多发送端的资源。详细的东西请参看《TCP SACK的性能权衡》
Duplicate SACK又称D-SACK,其主要使用了SACK来告诉发送方有哪些数据被重复接收了
。RFC-2883 里有详细描述和示例。下面举几个例子(来源于RFC-2883)
D-SACK使用了SACK的第一个段来做标志,
示例一:ACK丢包
下面的示例中,丢了两个ACK,所以,发送端重传了第一个数据包(3000-3499),于是接收端发现重复收到,于是回了一个SACK=3000-3500,因为ACK都到了4000意味着收到了4000之前的所有数据,所以这个SACK就是D-SACK——旨在告诉发送端我收到了重复的数据,而且我们的发送端还知道,数据包没有丢,丢的是ACK包。
示例二,网络延误
下面的示例中,网络包(1000-1499)被网络给延误了,导致发送方没有收到ACK,而后面到达的三个包触发了“Fast Retransmit算法”,所以重传,但重传时,被延误的包又到了,所以,回了一个SACK=1000-1500,因为ACK已到了3000,所以,这个SACK是D-SACK——标识收到了重复的包。
这个案例下,发送端知道之前因为“Fast Retransmit算法”触发的重传不是因为发出去的包丢了,也不是因为回应的ACK包丢了,而是因为网络延时了。
Linux下的tcp_dsack
参数用于开启这个功能(Linux 2.4后默认打开)
从前面的TCP重传机制我们知道Timeout的设置对于重传非常重要。
而且,这个超时时间在不同的网络的情况下,根本没有办法设置一个死的值。只能动态地设置。
为了动态地设置,TCP引入了RTT——Round Trip Time
,也就是一个数据包从发出去到回来的时间
。这样发送端就大约知道需要多少的时间,从而可以方便地设置Timeout——RTO(Retransmission TimeOut)
,以让我们的重传机制更高效。
听起来似乎很简单,好像就是在发送端发包时记下t0,然后接收端再把这个ack回来时再记一个t1,于是RTT = t1 – t0。没那么简单,这只是一个采样,不能代表普遍情况。
RFC793 中定义的经典算法是这样的:
SRTT = ( α * SRTT ) + ((1- α) * RTT)
RTO = min [ UBOUND, max [ LBOUND, (β * SRTT) ] ]
其中:
- UBOUND是最大的timeout时间,上限值
- LBOUND是最小的timeout时间,下限值
- β 值一般在1.3到2.0之间
但是上面的这个算法在重传的时候会出有一个终极问题——你是用第一次发数据的时间和ack回来的时间做RTT样本值,还是用重传的时间和ACK回来的时间做RTT样本值?
这个问题无论你选那头都是按下葫芦起了瓢。 如下图所示:
忽略重传,不把重传的RTT做采样
(你看,你不需要去解决不存在的问题)。但是,这样一来,又会引发一个大BUG——如果在某一时间,网络闪动,突然变慢了,产生了比较大的延时,这个延时导致要重转所有的包(因为之前的RTO很小),于是,因为重转的不算,所以,RTO就不会被更新,这是一个灾难
。 于是Karn算法用了一个取巧的方式——只要一发生重传,就对现有的RTO值翻倍(这就是所谓的 Exponential backoff),很明显,这种死规矩对于一个需要估计比较准确的RTT也不靠谱。
前面两种算法用的都是“加权移动平均”,这种方法最大的毛病就是如果RTT有一个大的波动的话,很难被发现,因为被平滑掉了。所以,1988年,又有人推出来了一个新的算法,这个算法叫Jacobson / Karels Algorithm(参看RFC6289)。这个算法引入了最新的RTT的采样和平滑过的SRTT的差距做因子来计算。 公式如下:(其中的DevRTT是Deviation RTT的意思)
SRTT = SRTT + α (RTT – SRTT) —— 计算平滑RTT
DevRTT = (1-β)DevRTT + β(|RTT-SRTT|) ——计算平滑RTT和真实的差距(加权移动平均)
RTO= µ * SRTT + ∂ *DevRTT —— 神一样的公式
(其中:在Linux下,α = 0.125,β = 0.25, μ = 1,∂ = 4 ——这就是算法中的“调得一手好参数”,nobody knows why, it just works…) 最后的这个算法在被用在今天的TCP协议中(Linux的源代码在:tcp_rtt_estimator)。
上面我们知道了,TCP通过Sliding Window来做流控(Flow Control)
,但是TCP觉得这还不够,因为Sliding Window需要依赖于连接的发送端和接收端
,其并不知道网络中间发生了什么。
TCP的设计者觉得,一个伟大而牛逼的协议仅仅做到流控并不够,因为流控只是网络模型4层以上的事,TCP的还应该更聪明地知道整个网络上的事。
具体一点,我们知道TCP通过一个timer采样了RTT并计算RTO,但是,如果网络上的延时突然增加,那么,TCP对这个事做出的应对只有重传数据,但是,重传会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,于是,这个情况就会进入恶性循环被不断地放大。试想一下,如果一个网络内有成千上万的TCP连接都这么行事,那么马上就会形成“网络风暴”,TCP这个协议就会拖垮整个网络。这是一个灾难。
所以,TCP不能忽略网络上发生的事情,而无脑地一个劲地重发数据,对网络造成更大的伤害。对此TCP的设计理念是:
TCP不是一个自私的协议,当拥塞发生的时候,要做自我牺牲。就像交通阻塞一样,每个车都应该把路让出来,而不要再去抢路了
。
为了在发送端调节所要发送数据的量,定义了一个叫“拥塞窗口”的概念。于是在慢启动的时候,将这个拥塞窗口的大小设置为1个数据段发送数据,之后每收到一次确认(ACK)应答,拥塞窗口的值就加1。在发送数据包时,将拥塞窗口的大小与接收端主机通知的窗口大小做比较,然后按照它们当中较小的那个值,发送比其还要小的数据量。如果重发采用超时机制,那么拥塞窗口的初始值可以设置为1以后再进行慢启动修正。不过随着包的每次往返,拥塞窗口也会以1、2、4等指数函数的增长。
所以需要记住,拥塞窗口作用:
防止发送方发的太快,使得网络来不及处理,从而导致网络拥塞
关于拥塞控制的论文请参看《Congestion Avoidance and Control》(PDF)
拥塞控制主要是四个算法:
这四个算法不是一天都搞出来的,这个四算法的发展经历了很多时间,到今天都还在优化中。 备注:
首先,我们来看一下TCP的慢热启动。慢启动的意思是,刚刚加入网络的连接,一点一点地提速
,不要一上来就像那些特权车一样霸道地把路占满。新同学上高速还是要慢一点,不要把已经在高速上的秩序给搞乱了。
慢启动的算法如下(cwnd全称Congestion Window
):
所以,我们可以看到,如果网速很快的话,ACK也会返回得快,RTT也会短,那么,这个慢启动就一点也不慢。下图说明了这个过程。
这里,我需要提一下的是一篇Google的论文《An Argument for Increasing TCP’s Initial Congestion Window》Linux 3.0后采用了这篇论文的建议——把cwnd 初始化成了 10个MSS。 而Linux 3.0以前,比如2.6,Linux采用了RFC3390,cwnd是跟MSS的值来变的,如果MSS< 1095,则cwnd = 4;如果MSS>2190,则cwnd=2;其它情况下,则是3。参考 这里
前面说过,还有一个ssthresh(slow start threshold
),是一个上限,当cwnd >= ssthresh时,就会进入“拥塞避免算法”。一般来说ssthresh的值是65535,单位是字节,当cwnd达到这个值时后,算法如下:
这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。很明显,是一个线性上升的算法。
前面我们说过,当丢包的时候,会有两种情况:
1)等到RTO超时,重传数据包。TCP认为这种情况太糟糕,反应也很强烈。
2)Fast Retransmit算法,也就是在收到3个duplicate ACK时就开启重传,而不用等到RTO超时。
上面我们可以看到RTO超时后,sshthresh会变成cwnd的一半,这意味着,如果cwnd<=sshthresh时出现的丢包,那么TCP的sshthresh就会减了一半,然后等cwnd又很快地以指数级增涨爬到这个地方时,就会成慢慢的线性增涨。我们可以看到,TCP是怎么通过这种强烈地震荡快速而小心得找到网站流量的平衡点的。
这个算法定义在RFC5681。快速重传和快速恢复算法一般同时使用
。快速恢复算法是认为,你还有3个Duplicated Acks说明网络也不那么糟糕,所以没有必要像RTO超时那么强烈。 注意,正如前面所说,进入Fast Recovery之前,cwnd 和 sshthresh已被更新:
然后,真正的Fast Recovery算法如下:
如果你仔细思考一下上面的这个算法,你就会知道,上面这个算法也有问题,那就是——它依赖于3个重复的Acks
。注意,3个重复的Acks并不代表只丢了一个数据包,很有可能是丢了好多包。但这个算法只会重传一个,而剩下的那些包只能等到RTO超时,于是,进入了恶梦模式——超时一个窗口就减半一下,多个超时会超成TCP的传输速度呈级数下降,而且也不会触发Fast Recovery算法了。
最后以一系列图来说明:
主要是作为知识点的学习记录,方便以后回来查阅。
非常感激以下帖子,也请读者去学习