目录
- 目录
- 1. TCP 包头格式
- 2. TCP 的三次握手
- 3. TCP 的四次挥手
- 4. TCP 状态机
- 5. TCP 如何保证传输可靠
- 6. 顺序问题与丢包问题
- 7. 流量控制问题
- 8. 拥塞控制问题
- 9. 小结
- 参考文章
1. TCP 包头格式
- 源端口号、目标端口号
- 包的序号、确认序号
- 状态位:
SYN
发起连接、ACK
回复、RST
重新连接、FIN
结束连接 - 窗口大小:用于流量控制、拥塞控制
2. TCP 的三次握手
TCP 的连接建立,我们称之为“三次握手”,即“请求 -> 应答 -> 应答之应答”。
三次握手除了确保双方建立连接以外,主要还为了沟通 TCP 包的序号问题。
A 要告诉 B,自己发起的包的序号起始是从哪个号开始的,B 同样也要将自己的起始序号告诉 A。为了确保互相包的序号不发生冲突,TCP 的每个连接都要有不同的序号,这个序号的起始序号是随着时间变化的
当双方终于建立了信任,建立了连接之后,为了维护这个连接,双方都要维护一个状态机。在连接建立的过程中,双方的状态变化时序图如下所示:
- 一开始,客户端和服务端都处于
CLOSED
状态 - 先是服务端主动监听某个端口,处于
LISTEN
状态 - 然后客户端主动发起连接
SYN
,之后处于SYN-SENT
状态 - 服务端收到发起的连接,返回
SYN
,并且ACK
客户端的SYN
,之后处于SYN-RCVD
状态 - 客户端收到服务端发送的
SYN
和ACK
之后,发送ACK
的ACK
,之后处于ESTABLISHED
状态,因为客户端一发一收已经成功了 - 最后,服务端收到
ACK
的ACK
之后,处于ESTABLISHED
状态,因为它也一发一收成功了
3. TCP 的四次挥手
类似的,TCP 在断开连接时,也需要进行四次挥手,如下图所示:
- 断开的时候,当 A 说 “不玩了”,就进入了
FIN_WAIT_1
状态 - B 收到 A “不玩了” 的消息后,发送
ACK
,就进入CLOSE_WAIT
状态 - A 收到 B 的
ACK
后,就进入FIN_WAIT_2
状态。这时如果 B 直接跑路,则 A 将永远停留在这个状态。可以在 Linux 中调整tcp_fin_timeout
这个参数,设置一个超时时间 - 如果 B 没有跑路,发送 “B 也不玩了” 的请求到达 A 时,A 发送 “知道 B 也不玩了” 的
ACK
后,从FIN_WAIT_2
状态结束,进入TIME_WAIT
状态,等待时间为2MSL
(Maximum Segment Lifetime,报文最大生存时间) - 最后还有一个异常情况就是,B 超过了
2MSL
的时间,仍然没有收到它发的FIN
的ACK
,按照 TCP 的原理,B 就会重发这个FIN
,只不过当 A 再收到这个包之后,A 就直接发送RST
回复给 B,这时候 B 就会知道 A 早就跑了
4. TCP 状态机
5. TCP 如何保证传输可靠
TCP 协议为了保证包的顺序性,每一个包都有一个 ID。在建立连接的时候,会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但这个应答并不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式称为累计确认或者累计应答(cumulative acknowledgment)。
为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别都有缓存来保存这些记录。发送端里的缓存是按照包的 ID 一个个排列的,根据处理的情况分成四个部分:
- 发送了并且已经确认的
- 发送了并且尚未确认的
- 没有发送,但是已经等待发送的
- 没有发送,并且暂时还不会发送的
在 TCP 里,接收端会给发送端报一个窗口的大小,叫Advertised Window
。它的大小应该等于上面的第二部分加上第三部分,即已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。
为此,发送端需要保持下面的数据结构:
LastByteAcked
:「已发送已确认」的最后一个字节LastByteSent
:「已发送未确认」的最后一个字节AdvertisedWindow
:黑框部分,「已发送未确认」+「未发送可发送」
对于接收端来讲,其缓存里的记录和内容要更简单一些:
- 接收并确认过的
- 还没接收,但是马上就能接收的
- 还没接收,也没法接收的,即超过窗口的部分
对应缓存的数据结构如下图所示:
LastByteRead
:之后是已经接收了,但是还没被应用层读取的NextByteExpected
:第一部分和第二部分的分界线MaxRcvBuffer
:最大缓存的量,图中黑框部分
第二部分窗口大小
AdvertisedWindow
即为MaxRcvBuffer
减去第一部分「接收已确认」的大小
6. 顺序问题与丢包问题
在 TCP 传输的过程中,顺序问题与丢包问题都可能发生:有些包可能丢了,有些包可能还在路上。还有些可能已经到了,还是因为出现了乱序,所以只能先缓存着但是没办法ACK
。
为了解决这些问题,TCP 有一套确认与重发的机制。
假设
4
的确认收到了,不幸的是,5
的ACK
丢了,6
、7
的数据包丢了,这种时候该怎么办?
6.1 超时重试
一种解决方法就是超时重试,即对每一个发送了但是没有收到ACK
的包,都设置一个定时器,超过一定时间必须重新尝试。这个时间必须大于往返时间 RTT,否则会引起不必要的重传,也不宜过长,导致访问变慢。
6.2 自适应重传算法
估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值。由于重传时间是不断变化的,我们称之为自适应重传算法(Adaptive Retransmission Algorithm)。
6.3 超时间隔加倍
当一个包再次超时,又需要重传的时候,TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
6.4 快速重传
TCP 还有一个可以快速重传的机制:当接收方收到一个序号大于下一个所期望的报文段时,就检测到了数据流中的一个间格,于是发送三个冗余的ACK
。客户端收到后,就在定时器过期之前,重传丢失的报文段。
6.5 SACK
还有一种方式称为 Selective Acknowledgement(SACK)。这种方式需要在 TCP 头里加上SACK
,可以将缓存的地图发送给发送方。例如可以发送ACK6
、SACK8
、SACK9
,有了地图,发送方就可以看出是序号为7
的包丢失了。
7. 流量控制问题
TCP 还有流量控制机制,在接收方对于包的确认中,同时会携带一个窗口的大小。
7.1 窗口不变的情况
先假设窗口不变的情况,窗口始终为9
。4
的确认来的时候,会右移一格,这时候第13
个包也可以发送了:
这个时候,假设发送端发送过猛,会将第三部分的10
、11
、12
、13
全部发送完毕。由于已发送未确认的包已经占满整个窗口,因此之后就停止发送了,未发送可发送的部分变为0
:
当对于包5
的确认到达时,在客户端相当于窗口再滑动了一格,这个时候,第14
个包就可以发送了:
如果接收方实在处理的太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为
0
,则发送方将暂时停止发送
7.2 窗口变化的情况
再假设一个极端情况:接收端的应用一直不读取缓存中的数据,当数据包6
确认后,窗口大小就要缩小一个变为8
:
当新的窗口大小8
通过6
的确认消息到达发送端之后,发送端的窗口就不会再右移,而是仅仅左边的边右移,窗口大小也从9
变成了8
:
如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口也会越来越小,直到为0
:
当这个窗口通过包14
的确认到达发送端的时候,发送端的窗口也调整为0
,停止发送:
除了被动接收窗口信息之外,发送方也会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止底能窗口综合征:可以当窗口太小的时候就停止更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口
8. 拥塞控制问题
最后来谈一下 TCP 的拥塞控制问题,也是通过窗口的大小来控制的。前面流量控制的滑动窗口rwnd
(Receive Window,接收窗口)是怕发送方把接收方缓存塞满,而拥塞窗口cwnd
(Congestion Window,拥塞窗口)是怕把网络塞满。
TCP 的发送速度由拥塞窗口和滑动窗口共同控制:
swnd = LastByteSent - LastByteAcked <= min {cwnd, rwnd}
对于 TCP 协议来讲,整个网络路径就是一个黑盒。TCP 发送包常被比喻为往一个水管里面灌水,而 TCP 的拥塞控制就是在不堵塞、不丢包的情况下,尽量发挥带宽
如果设置发送窗口swnd
,使得发送但未确认的包为通道的容量,就能撑满整个管道:
TCP 的拥塞控制主要用来避免两种现象:包丢失和超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。
- 一条 TCP 连接开始,
cwnd
设置为 1 个报文段,一次只能发送一个 - 当收到这一个确认的时候,
cwnd
加 1,于是一次能够发送两个 - 当这两个的确认到来的时候,两个确认
cwnd
加 2,于是一次能够发送四个 - 当这四个的确认到来的时候,四个确认
cwnd
加 4,于是一次能够发送八个
这个过程称为 TCP 的慢启动阶段
可以看出在慢启动阶段,cwnd
呈指数性的增长。但当swnd
超过阈值ssthresh
(默认为 65535 字节,即当swnd
为 16 时刚好超过),就要改为线性增长,即每收到一个确认后,cwnd
增加1/cwnd
:
这个过程称为 TCP 的拥塞避免阶段
还是看上图,当cwnd
为 20 时,出现了拥塞(例如丢包,需要超时重传),这个时候,需要将ssthresh
设为cwnd/2
即 10,将cwnd
设为 1,重新开始慢启动。下图是另外一个例子:
虽然这种方式避免了拥塞,但是大大降低了原本处于高速状态的传输速度,还可能造成网络卡顿。
之前提到,TCP 还有一个快速重传算法:当接收端发现丢了一个中间包的时候,发送三次前一个包的ACK
,于是发送端就会快速的重传,不必等待超时再重传,这时cwnd
减半变为cwnd/2
,然后再令ssthresh=cwnd
,当三个包返回时,cwnd=sshthresh+3
,继续维持线性增长(即图中的橙线部分)。
正是 TCP 这种知进退的特点,使得在时延很重要的情况下,反而降低了速度。但其实,TCP 的拥塞控制主要来避免的两个现象都是有问题的:
- 丢包并不代表着通道满了,例如公网上带宽不满也会丢包,这个时候就认为拥塞了、退缩了,其实是不对的
- TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这个时候已经晚了,其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满
为了优化这两个问题,后来有了 TCP BBR 拥塞算法(Bottleneck Bandwidth and Round-trip propagation time,由 Google 设计,于 2016 年发布)。它企图找到一个平衡点,就是通过不断的加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样会导致时延增加,在这个平衡点可以很好的达到高带宽和低时延的平衡
9. 小结
- TCP 的 顺序问题、丢包问题、流量控制问题都是通过滑动窗口来解决的
- 拥塞控制是通过拥塞窗口
cwnd
来解决的