浅谈网络协议:TCP 篇

587 阅读22分钟

这是我参与更文挑战的第 8 天,活动详情查看: 更文挑战

TCP 连接

三次握手

说一说 TCP 三次握手的过程?

  1. 第一次握手:客户端发包,同步标志位 SYN = 1,序号 seq = x
  2. 第二次握手:服务端收包,看到 SYN = 1,知道客户端要和自己建立 TCP 连接;服务端发包,同步标志位 SYN = 1,序号 seq = y,确认标志位 ACK = 1,确认号 ack = x + 1(表示自己希望下一次收到客户端发过来的是 x + 1)
  3. 第三次握手:客户端收包,看到 SYN = 1,知道服务端要和自己建立 TCP 连接,检查 ACK 是否为 1,ack 是否为 x + 1;客户端发包,确认标志位 ACK = 1,确认号 ack = y + 1(表示自己希望下一次收到服务端发过来的是 y + 1),seq = x + 1
  4. 服务端收包,确认 ACK = 1,确认 seq = x + 1,双方成功建立 TCP 连接

为什么是三次握手,而不是两次或者四次?

从确保双端收发能力正常的角度理解,==三次握手是能让客户端和服务端确信自己和对方的收发能力正常所需的最少次数==:

  • 第一次握手:服务端确认客户端的发送能力正常,确认自己的接收能力正常
  • 第二次握手:客户端确认服务端的发送能力正常,确认自己的接受能力正常;此外,也能确认自己的发送能力和服务端的接收能力正常,因为任意一个能力不正常,服务端就都不可能接收自己初次发送的信息并给出回应
  • 第三次握手:服务端确认自己的发送能力和客户端的接收能力正常,因为任意一个能力不正常,客户端就都不可能接收自己初次响应的信息并给出回应

所以,如果是两次握手,那么服务端是无法确认自己的发送能力和客户端的接收能力的;而如果是四次握手,其实也可以,但是没有必要,因为三次握手就可以保证双方收发能力正常了。

四次挥手

说一说 TCP 四次挥手的过程?

一开始,双方都处于 established 状态

  1. 第一次挥手:客户端发送一个连接释放报文段(FIN=1.seq = u),表示自己已经不再发送数据了,打算主动关闭 TCP 连接。之后进入 FIN_WAIT1 状态。
  2. 第二次挥手:服务端收到,回应一个确认报文段(ACK = 1,ack = u + 1,seq = v),表示自己已经收到客户端的报文了。之后进入 CLOSE_WAIT 状态。客户端收到,之后进入 FIN_WAIT2 状态
  3. 第三次挥手:第二次挥手,服务端只是告诉客户端自己收到了它的报文,但此时的服务端可能还有数据没发送完,所以不会马上提出释放连接。一段时间后,服务端才会发送一个连接释放报文段(FIN = 1,seq = w,ACK = 1,ack = u + 1),表示自己也已经不再发送数据了,也打算被动关闭 TCP 连接。之后进入 LAST_ACK 状态。
  4. 第四次挥手:客户端收到,回应一个确认报文段(ACK = 1,ack = w + 1,seq = u + 1),表示自己已经收到服务端的报文了。之后进入 TIME_WAIT 状态,等待 2MSL 的时间后再进入 CLOSED 状态,彻底关闭 TCP 连接;而服务端在收到 ACK 之后,就直接进入 CLOSED 状态,关闭 TCP 连接了

为什么握手只需要三次,挥手却需要四次?

本质上是因为 TCP 的半关闭机制:客户端主动关闭 TCP 连接的动作是单向的,只是说自己不再发送数据给服务端,但除非服务端也关闭 TCP 连接,否则服务端依然可以发送数据给客户端去接收。

三次握手之所以只需要三次,是因为服务端在第一次响应中,可以将 ACK 和 SYN 一并发送给客户端,一方面对客户端的 SYN 做一个确认,另一方面做一个同步,表示自己也想要建立 TCP 连接,==注意这两件事完全可以在一次响应中同时完成,无需分开==,这里就节省了一次握手了;四次挥手之所以需要四次,是因为客户端单方面想要断开连接时,服务端这边的数据可能还没发送完,这样,服务端就只能先发一个 ACK 对客户端的 FIN 做一个确认,然后等到自己的数据全部发送完的时候,再发一个 FIN 断开连接,==注意这两件事是不可以在一次响应中同时完成的,根据实际数据传输情况,不得不分开,==因此不可避免地需要多一次挥手。

客户端最后发送 ACK 之后,为什么需要等待一段时间才 CLOSED?这段时间为什么是 2MSL?

简而言之就是:==客户端必须做好准备去处理可能出现的 FIN 超时重传==。

一定要清楚一件事:客户端最后一次发送的 ACK 是可能丢失的,我们假设 ACK 确实丢失了:

  • 假如客户端最后发送 ACK 之后,直接就 CLOSED。因为 ACK 丢失了,服务端就收不到 ACK,就会以为是自己这边的 FIN 有问题,于是进行 FIN 的超时重传,但由于客户端已经 CLOSED,根本就无法收到这个超时重传的 FIN,自然也就无法发回一个 ACK,服务端这边收不到 ACK,就迟迟无法 CLOSED 断开连接;
  • 但是,假如客户端最后发送 ACK 之后,会等待一段时间再 CLOSED。那么服务端因为收不到 ACK 而超时重传的 FIN 是有机会到达客户端这边的,客户端就会重发 ACK,服务端收到 ACK 之后也就自然可以 CLOSED 断开连接。

到这里,我们就知道了,客户端必须在发送 ACK 之后等待一段时间才能 CLOSED。但是,为何这个时间是 2MSL 呢?

MSL 指的是报文在网络中的最长寿命,也是从一端到另一端所需的最长时间。

两个原因:

  • 客户端发送的 ACK 到达服务端,最多需要 1MSL 的时间,而服务端超时重传的 FIN 到达客户端,同样最多需要 1MSL 的时间,这就是说,如果服务端真的因为收不到 ACK 而需要超时重传 FIN,那么一定会在这 2MSL 的时间内做这件事 —— 换句话说,客户端就专门在 2MSL 的时间里等待,没有收到超时重传,说明自己的 ACK 确实到达了服务端,那么自己不用做什么,时间过了 CLOSED 就行;收到了超时重传,说明自己的 ACK 出了问题,那么自己就要重发 ACK,同时重新开始进行 2MSL 的计时。

    无论如何,客户端都必须等待 2MSL 的时间,以做好准备去处理可能出现的 FIN 超时重传,没出现,就安心等时间过去,出现了,自己就去处理,就这么简单。

  • 2 MSL 可以确保此次连接中产生的报文耗光“寿命”,不会跑到下一次的 TCP 连接中,对其产生影响

TCP Fast Open

TCP Fast Open(TFO)即 TCP 快速打开,客户端和服务端通过首轮三次握手中交换的 Cookie,从而实现在后续的握手中,一边握手一边发送数据。

首轮三次握手:

  • 客户端发送 SYN 报文,该报文包含 Fast Open 选项,且 Cookie 为空,表示客户端请求一个 TFO Cookie
  • 服务端响应 ACK + SYN 报文,该报文包含 Fast Open 选项,且携带生成的 TFO Cookie
  • 客户端收到 TFO Cookie,进行缓存

之后的三次握手:

  • 客户端发送 SYN + TFO Cookie + 要发送的数据
  • 服务端进行 Cookie 校验,确认没问题之后(合法、没有过期),响应 ACK + SYN + 数据给客户端
  • 客户端发送 ACK 进行确认

可以注意到,普通的 TCP 连接,数据交换需要在三次握手结束之后,而 TFO 可以做到在三次握手还没完全结束的时候,让客户端发送数据,服务端响应数据。

TCP keepalive

在 HTTP 中有一个 keep-alive 机制,它的目的是实现 TCP 的持久连接,建立连接后不轻易断开,从而让多轮请求-响应复用同一个 TCP 连接,而不是每轮请求-响应都需要重建一个 TCP 连接。而在 TCP 中也有一个 keepalive 机制(注意没有横线),这个和 HTTP 的 keep-alive 是不一样的。

简单地说,HTTP keep-alive 是一个保活机制,而 TCP keepalive 是一个保活探测机制

一个 TCP 连接并不是每时每刻都在传输数据,可能有一段时间是没有数据交互的。那么在这段时间里,双方都不知道对端的情况,可能对端发生了死机、重启、崩溃,甚至是中间网络意外断开,但自己却还保持着这个 TCP 连接没有释放,浪费了资源。所以,我们需要借助一种机制探测对端情况,以及时地释放 TCP 连接 —— 这就是 TCP keepalive。

具体地说,发送端在接收到 ACK 之后,会开启一个保活定时器,并等待一段时间(保活时间 tcp_keepalive_time)。如果过了这段时间还没发生数据交互,那么发送端就会发送一个保活探测报文,相当于问接收端 “这么长时间没有你的消息了,你是不是出事了?” 这时候有两种情况:

  • 接收端对此回应了一个 ACK,则说明对端和中间网络都是正常的,没有什么意外发生,只是对端没有发送数据而已,这时候就重置保活定时器;
  • 接收端没有任何回应,就说明对端或者中间网络可能发生了意外。则发送端在一定的时间间隔(探测时间间隔 tcp_keepalive_intvl)之后会再次发送保活探测报文,直到收到响应为止。若达到了探测循环次数上限(tcp_keepalive_probes)还没有收到响应,则说明对端或者中间网络确实发生了意外,此时发送端会认为对端是不可达的,也就没有必要继续维持这个 TCP 连接了,于是就会主动释放 TCP 连接。

再次重申,HTTP keep-alive 和 TCP keepalive 的目的是完全不同的,前者是为了让 TCP 保活好进行复用,后者是探测对端或者中间网络的情况,决定是否释放连接。

重传机制

TCP 为了保证数据的可靠传输,在数据包丢失的时候会利用重传机制重新发送一次数据包

超时重传

如果接收端确实收到了来自发送端的数据包,那么接收端应该相应地返回一个 ACK,表示自己期待下一次接收到哪个数据包。超时重传是依靠时间去驱动重传的,发送端每发送一个数据包之后,都会开启一个针对这个数据包的定时器,等待一个 RTO(Retansmission Time Out,超时重传时间,是动态变化的)的时间,若过了这个时间还没收到接收端的 ACK,那么就会重传数据包。注意这里应该有两种情况:

  • 发送端的数据包在传输途中丢失,没有到达接收端,所以接收端不会返回一个 ACK
  • 发送端的数据包到达了接收端,接收端也返回了一个 ACK,但是 ACK 在传输途中丢失

但是不管真实是哪一种情况,对于发送端来说,它都是没有收到 ACK 的,都是要进行数据包重传的。

超时重传机制存在两个明显的问题:

  • 因为是时间驱动的,所以必须得等待一个 RTO 之后才会进行数据包重传,而这个 RTO 可能会很长,这样就会导致数据包长时间无法重传到达接收端
  • 接收端只会对最大的连续收到的数据包给出 ACK,不能跳着给出 ACK。比如说发送端发送 123456,3 丢失了,而 12456 到达接收端,那么接收端只会发出 ACK = 3 而不是 ACK = 7。这里的问题其实在于,接收端明明已经拿到了 456,但是没有给出对应的 ACK,所以发送端就会误以为 456 也丢失了,就可能导致 456 的重传

快速重传

快速重传是基于数据驱动重传的,发送端并不需要得到计时结束再去重传数据包。以下图为例,发送端发送 12345,2 丢失了。

  • 接收端:对于 1,接收端首先会给出正常的 ACK = 2,表示自己期望下一次收到的是 2,而对于之后陆续到达的 345,接收端还会给出三次冗余的 ACK = 2。
  • 发送端:发现自己接收到一次正常的 ACK = 2 之后,又收到了三次冗余的 ACK = 2,就知道 2 丢失了,于是重传 2。

快速重传解决了等待 timeout 的问题,但是它和超时重传一样,无法做到单独重传丢失了的数据包,而是将该数据包和之后陆续发送的数据包一起重传(因为发送端并不清楚具体丢失了多少个数据包,可能认为后面的数据包都丢失了)

选择性重传之 SACK

针对上述的问题,出现了选择性重传。接收端给发送端的报文中可以携带一个 SACK,用于告知发送端,哪些数据包收到了,哪些数据包丢失了,这样,发送端就可以针对性地单独重传丢失了的数据包。

选择性重传之 D-SACK

D-SACK 即 duplicate SACK,它是 SACK 的升级版本,可以让发送端更精细地把握网络传输情况,了解哪些数据发重复了,以及发生重传的具体原因。

1)发送端可以知道,发生超时重传是因为自己的报文丢失,还是因为接收端的 ACK 丢失:

假设接收端返回的 ACK 丢失,那么发送端就不会收到 ACK,就会发生超时重传机制,导致接收端收到两次重复报文,它会回应一个 SACK 报文告知发送端:“我收到了两次重复报文,内容都是 xxx”,发送端就可以知道此次超时重传是因为接收端的 ACK 丢失,问题不是出在自己这里。

2)发送端可以知道,发生快速重传是因为自己的报文丢失,还是因为接收端的 ACK 丢失,还是因为网络延迟:

假设发送端的报文因为网络延迟一直没有到达接收端,那么接收端会回应三次相同的 ACK,发送端重传报文。后来的某个时刻,延迟的报文到达接收端,导致接收端收到两次重复报文,它就会回应一个 SACK 报文告知发送端:“我收到了两次重复报文,内容都是 xxx”,发送端就可以知道此次快速重传不是因为自己的报文丢失了(否则接收端不可能收到重复报文),也不是因为接收端的 ACK 丢失了,而是因为网络延迟。

流量控制

为什么需要流量控制?

发送端应该发得快还是慢,发得多还是少,不应该由自己决定,而是要看接收端的意见。毕竟发送端发送得再快再多,如果超出了接收端可以承受的范围,也是不行的。为此,TCP 提供了一种机制可以让发送端根据接收端的实际接收能力,动态地调整自己的发送能力,也就是所谓的流量控制。具体地说,发送端通过发送窗口控制自己的发送能力,接收端通过接收窗口控制自己的接收能力,接收端将自己的接收窗口大小告知发送端,从而让发送端调整自己的发送窗口大小,控制传输的流量。

滑动窗口机制

流量控制的实现需要依靠滑动窗口机制。以发送端为例:

  • 第一部分是已经发送并且得到接收端确认的数据
  • 第二部分是窗口,包括已经发送但是没有得到确认的数据,以及没有发送但是可以发送的数据
  • 第三部分是没有发送并且目前还不可以发送的数据

在数据传输的过程中,窗口是向右移动的。举个例子,假如现在发送端收到了对于 27、28 的确认,那么窗口就会右移两个格子,将 27、28 放进第一部分,同时腾出空间,将 41、42 放进窗口。

案例了解流量控制过程

假设有下面的场景:

  • 开始时,接收端通知发送端自己的接收窗口大小是 400 字节,于是发送端调整自己的发送窗口大小为 400 字节
  • 发送端发送 1-100,101-200,这两个现在属于窗口中已经发送的但是还没有得到确认的,因此窗口用去了 200 字节,还剩下 200 字节
  • 发送端发送 201-300,但是丢失了,不过还是会占用窗口大小,因此现在窗口还剩下 100 字节
  • 接收端收到数据,进行累计确认,因为只收到了 1-100,101-200,所以 ack = 201,表示期望下一次收到 201-300。同时告知发送端,需要调整发送窗口大小为 300 字节
  • 发送端收到数据,1-100,101-200 这两个得到了确认,所以窗口右移,腾出了 200 字节,不过因为接收端缩小了窗口大小,所以现在总共只有 300 字节可用,并且有 100 字节已经被占用(丢失的 201-300)
  • 发送端发送 301-400,401-500,发送窗口没有可用空间了
  • 在过了一个 RTO 的时间之后,发送端这边还是没有收到对于 201-300 的确认(ack = 301),由于超时重传机制,发送端会重传一次 201-300。这个新的 201-300 和旧的 201-300 会共用发送窗口的同一片空间
  • 接收端收到数据,进行累计确认,因为从 1-500 都收到了,所以可以给出 ack = 501。同时,再次缩小发送窗口至 100 字节
  • 发送端收到数据,三个都得到了确认,所以窗口右移 300 字节,但是由于接收端要求缩小窗口,所以现在只有 100 字节可用
  • 发送端发送 501-600,发送窗口没有可用空间了
  • 接收端收到数据,进行确认,同时第三次缩小窗口至 0,0 的意思就是告知发送端不要再发送新数据了
  • 发送端窗口右移 100 字节,但由于窗口大小已经变成 0,所以不能再发送数据了

如何解决死锁问题?

接收端调整发送窗口至 0 的时候,就可以认为这一轮数据传输结束了。后面如果想要开始新一轮的数据传输,接收端可以再发送一个非 0 窗口通知报文,通知发送端扩大窗口来发送数据。

但是,当这个非 0 窗口通知报文丢失的时候,那么就会陷入一种死锁的局面:发送端等待接收端发送改变窗口大小的通知报文,接收端等待发送端发送数据。要打破死锁,可以采取一种类似轮询的机制:

  • 发送端接收到 0 窗口报文后,开启一个定时器,若计时结束还没有收到非 0 窗口通知报文,那么就会主动发出一个探测报文,询问接收端
  • 接收端返回自己当前的窗口值,相当于重传了一次非 0 窗口值
  • 当然,这次重传的窗口值可能还是 0,如果是这样,那么发送端会再次开启定时器,重复上面的过程,直到拿到一个非 0 窗口值。

拥塞控制

流量控制 VS 拥塞控制

为什么有了流量控制之后,还需要拥塞控制呢?其实这是两个不一样的东西:

  • 流量控制是针对端到端的,发送端需要根据接收端的接收能力动态调整自己的发送能力,防止数据传输过程流量太大,导致接收端无法承受;
  • 拥塞控制针对的是整个端系统,发送端需要根据网络拥塞情况动态调整自己的发送能力,防止一下子发送过多数据,加重网络阻塞情况

如果只实现了流量控制,那么只能确保双端收发能力匹配,而无法确保网络流畅 —— 在网络不流畅、拥塞的时候,就算接收端的接受能力再好,数据在传输的过程中也会发生丢包和延迟,而拥塞控制就是来解决这个问题的,因此它也是必不可少的。

拥塞控制的算法

发送端通过发送窗口的大小来体现自己的发送能力,从上面的描述不难看出,发送窗口的大小不应该只由接收窗口的大小来决定,还需要考虑拥塞窗口的大小。事实上,发送窗口 = Min ( 接收窗口,拥塞窗口 ) 。

那么拥塞窗口本身应该是多大呢?它的大小应该根据拥塞控制算法,视网络情况动态调整。

慢开始 + 拥塞避免:

PS:这里不考虑接受窗口大小,因此拥塞窗口大小直接决定发送窗口大小,即发送端的发送能力。

  • 慢开始会以一个很小的拥塞窗口值(这里是 1)起步,并在下一轮传输中(一轮即一个来回)将拥塞窗口值翻倍,因此在数据传输的初期,发送端发送的数据是比较多的
  • 慢开始实际上是一个指数级增长的过程,若不加控制,那么拥塞窗口会越来越大,发送的数据也会越来越多,导致网络很快陷入拥塞。因此这里会定义一个 ssthresh 作为慢开始的阈值,一旦达到这个阈值,就改用拥塞避免算法 —— 在下一轮传输中不再将拥塞窗口翻倍,而仅仅是加一,这样就不会那么快导致网络拥塞
  • 采用拥塞避免算法只是避免网络迅速陷入拥塞,但未来的某个时刻,它必然还是会陷入拥塞的,这时候,拥塞窗口的值会迅速降至慢开始的起步值 1
  • 接着又使用慢开始算法进行指数级增长,只不过这次的阈值要更小,它等于网络陷入拥塞时的拥塞窗口值的一半 —— 这其实是一个慢慢适应的过程,因为第一次的阈值可能太高了,所以这次设置得相对低一点,可能就不会那么快让网络陷入拥塞
  • 接着又改用拥塞避免算法,继续线性增长
  • …… 重复上面的过程

快速重传 + 快速恢复:

这个和上面的算法其实很像,一开始同样是经历慢开始和拥塞避免的过程,不同之处在于:

  • 某个时刻达到网络拥塞之后,会执行快速重传算法。这是因为,网络拥塞的时候,丢包的概率是很大的,这时候还依赖超时重传算法就不行了,我们要改用更快的快速重传算法,“挽救”那些丢失的数据包
  • 之后,不再是直接降至慢开始的起步值 1,而是只降到网络达到拥塞时的拥塞窗口值的一半,可以看作是省去了指数级增长这一步,直接从新的阈值开始进行线性增长。这个就是所谓的快速恢复,快就快在少了指数级增长这一步,直接跳到阈值处。

PS:早期使用的是 TCP Tahoe 版本,它是有指数级增长这个过程的,但它的问题在于 ,每次网络陷入拥塞导致丢包的时候,拥塞窗口值都会降至 1,相当于重走一遍拥塞窗口进行适应的过程,非常不利于数据的稳定传输;因此,后面都改用了 TCP Reno 版本。