TCP和UDP相关知识
前言
学习掘金小册深入理解 TCP 协议:从原理到实战的笔记汇总第二部分
TCP&UDP对比总览
TCP
和UDP
都是传输层的协议,但二者具有不同的特性,也适用于不同的场景。如下表所示:
TCP |
UDP |
|
---|---|---|
可靠性 | 可靠 | 不可靠 |
连接性 | 面向连接 | 无连接 |
数据经过传输之后可能是无序的,TCP 协议会将这些无序的数据重新进行组装成有序的,供给上层调用者使用 |
无序 | |
报文 | 面向字节流 | 面向报文 |
效率 | 传输效率低 | 传输效率高 |
双工性 | 全双工 | 一对一、一对多、多对一、多对多 |
流量控制 | 滑动窗口 | 无 |
拥塞控制 | 慢开始、拥塞避免、快重传、快恢复 | 无 |
无,若是接收端缓存区足够大,可将发送端多次发送的数据一次性接收,然后传给上层应用 | 有,发送端发送一次,接收端就要接收一次,发送多少次就要接收多少次 | |
传输速度 | 慢 | 快 |
重量级,数据报报头大小为20个字节 | 轻量级,数据报报头大小为8个字节 | |
应用场景 | 对效率要求低、对可靠性要求高或者要求有连接的场景,如文件传输,邮件发送等 | 对效率要求高、对准确性要求低,如即时通信,QQ,视频通话等 |
基于连接 vs 无连接
TCP
是面向连接的协议,UDP
是无连接的协议。因而,当使用TCP
协议传输数据时,客户端与服务端之间必须通过三次握手来建立连接。
可靠性
TCP
提供交付保证,有许多机制用于保证消息的可靠性。
- 校验和:每个报文的报头部分都有一个校验和,防止在传输途中数据被损坏。如果收到一个校验和有差错的报文,
TCP
直接丢弃,不会进行确认; - 序列号:每个报文的报头部分都有一个序列号,借助于这个序列号,可以将不同报文中的数据重新按照顺序进行组装,从而保证消息的有序性;
- 超时重传:发送完消息之后,
TCP
会启动一个定时器,等待对端确认这个数据包。如果在指定时间内没有确认,则会进行重传,再等待一段时间,往复几次,直到重传次数超过一定次数之后,就会丢弃这个包; - 流量控制和拥塞控制:下面会详细介绍到。
有序性
TCP
协议会根据报头的序列号将传输过来的无序数据整理成有序的,而UDP
不会。
效率
TCP
比较慢,而UDP
比较快。因为TCP
必须要先建立连接,以保证消息的可靠性和有序性,需要进行的内部操作比UDP
多很多。TCP
适合大量数据的传输,UDP
适合少量数据的传输。
量级
TCP
是重量级协议,而UDP
是轻量级协议。一个TCP
数据报的报头至少为20个字节,UDP
数据报报头固定是8个字节。如下所示:
报文
TCP
是面向字节流的协议,无边界记录。而UDP
发送的每个数据是记录型的数据报,所谓记录型数据报就是接收进程可以识别到接收到的数据报的记录边界。
那么问题来了,TCP
提供了一种字节流服务,而收发双方都不保持记录的边界,应用程序应该如何提供他们自己的记录标识呢?
因为发送窗口(接收主机能够接收的数据量)、拥塞窗口(对网络拥塞的估计)、路径上的最大传输单元(传输的最大数据量)以及慢启动等等因素。所以不能确定TCP的分包个数与大小。为了解决这个问题,使用者可采取下面两种方式
- 定长报文,读取报文中的固定字节;
- 可以用结束标记(回车、换行)来分隔记录;
TCP
报头分析
TCP
(Transmission Control Protocol
传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP
报文段的报头有 10 个必需的字段和 1 个可选字段。报头至少为 20 字节。报头后面的数据是可选项。
源端口和目的端口
各占2个字节字节,分别是源端口号和目的端口号
序列号
4个字节,使用mod
计算,TCP
协议是面向字节流的,在TCP
连接中传输的字节流的每一个字节都是按照顺序编号的。
确认号
4个字节,表示期望收到对方下一个报文段的第一个数据字节的序号。若确认号为N
,则表示到序号N-1
为止的所有数据都已经确认收到。
数据偏移
4位,TCP
报文段的数据起始处距离TCP
报文段的起始处有多远,即首部长度。 由于 TCP
报头的长度随 TCP
选项字段内容的不同而变化,因此报头中包含一个指定报头字段的字段。TCP
报头最小20个字节,最大60个字节。
保留
6位,目前还未使用,待以后使用,目前都是0。
控制位
6位
URG
当URG=1
时表示紧急指针字段有效。这个时候发送方TCP
就会把紧急数据插入到本报文段数据的最前面,而在紧急数据后面的数据仍然是普通数据。
ACK
当ACK=1
时,确认字段有效。当ACK=0
,确认字段无效。**TCP
规定,在连接建立后所有传送的报文段都必须置为1。 **
PUSH
接收方TCP
收到PUSH=1
的报文段时,就会尽快交付给上层应用程序,而不是等到整个缓存区都填满了之后再交付。
RST
当RST=1
时,表明TCP
连接出现严重差错,必须释放连接,然后再重新建立连接。(先释放连接,再重新建立连接)。
SYN
在建立连接时用来同步序号。当SYN=1
,ACK=0
时,表明这是一个用来建立连接的请求报文。对方若是同意建立连接,则会回应一个SYN=1
,ACK=1
的报文。故SYN=1
,表明这是一个连接请求或者是连接接收报文。
FIN
用于释放连接。当FIN=1
时,表明发送方的数据已发送完毕,并要求释放连接。
窗口
2个字节,此字段用来进行流量控制,这个值是本机期望一次接收的字节数,告诉对方在不等待确认的情况下,可以发来多大的数据。这里表示的最大长度是2^16 - 1 =65535,如需要使用更大的窗口大小,需要使用选项中的窗口扩大因子选项。指发送本报文段的一方的接收窗口(而不是自己的发送窗口)。
校验和
源主机和目的主机根据TCP
报文段以及伪报头的内容计算校验和。伪报头中存放着来自IP
报头以及TCP
报文段长度信息。与UDP
一样,伪报头并不在网络中传输,并且在校验和中包含伪报头的目的是为了防止目的主机错误地接收存在路由的错误数据报。
伪首部
伪首部,又称伪报头:指在TCP
的分段或者是UDP
的数据报格式中,在数据报首部增加,源IP
地址,目的IP
地址,IP
分组协议字段,TCP
或者UDP
数据报的总长度,构成的扩展首部结构,共12个字节。伪首部是个临时结构,不向下也不向上传递,仅仅是为了保证校验套接字的正确性。
其数据结构如下所示:
//伪头部:用于TCP/UDP计算CheckSum
//填充字段值来自IP层
typedef struct tag_pseudo_header
{
u_int32_t source_address; //源IP地址
u_int32_t dest_address; //目的IP地址
u_int8_t placeholder; //必须置0,用于填充对齐
u_int8_t protocol; //协议号(IPPROTO_TCP=6,IPPROTO_UDP=17)
u_int16_t tcplength; //UDP/TCP头长度(不包含数据部分)
}PseudoHeader_S;
伪首部的结构示意图如下所示:
紧急指针
仅在 URG = 1
时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据),即指出了紧急数据的末尾在报文中的位置,注意:即使窗口为零时也可发送紧急数据。例如,**如果报文段的序号是 1000,前 8 个字节都是紧急数据,那么紧急指针就是 8 **。紧急指针一般用途是使用户可中止进程。
可选项
可选项的格式如下所示:
常用的可选项有以下几种:
timestamp
时间戳MSS
允许接收的最大报文段SACK
选择确认选项Window Scale
窗口缩放因子
可选项的长度可变,最长可达 40 字节,当没有使用可选项时,TCP 首部长度是 20 字节。
填充
用于保证选项大小为32位的整数倍。
数据
真正需要进行传输的数据。
UDP
报头分析
UDP
是 User Datagram Protocol
的简称, 中文名是用户数据报协议,是一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。
源端口
2个字节,发送方端口号
目的端口号
2个字节,接收方端口号
报文长度
2个字节,UDP
用户数据报的总长度,以字节为单位。
校验和
2个字节,检测UDP
用户数据报在传输过程中是否出错,出错直接丢弃。
伪首部
数据
真正需要传输的数据。需要注意的是,UDP
的数据部分如果不为偶数需要用 0 填补,就是说,如果数据长度为奇数,数据长度加“1”。
TCP 比 UDP 安全,为什么还要用UDP?
- 无需建立连接(延迟小);
- 无需维护连接状态;
- 头部开销小,固定8个字节;
- 应用层可以更好地控制要发送的数据和发送时间。
UDP为什么那么快?
- 不需要建立连接;
- 不需要进行确认;
- 没有超时重发机制;
- 没有流量控制和拥塞控制;
TCP 流量控制
TCP
协议其实是一个传输控制协议,顾名思义,就是用来传输数据的。如果从socket
角度来看TCP
的话,就是下面这样的:
发送端发送的数据放入发送缓存区Send Buffer
中,接收端要把接收到的数据放入接收缓存区Receive Buffer
中,上层应用程序们不停地塞取数据,完成数据的传输。
在这个传输过程中,有个很明显的问题,就是Send Buffer
和Receive Buffer
都是有限的,万一满了溢出了怎么办。这个时候就是流量控制派上用场的时候了。
流量控制做的事情就是,如果Receive Buffer
接收缓存区满了之后,发送端此时就要停止发送数据,以便接收端及时处理完已接收的数据。那发送端如何知晓接收端的缓冲区已满了呢?
为了控制发送端的发送速率,接收端会告知发送端自己的接收窗口大小,即Receive Buffer
接收缓存区中空闲的部分,如下所示:
接收端在每次接收完发送端发送的数据之后,都会应答一个ACK
报文,在这个报文中,接收端会告知发送端此时自己的接收窗口。
滑动窗口
TCP
中的窗口并不是固定的,由于报头、网络阻塞等影响,TCP
中窗口的大小一般会变化。这就是滑动窗口的由来,可以根据实际环境因素,动态地调整。滑动窗口分为两种,发送窗口和接收窗口。
数据包的状态
TCP
报文中的数据,发送端的数据根据状态不同可以分为以下四种:
- 1.已发送且已经被接收端
ACK
的数据包; - 2.已发送但尚未收到接收端
ACK
的数据包,如果在一段时间内都没有收到接收端的ACK
,则会进行重传; - 3.未发送但接收端表示有空间可以接收的数据包;
- 4.未发送且接收端没有空间进行接收的数据包;
发送窗口
发送窗口表示某个时刻,发送端能拥有的最大的可以发送但允许未被确认的数据包大小,也是发送端被允许发送的最大的数据包大小,就是上图中2与3的和。
可用窗口
可用窗口是指发送端还能发送的最大数据包大小,等于发送窗口的大小减去已发送但未被确认的数据包大小,即上图中3的部分,未发送但可以被接收端接收的数据包大小。
对于发送窗口来说,其左边界为成功发送且已经被接收方确认的最大字节序号,窗口的右边界是发送方当前可以发送的最大字节序号,滑动窗口的大小即为右边界减去左边界。
当上图的可用窗口的6个字节数据(46~51)发送出去,可用窗口大小就变成了0,这个时候除非接收到来自接收端的ACK
,否则发送端不再接收数据。
每次成功发送数据之后,发送窗口就会在发送缓存区中按顺序移动,将新的数据包含到窗口中准备发送。
接收窗口
TCP
报文中的数据,接收端的数据根据状态不同可以分为以下三种:
- 已接收并回复给发送端
ACK
的数据包; - 准备接收发送端发送的数据包;
- 尚未准备接收的缓存区;
当收到数据包后,将窗口向前移动一个位置,并发回ACK
,若收到的数据落在接收窗口之外,则一律丢弃。
流量控制过程
这里我们不用太复杂的例子,以一个最简单的来回来模拟一下流量控制的过程,方便大家理解。
首先双方三次握手,初始化各自的窗口大小,均为 200 个字节。
假如当前发送端给接收端发送 100 个字节,那么此时对于发送端而言,SND.NXT 当然要右移 100 个字节,也就是说当前的可用窗口
减少了 100 个字节,这很好理解。
现在这 100 个到达了接收端,被放到接收端的缓冲队列中。不过此时由于大量负载的原因,接收端处理不了这么多字节,只能处理 40 个字节,剩下的 60
个字节被留在了缓冲队列中。
注意了,此时接收端的情况是处理能力不够用啦,你发送端给我少发点,所以此时接收端的接收窗口应该缩小,具体来说,缩小 60 个字节,由 200 个字节变成了 140 字节,因为缓冲队列还有 60 个字节没被应用拿走。
因此,接收端会在 ACK 的报文首部带上缩小后的滑动窗口 140 字节,发送端对应地调整发送窗口的大小为 140 个字节。
此时对于发送端而言,已经发送且确认的部分增加 40 字节,也就是 SND.UNA 右移 40 个字节,同时发送窗口缩小为 140 个字节。
这也就是流量控制的过程。尽管回合再多,整个控制的过程和原理是一样的。
拥塞控制
前面说的流量控制一般发生在发送端和接收端之间,并没有考虑整个网络环境的影响。当网络特别差时,容易发生丢包现象,此时发送端就要进行一定的控制,防止过多的数据注入到网络中。这个时候,拥塞控制就派上用场了。
对于发送端来说,它需要维护两个状态变量,拥塞窗口(Congestion Window,cwnd
)和慢启动阈值(Slow Start Threshold
)。
拥塞窗口是指发生端目前还能传输的数据量大小。它与之前介绍的接收窗口有所不同:
- 接收窗口:接收端给的限制,用于限制接收窗口的大小;
- 拥塞窗口:发送端给的限制,用于限制发送窗口的大小;
在之前介绍发送窗口的时候,我们也提到过,发送窗口会根据接收端返回的ACK
信息进行调整,那么这两个窗口发送窗口(swnd
)呢?答案是取二者之中最小的那个,如下所示:
swnd=min(rwnd , cwnd)
拥塞控制就是用来控制拥塞窗口cwnd
的大小。拥塞控制过程中涉及到几种算法,分别是:
- 慢启动 & 拥塞避免
- 快重传 & 快恢复
慢启动
当发送端和接收端通过三次握手建立连接后,接下来就要开始传输数据了。由于发送端此时不知道现在的网络处于什么状况,如果一下子发送大量的数据,则可能会大量丢包。因此,拥塞控制首先采用一种名为慢启动的算法来试探网络的拥塞情况。主要原理就是,当主机开始发送数据时,由小到大逐渐增大拥塞窗口数值(即 发送窗口数值),从而逐渐增大发送报文段,流程如下:
- 首先,三次握手之后,发送端和接收端明确自己的接收窗口大小;
- 双方初始化自己的拥塞窗口大小,一般设置的比较小,如一个
MSS
; - 在最初开始传输的一段时间内,发送端每收到一个
ACK
,则将拥塞窗口大小加倍。就是说,每经过一个轮次,即RTT(route-trip time)
,拥塞窗口cwnd
加倍;
如下图所示:
需要注意的是,这里的慢并不是指传输慢或者是增长速率慢,而是指一开始发送报文段时拥塞窗口cwnd
设置的比较小,使得发送方在开始时只发送一个报文段,主要是试探一下网络的拥塞情况。
难道一直会一直翻倍下去?肯定不会啊,所以慢启动阈值就派上用场了。当拥塞窗口的大小达到这个阈值之后,拥塞避免就要开始上场了。
拥塞避免
拥塞避免算法可以使得拥塞窗口按线性规律缓慢增长。慢启动阶段每一个轮次拥塞窗口加倍,但到了拥塞避免阶段,拥塞窗口每一个轮次都只增加1,如下图所示:
需要注意的是,拥塞避免并不能避免拥塞,只是将拥塞窗口的增长减缓,减少网络当中的数据数量。
实例分析
借助于下图进行慢启动和拥塞避免的具体分析。
过程分析:
- 当拥塞窗口
cwnd
小于慢启动阈值ssthres
时,使用慢启动算法; - 当拥塞窗口
cwnd
大于慢启动阈值ssthres
时,停止使用慢启动,开始使用拥塞避免算法; - 当出现网络拥塞时,将慢启动阈值
ssthres
设置为出现拥塞时发送窗口的一半,但必须大于等于2;把拥塞窗口重新设置为1; - 此时,拥塞窗口
cwnd
小于慢启动阈值ssthres
,开始使用慢启动算法; - 当拥塞窗口
cwnd
大于慢启动阈值时,停止使用慢启动,开始使用拥塞避免算法;
这两种算法结合可以在出现网络拥塞时迅速减少主机发送到网络中的分组数,使得发送拥塞的路由器有足够的时间把队列中积压的分组处理完毕。
注
- 乘法减小:出现网络拥塞时,慢启动阈值设置为出现拥塞时发送窗口的一般;
- 加法增大:拥塞避免时的拥塞窗口缓慢增大
二者合并为AIMD算法,即加法增大,乘法减小。
快重传
在TCP
传输数据的过程,如果发生了丢包,即接收端发现数据段不是按序到达的,此时快重传就要上场了,以便及时提醒发送端存在丢失报文段的现象,提高整个网络的吞吐量。
原理:
- 接收方每收到一个失序的报文段,就立即发出重复确认,为了及时告知发送端有数据丢失,而不用等待到自己要发送数据时才进行确认;
- 发送方只有连续收到3个重复确认就立即重传对方尚未收到的报文段,而不必等到设置的重传计时器到期;
快重传的过程如下所示:
-
在传输的过程中,接收端在接收了
M1
、M2
后,紧接着收到了M4
,则M4
就是失序报文段; -
此时接收端就接着发送对
M2
的重复确认; -
接着
M5
,M6
又到达了接收端,则接收端继续发送两个对M2
的重复确认; -
至此,发送端收到四个对
M2
的确认,后三个都是重复确认; -
接着发送端就会立即重传接收端尚未收到的
M3
,而不必等到设定的重传计时器到期;
好了,上面说到如果出现丢失报文段,发送端就会进行重传,那么重传哪段呢?
选择性重传
在上面快重传的流程分析过程中,当M3
丢失之后,接收端会告知发送端进行重发,但重发哪一段呢?答案是只会重传丢失的那一段,如何实现呢?
接收端发送的重复确认的报文中,在报文首部中有个SACK
选项,通过left edge
和right edge
这两个值来告知发送端已经接收到了哪些区间的数据报。因此,即使M3
报文丢失了,当收到M4
、M5
、M6
后,接收端会告知发送端,这几个报文正常接收到了,剩下M2
这个报文没到,就只需要重传M3
这个报文即可。这个过程也称之为**选择性重传(SACK,Selective Acknowledgment)
,解决如何重传的问题。
快恢复
当发送端连续收到3个重复确认之后,发现出现丢包现象,觉得现在的网络已经有些拥塞,自己会进入快恢复阶段,如下所示:
流程如下:
TCP
连接刚刚建立,使用慢启动算法;- 拥塞窗口大于慢启动阈值,开始使用拥塞避免算法;
- 整个过程都是用快重传算法;收到三个连续的重复确认,开始执行快重传算法,即立即回传对方尚未收到的报文段;
- 执行快恢复算法,将慢启动阈值设置为拥塞时发送窗口的一半,拥塞窗口设置为此时的慢启动阈值大小,接着使用拥塞避免算法,使拥塞窗口缓慢线性增大;
注:快恢复算法中,慢启动的应用场景,TCP
连接建立和网络超时两种情况。
拥塞控制总结
快重传和快回复是对慢启动和拥塞避免的改进。
传输效率
流量控制过程中,也要考虑到传输效率的问题。
Nagle 算法
Negle
算法主要是为了避免网络中存在大量的小包(TCP
报头占比过大)造成拥塞。Negle
算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。Negle
算法要求一个TCP
连接上最多只有一个未被确认的未完成的小分组,在该组的确认未到达之前不能发送其他的小分组。
Negle
算法主要是为了提高网络的吞吐量,总结如下:
-
第一次发送数据时,不用管包大小,不用等待,直接发送;
-
后面满足下面的条件之一就可以继续发送:
-
之前所有的包都
ACK
-
数据包的大小达到最大段大小(
MSS
); -
等待一定的时间(一般为200
ms
); -
紧急发送;
-
糊涂窗口综合症
设想一种场景:当接收端的缓存已满,但是上层的应用程序每次只读取一个字节的数据,然后向发送端回应一个ACK
,并将接收窗口设置为1个字节。于是发生端接着发送了一个字节的数据过来,接收端继续确认,循环往复,致使传输的效率很低。
解决办法:让接收端等待一段时间,使其满足下面条件之一:
- 接收端缓存区有足够空间可以容纳一个最长的报文段;
- 接收端缓存区的一半大小;
只有满足其中一个条件,接收端就可以回应ACK
,向发送端通知自己当前的接收窗口大小。另外,发送端也不要发送太小的数据包,而是把数据积累成足够大的报文段,或者是达到接收端缓冲区一半大小。
延迟确认
试想这样一个场景,当我收到了发送端的一个包,然后在极短的时间内又接收到了第二个包,那我是一个个地回复,还是稍微等一下,把两个包的 ACK 合并后一起回复呢?
延迟确认(delayed ack)所做的事情,就是后者,稍稍延迟,然后合并 ACK,最后才回复给发送端。TCP 要求这个延迟的时延必须小于500ms,一般操作系统实现都不会超过200ms。
不过需要主要的是,有一些场景是不能延迟确认的,收到了就要马上回复:
- 接收到了大于一个 frame 的报文,且需要调整窗口大小;
- TCP 处于 quickack 模式(通过
tcp_in_quickack_mode
设置); - 发现了乱序包;
TCP中的Keep-alive
TCP
层面也是有keep-alive
机制,而且跟应用层不太一样。
试想一个场景,当有一方因为网络故障或者宕机导致连接失效,由于 TCP 并不是一个轮询的协议,在下一个数据包到达之前,对端对连接失效的情况是一无所知的。
这个时候就出现了 keep-alive, 它的作用就是探测对端的连接有没有失效。
在 Linux 下,可以这样查看相关的配置:
sudo sysctl -a | grep keepalive
// 每隔 7200 s 检测一次
net.ipv4.tcp_keepalive_time = 7200
// 一次最多重传 9 个包
net.ipv4.tcp_keepalive_probes = 9
// 每个包的间隔重传间隔 75 s
net.ipv4.tcp_keepalive_intvl = 75
不过,现状是大部分的应用并没有默认开启 TCP 的keep-alive
选项,为什么?
站在应用的角度:
7200s
也就是两个小时检测一次,时间太长- 时间再短一些,也难以体现其设计的初衷, 即检测长时间的死连接
因此是一个比较尴尬的设计。
TCP 粘包问题
由于TCP
连接是无边界的,这就导致数据在传输过程中出现粘包问题。什么是粘包?TCP
报文粘连是指,本来发送的多个TCP
报文,但是在接收端收到的却是一个报文,把多个报文合并成了一个。由于UDP
传输的报文是有边界的(两段消息间存在界限),所以其不会出现粘包现象。粘包有可能发生在发送端,也有可能发生在接收端。TCP
产生粘包的原因有两种,下面详细解释一下。
接收端问题
接收端会把接收到的数据存放在缓存区当中,然后通知上层应用去取数据。当应用层由于某些原因不能及时的把数据取走,则会造成接收缓存区存放了多条报文,由此产生报文粘连现象。
Negle 算法
上面介绍到的Negle
算法时说到,Negle
算法是为了解决网络中存在大量的小包(数据报首部占比过大),从而导致的网络拥塞。因此,当开启Negle
算法时,当有数据需要发送的时候,先不发送,而是稍微等待一会,看看在这一小段时间内,还有没有其他需要发送的数据,然后再把需要发送的数据一次性发送出去。这样虽然可以提高网络的吞吐量和利用率,但是当发送端缓存区存放着几条报文时,就有可能产生报文粘连的现象。
总结下来就是,发送端没有及时发送,接收端没有及时清除,这样才导致了粘包现象。
解决办法
- 关闭
Negle
算法,在SOCKET
选项中,TCP_NODELAY
表示关闭Negle
算法; - 上层应用尽快将缓存区中的数据读取使用;
- 在发送的数据中,添加标识符,标识着数据的开始和结束,当收到消息,通过这些标识来处理报文粘连;
TCP快速打开原理
TCP
每次建立连接的时候都要进行三次握手,很是麻烦,于是后面出现了TCP
握手流程的优化,即TCP
快速打开(TCP Fast Open
, TFO
)。
TFO 流程
首轮三次握手
首先客户端发送SYN
给服务端,服务端接收到。
注意哦!现在服务端不是立刻回复 SYN
+ ACK
,而是通过计算得到一个SYN Cookie
, 将这个Cookie
放到 TCP 报文的 Fast Open
选项中,然后才给客户端返回。
客户端拿到这个 Cookie
的值缓存下来。后面正常完成三次握手。
首轮三次握手就是这样的流程。而后面的三次握手就不一样啦!
后面的三次握手
在后面的三次握手中,客户端会将之前缓存的 Cookie
、SYN
和HTTP请求
(是的,你没看错)发送给服务端,服务端验证了 Cookie 的合法性,如果不合法直接丢弃;如果是合法的,那么就正常返回SYN
+ ACK
。
重点来了,现在服务端能向客户端发 HTTP 响应了!这是最显著的改变,三次握手还没建立,仅仅验证了 Cookie 的合法性,就可以返回 HTTP 响应了。
当然,客户端的ACK
还得正常传过来,不然怎么叫三次握手嘛。
流程如下:
注意: 客户端最后握手的 ACK
不一定要等到服务端的 HTTP
响应到达才发送,两个过程没有任何关系。
TFO 的优势
TFO
的优势并不在与首轮三次握手,而在于后面的握手,在拿到客户端的 Cookie
并验证通过以后,可以直接返回 HTTP
响应,充分利用了1 个RTT(Round-Trip Time
,往返时延)的时间提前进行数据传输,积累起来还是一个比较大的优势。
TCP报文中时间戳的作用?
timestamp
是 TCP 报文首部的一个可选项,一共占 10 个字节,格式如下:
kind(1 字节) + length(1 字节) + info(8 个字节)
其中 kind
= 8, length
= 10, info
有两部分构成: timestamp
和timestamp echo
,各占 4 个字节。
那么这些字段都是干嘛的呢?它们用来解决那些问题?
接下来我们就来一一梳理,TCP 的时间戳主要解决两大问题:
- 计算往返时延
RTT
(Round-Trip Time
) - 防止序列号的回绕问题
计算往返时延 RTT
在没有时间戳的时候,计算 RTT
会遇到的问题如下图所示:
如果以第一次发包为开始时间的话,就会出现左图的问题,RTT
明显偏大,开始时间应该采用第二次的;
如果以第二次发包为开始时间的话,就会导致右图的问题,RTT
明显偏小,开始时间应该采用第一次发包的。
实际上无论开始时间以第一次发包还是第二次发包为准,都是不准确的。
那这个时候引入时间戳就很好的解决了这个问题。
比如现在 a
向 b
发送一个报文 s1
,b
向 a
回复一个含 ACK
的报文 s2
那么:
- step 1:
a
向b
发送的时候,timestamp
中存放的内容就是a
主机发送时的内核时刻ta1
。 - step 2:
b
向a
回复s2
报文的时候,timestamp
中存放的是b
主机的时刻tb
,timestamp echo
字段为从s1
报文中解析出来的ta1
。 - step 3:
a
收到b
的s2
报文之后,此时a
主机的内核时刻是ta2
, 而在s2
报文中的timestamp echo
选项中可以得到ta1
, 也就是s2
对应的报文最初的发送时刻。然后直接采用ta2 - ta1
就得到了RTT
的值。
防止序列号回绕问题
现在我们来模拟一下这个问题。
序列号的范围其实是在0 ~ 2 ^ 32 - 1
, 为了方便演示,我们缩小一下这个区间,假设范围是 0 ~ 4
,那么到达 4 的时候会回到 0。
第几次发包 | 发送字节 | 对应序列号 | 状态 |
---|---|---|---|
1 | 0 ~ 1 | 0 ~ 1 | 成功接收 |
2 | 1 ~ 2 | 1 ~ 2 | 滞留在网络中 |
3 | 2 ~ 3 | 2 ~ 3 | 成功接收 |
4 | 3 ~ 4 | 3 ~ 4 | 成功接收 |
5 | 4 ~ 5 | 0 ~ 1 | 成功接收,序列号从0开始 |
6 | 5 ~ 6 | 1 ~ 2 | ??? |
假设在第 6 次的时候,之前还滞留在网路中的包回来了,那么就有两个序列号为1 ~ 2
的数据包了,怎么区分谁是谁呢?这个时候就产生了序列号回绕的问题。
那么用timestamp
就能很好地解决这个问题,因为每次发包的时候都是将发包机器当时的内核时间记录在报文中,那么两次发包序列号即使相同,时间戳也不可能相同,这样就能够区分开两个数据包了。
TCP 的超时重传时间是如何计算的?
TCP
具有超时重传机制,即间隔一段时间没有等到数据包的回复时,重传这个数据包。
那么这个重传间隔是如何来计算的呢?今天我们就来讨论一下这个问题。
这个重传间隔也叫做超时重传时间(Retransmission TimeOut
, 简称RTO
),它的计算跟上一节提到的 RTT
密切相关。这里我们将介绍两种主要的方法,一个是经典方法,一个是标准方法。
经典方法
经典方法引入了一个新的概念——SRTT
(Smoothed round trip time
,即平滑往返时间),每产生一次新的 RTT
就根据一定的算法对 SRTT
进行更新,具体而言,计算方式如下(SRTT
初始值为0):
SRTT = (α * SRTT) + ((1 - α) * RTT)
其中,α
是平滑因子,建议值是0.8
,范围是0.8 ~ 0.9
。
拿到 SRTT
,我们就可以计算 RTO
的值了:
RTO = min(ubound, max(lbound, β * SRTT))
β
是加权因子,一般为1.3 ~ 2.0
, lbound
是下界,ubound
是上界。
其实这个算法过程还是很简单的,但是也存在一定的局限,就是在 RTT
稳定的地方表现还可以,而在 RTT
变化较大的地方就不行了,因为平滑因子 α
的范围是0.8 ~ 0.9
, RTT
对于 RTO
的影响太小。
标准方法
为了解决经典方法对于 RTT
变化不敏感的问题,后面又引出了标准方法,也叫Jacobson / Karels 算法
。一共有三步。
第一步: 计算SRTT
,公式如下:
SRTT = (1 - α) * SRTT + α * RTT
注意这个时候的 α
跟经典方法中的α
取值不一样了,建议值是1/8
,也就是0.125
。
第二步: 计算RTTVAR
(round-trip time variation
)这个中间变量。
RTTVAR = (1 - β) * RTTVAR + β * (|RTT - SRTT|)
β
建议值为 0.25。这个值是这个算法中出彩的地方,也就是说,它记录了最新的 RTT
与当前 SRTT
之间的差值,给我们在后续感知到 RTT
的变化提供了抓手。
第三步: 计算最终的RTO
:
RTO = µ * SRTT + ∂ * RTTVAR
µ
建议值取1
, ∂
建议值取4
。
这个公式在 SRTT
的基础上加上了最新 RTT
与它的偏移,从而很好的感知了 RTT
的变化,这种算法下,RTO
与RTT
变化的差值关系更加密切。