《趣谈网络协议》读书笔记(五):TCP与UDP

概述

此文为极客时间趣谈网络协议第二模块第10讲至第12讲的的学习笔记。

主要内容包括传输层的两个重要协议 TCP 与 UDP 协议,以及 TCP 是如何建立稳定连接。

一、TCP与UDP的区别

1.定义

传输控制协议:(TCP,Transmission Control Protocol)是为了在不可靠的互联网络上提供可靠的端到端字节流而专门设计的一个传输协议

用户数据报协议:(UDP,User Datagram Protocol)是一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。

2.区别

就其特性而言,TCP 是面向连接的,UDP 是面向无连接的,更直白的说,TCP 是有状态的,UDP 是无状态的。

TCP 在连接之前,会进行三次握手以建立连接,这里的建立连接,是为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性。

基于连接状态,TCP 有了与 UDP 最大的区别,即连 TCP 提供可靠的数据传输。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。而 UDP 继承了 IP 包的特性,与 ICMP 协议报文一样,他的包是没有任何可靠性保证的,一旦发出去,是不保证不丢失,不保证按顺序到达。

二、UDP协议

1.UDP头结构

根据前文,我们知道从网络层到数据链路层会封装 IP 和 MAC 包,发出去的包先拆 MAC 包再拆 IP 包,都对上以后再拆就会拆传输层的包,也就是 UDP 或者 TCP 包。

在 IP 头中有一个 8 位的协议,会标明是 UDP 包还是 TCP 包,接着拆完包以后,操作系统内核的任务就完成了,会根据包上的端口去转发给监听端口的应用程序。

img

2.特点与使用场景

UPD 的特点是结构简单,不会根据网络情况进行发包的拥塞控制,并且任何应用程序都可以去接受它。

因此,基于以上特点,UPD 适合用于一些传输资源小、对丢包情况不敏感,要求传输速度快,同时网络情况好的场景使用,或者用于广播或者多播。

比较典型的例子是前文提到过的 DHCP 协议,因为一般 DHCP 协议分配 IP 都是在内网发起的请求,请求的包也很小。

3.扩展协议

由于 HTTP 协议是基于 TCP 的,TCP 为了保证可靠传输而设置的各种重连和拥塞策略会比较耗时间,对于一些互联网应用来说,一般会基于传输较快的 UDP 协议进行扩展。

比如谷歌提出的 QUIC 协议,他是基于 UDP 协议扩展的引用层协议,在原基础上实现了快速建立连接,自适应塞控制等功能。

还有不少的流媒体协议也是基于 UDP 扩展的,因为原本基于 TCP 的 RTMP 协议,在丢包的情况下,严格的顺序控制会导致直播时的卡顿,因此对于比较看重实时性的直播来说,显然还是 UDP 比较符合条件。

此外,还有游戏,物联网,移动通信等领域,对于比较看重实时性的应用场景,UDP 要比 TCP 应用范围广一些。

三、TCP协议

为了保证安全可靠的数据传输,TCP 的头结构要复杂的多,除了端口号以外,他还有以下结构:

  • 为了保证顺序,它还有包的序号,以及接受方的确认序号;
  • 为了建立可靠连接,它还有如 SYN,ACK 等标志位,用于表示连接状态;
  • 为了做流量控制,它有窗口大小;

此外,为了因为各种网络情况,它还有一套拥塞策略。

img

四、TCP三次握手

1.什么是三次握手

TCP 需要通过三次请求来建立两台机器的连接,我们成为三次握手。直白的描述这个过程,就是:

  1. A:你好,我是 A,你收到了吗?
  2. B:你好 A,我是 B,我收到了你的消息,你收到我的了吗?
  3. A:你好 B,我收到了你的确认。

为什么需要三次,而不是两次或者四次?我们从机器的角度来说,要建立连接,需要确认客户端与服务器端都处于就绪状态,确保都能接受消息,第一次 A 请求 B,B 接受到了,B 知道 A 要跟他建立连接,于是第二次 B 请求 A ,于是表明自己已经就绪,A 知道 B 就绪了,于是第三次 A 请求 B,表面自己也就绪了。

也就是说,客户端和服务器端都要对方就绪,并且确保对方知道自己就绪。

如果没有三次握手会怎样?

如果是只有两次握手,那么当 B 向 A 表明自己已经就绪的包是没法确保送达的,B 就无法确认 A 到底真要跟自己建立连接,可能包丢了,也可能 A 挂了,甚至可能因为延迟,A 和 B 已经建立联系了,这个请求又莫名其妙到了一脸懵逼的 A 那里。

如果是四次或者更多呢?首先,要明确的是,三次握手是不能确保就连接一定通畅的,因为和有可能在第三次的时候 A 给 B 的包丢了,但是一般建立链接以后很快还会发送数据的,这个时候可以默认已经建立成功了,如果后续 A 跟 B 发送消息没响应,或者 B 发现 A 长时间没动静,也可以通过其他探活机制去结束连接或者重试。针对这种情况,就算加了第四第五次也没什么区别,因为永远无法保证最后一次响应是必定成功的,因此三次共用即可。

2.三次握手过程的状态机变化

根据前文,我们 TCP 头存在标志位,而 SYN(同步,SYNCHRONISATION) 为建立连接,ACK(确认,ACKNOWLEDGEMENT) 为确认请求。

为了维护这个连接,客户端与服务器端都需要维护一个状态机。

img

整个过程如下:

  • 一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态。

  • 第一次握手:然后客户端主动发起连接 SYN,之后处于 SYN-SENT 状态。

  • 第二次握手:服务端收到发起的连接,返回 SYN,并且 ACK 客户端的 SYN,之后处于 SYN-RCVD 状态。

  • 第三层握手:客户端收到服务端发送的 SYN 和 ACK 之后,发送 ACK 的 ACK,之后处于 ESTABLISHED(成立)状态,因为它一发一收成功了。

    服务端收到 ACK 的 ACK 之后,处于 ESTABLISHED 状态,因为它也一发一收了。

3.三次握手的顺序问题

三次握手处理建立连接外,还为了确定发包的顺序。

首先,每一个连接都会有一个序号,这个序号开始的顺序是不一样的,如果都从1开始,第二次连接可能会因为受到第一次连接的相同序号的包而出问题。为此,序号有32位,每 4微秒+1,大概4小时左右加到满,再清零重头开始,称为一个 ISN 周期。

五、TCP四次挥手

1.什么是四次挥手

TCP 建立连接需要三次握手,而断开连接需要四次挥手。直白的描述这个过程,就是:

  1. A:B,我不玩了,你知道了吗?
  2. B:好的,我知道了
  3. B:A,你不玩,那我也不玩了,你知道了吗?
  4. A:好的,我也知道了

实际上,我们不难看出,对于 A 来说,当 B 响应以后,它就可以认为连接已经关闭了。实际上这个状态称为半关闭状态,即 B 还可以给 A 发送数据,但是 A 可以选择接受或者不接受数据。

由于 B 只是确认了 A 断开连接的申请,它可能还有一些事情没做完,对于 A 来说,他不知道 B 什么时候才能弄完,甚至有可能 B 响应完就挂了,对于 B 来说,它也不能确认 A 到底要不要接受它最后的数据。

针对这个情况,最好是双方都能确认对方要中断连接,并且也能确定对方知道自己要中断连接

相对三次挥手的一问一答,四次挥手是你问我答。

2.四次挥手过程中的状态机变化

同三次挥手一样,四次挥手也需要双方共同维护一个状态机。

img

流程是这样的:

  1. 第一次挥手:客户端进入 FIN_WAIT_1 状态,发起一个 FIN 请求;
  2. 第二次挥手:客户端收到以后,响应一个 ACK 请求,并进入 CLOSE_WAIT 状态;
  3. 第三次挥手:服务器端发起一个 FIN 请求,并进入 LAST_ACK 状态;
  4. 第四次挥手:客户端接收到请求以后,响应一个 ACK,并进入 TIME_WAIT 状态,并等待一段时间
  5. B 收到 ACK,进入 CLOSED 状态,A 过了最后等待时间,也进入 CLOSED 状态。彻底断开连接。

3.为什么客户端需要 TIME_WAIT 状态?

这里比较值得注意的是第四次挥手时的 TIME_WAIT 状态,在这个阶段,如果 B 没有收到 A 给 B 响应的 ACK ,就会重发,而 A 的等待时间至少要住够 B 重发,然后 A 就会再次发送一个 ACK。

实际上,这个等待时间是 2MSL ,MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。这么做是为了保证在 A 断开连接前,B 发送的数据包彻底完蛋,否则如果在这个过程 A 原先使用的端口号被另一个应用占了,就可能会接受到 B 最后发送的数据包,虽然有序号保证,但是还是以防万一。

还有一个异常情况就是,B 超过了 2MSL 的时间,依然没有收到它发的 FIN 的 ACK,怎么办呢?按照 TCP 的原理,B 当然还会重发 FIN,这个时候 A 再收到这个包之后,A 就表示,我已经在这里等了这么长时间了,已经仁至义尽了,之后的我就都不认了,于是就直接发送 RST,B 就知道 A 早就跑了。

4.TCP 状态机

img

六、TCP的滑动窗口

1.什么是滑动窗口

由于实际的网络传输肯定是有延迟的,所以 TCP 协议不可能严格按顺序一个一个处理包。而是会根据包划分批次,一次应答某个包的 ID,表示在它之前包都收到了,这种模式有点像 mysql 中日志的组提交,称为累积确认

为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别都有缓存来保存这些记录。发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分:

  1. 已经发送并且确认的;
  2. 已经发送但是未确认的;
  3. 等待发送的;
  4. 暂时不会发送的;

在 TCP 里,接收端会给发送端报一个窗口的大小,叫 Advertised window(广播窗口)。这个窗口的大小应该等于上面的第二部分加上第三部分,就是已经发生但是未确认的加上等待发送的。超过这个窗口的,接收端做不过来,就不能发送了。

于是,发送端需要保持下面的数据结构:

img

对于接收端来讲,它的缓存里记录的内容要简单一些:

  1. 接收并且确认过的;
  2. 马上要接收的;
  3. 不能接收的;
img

这里的 MaxRcvBuffer 指最大缓存量。

AdvertisedWindow 其实是 MaxRcvBuffer 减去 A。也就是:AdvertisedWindow = MaxRcvBuffer - ( (NextByteExpected-1) - LastByteRead)

那第二部分和第三部分的分界线在哪里呢?NextByteExpected 加 AdvertisedWindow 就是第二部分和第三部分的分界线,其实也就是 LastByteRead 加上 MaxRcvBuffer。其中第二部分里面,由于受到的包可能不是顺序的,会出现空档,只有和第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了。

2.顺序与丢包问题

根据刚才的图,可以看到:

  • 服务器端:1、2、3 已经发送并确认;4、5、6、7、8、9 都是发送了还没确认;10、11、12 是还没发出的;13、14、15 是接收方没有空间,不准备发的
  • 客户端:1、2、3、4、5 是已经完成 ACK,但是没读取的;6、7 是等待接收的;8、9 是已经接收,但是没有 ACK 的

也就是说:

  • 1、2、3 都是确认无误的;
  • 4、5 客户端收到了,但是服务器端没收到响应;
  • 6、7、8、9 服务器端发送了,但是客户端只收到了 8、9

3.自适应重传算法

针对以上因为丢包导致的顺序错乱问题,可以使用自适应重传算法解决。

假设 4 的确认到了,不幸的是,5 的 ACK 丢了,6、7 的数据包丢了,这该怎么办呢?

一种方法就是超时重试,也即对每一个发送了,但是没有 ACK 的包,都有设一个定时器,超过了一定的时间,就重新尝试。但是这个时间不宜过短,时间必须大于往返时间 RTT,否则会引起不必要的重传。也不宜过长,这样超时时间变长,访问就变慢了。

估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还是要不断变化的,因为网络状况不断地变化。除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。由于重传时间是不断变化的,我们称为自适应重传算法(Adaptive Retransmission Algorithm)。

如果过一段时间,5、6、7 都超时了,就会重新发送。接收方发现 5 原来接收过,于是丢弃 5;6 收到了,发送 ACK,要求下一个是 7,7 不幸又丢了。当 7 再次超时的时候,有需要重传的时候,TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送

重复 ACK 快速重传

有一个可以快速重传的机制,当接收方收到一个序号大于下一个所期望的报文段时,就会检测到数据流中的一个间隔,于是它就会发送冗余的 ACK,仍然 ACK 的是期望接收的报文段。而当客户端收到三个冗余的 ACK 后,就会在定时器过期之前,重传丢失的报文段。

例如,接收方发现 6 收到了,8 也收到了,但是 7 还没来,那肯定是丢了,于是发送 6 的 ACK,要求下一个是 7。接下来,收到后续的包,仍然发送 6 的 ACK,要求下一个是 7。当客户端收到 3 个重复 ACK,就会发现 7 的确丢了,不等超时,马上重发。

SACK

还有一种方式称为 Selective Acknowledgment (SACK)。这种方式需要在 TCP 头里加一个 SACK 的东西,可以将缓存的地图发送给发送方。例如可以发送 ACK6、SACK8、SACK9,有了地图,发送方一下子就能看出来是 7 丢了。

4.流量控制

我们再来看流量控制机制,在对于包的确认中,同时会携带一个窗口的大小。我们先假设窗口不变的情况,窗口始终为 9。4 的确认来的时候,会右移一个,这个时候第 13 个包也可以发送了。

img

这个时候,假设发送端发送过猛,会将第三部分的 10、11、12、13 全部发送完毕,之后就停止发送了,未发送可发送部分为 0。

img

当对于包 5 的确认到达的时候,在客户端相当于窗口再滑动了一格,这个时候,才可以有更多的包可以发送了,例如第 14 个包才可以发送。

img

如果接收方实在处理的太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为 0,则发送方将暂时停止发送。我们假设一个极端情况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不能再是 9 了,就要缩小一个变为 8。

img

这个新的窗口 8 通过 6 的确认消息到达发送端的时候,你会发现窗口没有平行右移,而是仅仅左面的边右移了,窗口的大小从 9 改成了 8。

img

如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到为 0。

img

当这个窗口通过包 14 的确认到达发送端的时候,发送端的窗口也调整为 0,停止发送。

img

如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止低能窗口综合征,别空出一个字节来就赶快告诉发送方,然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口。

5.拥塞控制

最后,我们看一下拥塞控制的问题,也是通过窗口的大小来控制的,前面的滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。

这里有一个公式 LastByteSent - LastByteAcked <= min {cwnd, rwnd} ,是拥塞窗口和滑动窗口共同控制发送的速度。

那发送方怎么判断网络是不是慢呢?这其实是个挺难的事情,因为对于 TCP 协议来讲,他压根不知道整个网络路径都会经历什么,对他来讲就是一个黑盒。TCP 发送包常被比喻为往一个水管里面灌水,而 TCP 的拥塞控制就是在不堵塞,不丢包的情况下,尽量发挥带宽。

水管有粗细,网络有带宽,也即每秒钟能够发送多少数据;水管有长度,端到端有时延。在理想状态下,水管里面水的量 = 水管粗细 x 水管长度。对于到网络上,通道的容量 = 带宽 × 往返延迟。

如果我们设置发送窗口,使得发送但未确认的包为为通道的容量,就能够撑满整个管道

img

如图所示,假设往返时间为 8s,去 4s,回 4s,每秒发送一个包,每个包 1024byte。已经过去了 8s,则 8 个包都发出去了,其中前 4 个包已经到达接收端,但是 ACK 还没有返回,不能算发送成功。5-8 后四个包还在路上,还没被接收。这个时候,整个管道正好撑满,在发送端,已发送未确认的为 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以来回时间 8s。

如果我们在这个基础上再调大窗口,使得单位时间内更多的包可以发送,会出现什么现象呢?

我们来想,原来发送一个包,从一端到达另一端,假设一共经过四个设备,每个设备处理一个包时间耗费 1s,所以到达另一端需要耗费 4s,如果发送的更加快速,则单位时间内,会有更多的包到达这些中间设备,这些设备还是只能每秒处理一个包的话,多出来的包就会被丢弃,这是我们不想看到的。

这个时候,我们可以想其他的办法,例如这个四个设备本来每秒处理一个包,但是我们在这些设备上加缓存,处理不过来的在队列里面排着,这样包就不会丢失,但是缺点是会增加时延,这个缓存的包,4s 肯定到达不了接收端了,如果时延达到一定程度,就会超时重传,也是我们不想看到的。

于是 TCP 的拥塞控制主要来避免两种现象,包丢失和超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?

如果我们通过漏斗往瓶子里灌水,我们就知道,不能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢的倒,然后发现总能够倒进去,就可以越倒越快。这叫作慢启动

一条 TCP 连接开始,cwnd 设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。可以看出这是指数性的增长。

涨到什么时候是个头呢?有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。

每收到一个确认后,cwnd 增加 1/cwnd,我们接着上面的过程来,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。

但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。

拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。这真是一旦超时重传,马上回到解放前。但是这种方式太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿。

前面我们讲过快速重传算法。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。

0%