详解TCP协议 | 青训营笔记

557 阅读1小时+

这是我参与「第三届青训营 -后端场」笔记创作活动的第6篇笔记

1、可靠数据传输协议

传输层是在“以网络层IP协议为底层”的基础上传输数据的,而网络层不保证数据的可靠传输。

1、基于“停-等”机制

停等机制的思想是,发送方先发送一个分组,等待接收方的响应,然后才能继续发送分组。这种方式保证分组不会乱序到达。

需要考虑这些问题:

  • 分组内部可能出现数据错误

    • 产生原因:可能由于各种原因,造成到达的数据流与实际发送的不一致
    • 解决方案:使用校验和进行差错检测,接收方接收到数据后,就向发送方反馈信息是否有误(ACK/NCK)
  • 接收方可能收到重复分组

    • 产生原因:

      • 如果出现差错,接收方会向发送方反馈“错误信息”,发送方会重传分组。
      • 发送方的控制信息也可能出现错误,所以也有校验和机制。如果控制信息出错,发送方就直接重传,但可能原本的控制信息是ACK,就导致接收方收到了重复分组
    • 解决方案:

      • 为每个分组添加唯一标识,接收方收到重复的分组就直接丢弃
      • 有了唯一标识之后,可以弃用NCK,只需要使用ACK+最后一个有效的分组编号,同样可以让发送方知晓应该发送哪个分组
  • 分组在传输途中可能丢失

    • 产生原因:可能网络发生了拥塞,或者其他各种原因,导致该分组没有正确到达接收方
    • 解决方案:在接收方维护一个计时器,在规定时间内没有收到接收方的反馈,就重传该分组,这叫超时重传

2、基于“流水线”机制

流水线机制允许发送方连续发送多个分组,这产生了两个问题:

  • 需要更大的序列号范围,才能唯一标识每个分组。因为连续发送的多个分组都有可能出现问题。
  • 接收方需要缓存发出的分组,直到它被确认收到

接收方收到一个分组就返回一个ACK,重复和乱序到达的分组直接丢弃

3、基于“滑动窗口”机制

窗口指的是“允许使用的序列号的范围”,等待确认的消息个数不能超过窗口的宽度。

绿色:已经发送成功(接收方响应ACK成功)

黄色:已经发出,还没确认

蓝色:即将发出,但还没发出的

白色:待使用的序列号

滑动窗口协议产生的序列号是连续的,避免序列号重复导致分组混乱。这样接收方就能实现知道下一个分组的序列号,实现对重复分组或乱序分组的处理。

对乱序分组的两种做法

收到了乱序分组,可以直接丢弃,响应正确收到的最后一个分组序列号,让发送方重新发送。这样可以对一群分组进行统一确认,减少控制消息发送次数,减少控制消息发生错误的可能,进而避免额外的分组重传浪费资源。但丢弃掉正确但乱序的分组,本身也浪费资源

也可以选择不丢弃,而是缓存起来。这就需要:

  • 接收方对每个分组进行单独确认
  • 接收方也维护一个滑动窗口,才能知道哪些分组还没有被正确接收

4、总结

实现可靠数据传输,最好的实践是滑动窗口+流水线机制。

它的分组至少需要包含以下信息:

  • 校验和,用于判断分组内部数据是否出现错误
  • 序列号,用于标识一个分组,有两个作用:判断是否重复、判断是否乱序到达
  • 滑动窗口信息

2、TCP概述

1、TCP的头部格式

  • 序列号:

    • 在建立连接时,发起连接的一方生成一个随机数作为序列号的初始值,通过SYN报文发给接收方
    • 每发送一次数据,序列号就累加一次
    • 作用:解决数据包的乱序问题、重复问题
  • 确认应答号:

    • 接收端会填入它上次收到的序列号+1的值,含义是期望收到的下一个数据包的序列号的值
    • 发送端收到这个应答号,含义是这个序列号之前的序列号对应的数据包都被正确接收,接收端需要这个序列号的数据包
    • 作用:解决数据包的丢包问题
  • 控制位

    • ACK:为1时,表示确认应答号字段有效。TCP规定除了建立连接时的SYN包之外,通信过程中这个位必须是1
    • RST:为1时,表示TCP连接异常,必须强制断开连接
    • SYN:为1时,表示申请建立连接,并且使用这个数据包的序列号作为初始序列号
    • FIN:为1时,表示申请断开连接。

2、TCP的特点

TCP是一个面向连接的、可靠的、基于字节流的全双工传输层协议。

  • 面向连接:一个发送方、一个接收方,一对一发送消息
  • 可靠:能保证一个数据包最终一定能正确到达接收方
  • 有序的字节流:不限制消息的大小,而且确保接收方最终收到的消息是有序的
  • 全双工:同一个连接中能够双向传输数据流

3、TCP工作在哪一层

网络层的IP协议是不可靠的,它不保证数据包的可靠交付,也不保证数据包交付的顺序,而且不会对数据包做差错检测。

如果要保证可靠交付,就需要利用IP协议上层的传输层TCP协议,由TCP来决定要发送哪个数据包,然后交由IP协议进行发送即可。

3、关于TCP连接

1、什么是TCP连接

建立一个TCP连接,需要通信双方达成三个共识:

  • Socket:四元组,包括IP地址和端口号
  • 序列号:用来解决重复、乱序问题
  • 窗口大小:用来做流量控制

2、如何唯一确定一个TCP连接

TCP连接四元组:

  • 源IP地址、源端口号
  • 目的IP地址、目的端口号

其中,源IP、目标IP用于封装IP头部,通过IP协议把数据包发给接收方

源端口、目的端口用于封装TCP头部,作用是告诉通信双方应该把数据包交给哪个进程

3、一个端口的最大连接数

服务器通常是固定IP、固定监听某个端口的。

所以理论上,它能建立的连接数 = 客户端IP个数 * 客户端端口个数

  • IPV4最多有2的32次方个
  • 端口号最多有2的16次方个,因为TCP协议的端口号字段有16位

但是实际上服务器并不能为所有IP地址和所有端口建立TCP连接,原因有两个:

  • 操作系统的文件描述符限制

    • 每个TCP连接都被看做一个文件,占用一个文件描述符
    • 文件描述符是有上限的,如果文件描述符被占满了,会报错说too many open files
  • 服务器的内存限制

    • 每个TCP连接都会占用一定的内存
    • 服务器的内存是有限的,达到上限会OOM

4、TCP与UDP

1、UDP的特点

UDP协议唯一的功能就是,利用IP协议做到数据传输。它是面向无连接的、不保证可靠的。

2、UDP的头部格式

  • 源端口、目标端口:用于指定数据包交付给哪个进程
  • 校验和:能标识当前UDP包是否出现错误
  • 包长度:UDP头部+数据的总长度

1、为什么UDP头部没有“头部长度”字段

因为TCP头部有可变长的选项字段,所以TCP头部的长度不是固定的,需要标识出来才能知道数据从哪开始

UDP头部是固定的8个字节,长度不会变化,所以不用额外记录长度

2、为什么TCP头部没有“包长度”字段

包长度字段主要是为了计算出数据长度。知道了包长度,也知道了头部长度,就能知道数据长度。

知道了数据的起始点,知道了数据的长度,也就知道了具体的数据内容。

TCP计算出数据长度的方式:

  • TCP数据长度 = IP数据长度 - IP头部长度 - TCP头部长度
  • 其中的IP数据长度 和 IP头部长度,都记录在了IP头部中。

UDP其实也能通过这个公式计算出数据长度,但还有一个考量:

  • 网络设备为了处理方便,要求头部长度是4字节的整数倍
  • 可能是为了把UDP头部凑成4字节的倍数,引入了一个冗余的包长度字段

3、TCP和UDP的区别

  • 连接方面:

    • TCP是面向连接的,每次通信之前要先建立连接
    • UDP是面向无连接的
  • 通信方面:

    • TCP是一对一的双方通信
    • UDP支持一对一、一对多、多对多
  • 可靠性:

    • TCP是可靠交付数据的,保证数据不出错、不丢失、不重复、不乱序
    • UDP是尽力交付,不保证可靠
  • 拥塞控制、流量控制:

    • TCP具有拥塞控制、流量控制机制
    • UDP没有这些机制,即使网络非常拥堵,UDP也感受不到,继续正常发送数据
  • 头部开销:

    • TCP首部较长,默认是20字节,如果使用了选项字段,头部会变得更长,开销较大
    • UDP首部较短,只有8个字节,固定不变,开销较小
  • 传输方式

    • TCP是流式传输,没有边界
    • UDP是一个包一个包传输,所以有边界
  • 数据分段

    • TCP的数据如果超过MSS大小,会在传输层分片。如果某个分片丢失,只需要重传丢失的分片
    • UDP的数据不会在传输层分片,会直接交给IP层。数据如果超过MTU,会在IP层分片,后续如果丢失就丢失了。
  • 扩展性:

    • TCP是写死在Linux内核中的,不好修改,它规定了很多功能,应用层也不好扩展,只能去使用它的特性
    • UDP可以在应用层去实现一些特性,这是可插拔的,扩展性非常好,因为应用层的限制是很小的,很轻易可以实现跨平台

4、TCP和UDP的使用场景

TCP的特点是面向连接、可靠数据交付,常用于:

  • HTTP、HTTPS协议
  • FTP文件传输

UDP的特点是轻量化、简单高效、没有流量控制和拥塞控制,常用于:

  • 要求延迟低的场景,比如视频、语音
  • 包总量较少的通信,因为没必要为了几个包去花费建立连接的开销
  • 广播通信
  • TCP功能无法满足的场景,需要在应用层去扩展UDP

对于一个特定的场景,有两个选择依据:

  • 是否允许少量丢包,如果不允许那一般就使用TCP,也可以在应用层去给UDP实现可靠数据传输。

  • 数据包的大小,如果数据包小于MTU,那就可以用UDP,否则建议使用TCP

    因为TCP会做分段工作,它会保证每个TCP段都小于MTU。而UDP会直接把数据包交给IP协议进行分片,IP对数据分片和组装的效率很低。

5、深入研究三次握手

1、双方需要交换哪些信息

一次连接需要这些信息:

  • Socket:

    • 通信双方的IP、端口号,用于基本的通信。也叫TCP四元组,能唯一确定一个连接。
    • 源IP和目的IP在IP头部中,用于找到主机。源端口与目的端口在TCP头部中,用于找到进程。
  • 序列号:双方需要指定初始序列号,每个分组的序列号自增,用于判断重复分组与乱序分组

  • 窗口大小:用于接收方的流量控制

  • 另外,三次握手还协商了MSS的大小,取决于较小的一方。

2、三次握手的过程

三次握手(Three-way Handshake)其实就是指建立一个TCP连接时,需要客户端和服务器总共发送3个包。

刚开始客户端处于 Closed 的状态。服务端主动监听某个端口(应用程序中指定的),处于 Listen 状态。

三次握手的过程如下:

  • 第一次握手(客户端向服务器发送一个 SYN 报文):

    • 客户端构造一个SYN报文,TCP头部的SYN标志位置为1
    • 客户端随机初始化一个序列号(client_isn)作为初始序列号,填入序列号字段
    • 这个报文不能携带应用层数据,但要消耗掉一个序列号
    • 发送报文,此时客户端处于SYN_SENT状态。
  • 第二次握手(服务器回应客户端SYN+ACK报文):

    • 服务器收到客户端的 SYN 报文之后,也构造一个SYN报文,把SYN置为1,ACK置为1
    • 服务器随机初始化自己的序列号(server_isn)作为初始序列号,把这个序列号填入序列号字段
    • 把确认应答号字段设置为 客户端初始序列号 + 1
    • 这个报文也不能携带应用层数据
    • 发送报文,此时服务器处于 SYN_RCVD 的状态。
  • 第三次握手(客户端向服务器响应ACK报文):

    • 客户端收到 SYN 报文之后,构造一个ACK报文,把ACK标志位置为1
    • 把确认应答号字段设置为 服务器初始序列号 + 1
    • 客户端向服务器发送这个ACK报文
    • 此时客户端处于 ESTABLISHED 状态。(ESTABLISHED:已确认的)
    • 服务器收到 ACK 报文之后,也处于 ESTABLISHED 状态。此时双方已经成功建立了连接。
    • 第三次握手可以携带应用数据

3、为什么需要三次握手

三次握手有四个原因:

  • 确认通信双方的接收能力和发送能力是否正常
  • 防止服务器通过历史SYN擅自建立连接(主要原因)
  • 保证双方的初始序列号可靠交换
  • 避免服务器资源浪费

1、确认双方的通信能力

第一次:客户端发送网络包,服务端收到了。

  • 客户端指定自己的序列号(client_isn)
  • 服务器已知客户端发送能力没问题,服务器的接收能力没有问题

第二次:服务端发包,客户端收到了。

  • 服务器收到客户端的消息,指定自己的序列号(server_isn),把确认应答号字段设置为 client_isn+1
  • 客户端已知服务器接收、发送能力没有问题,客户端的接收、发送能力也没有问题

第三次:客户端发包,服务端收到了。

  • 客户端检查“确认应答号”字段,发现值是自己的序列号+1
  • 服务器这才能确认,服务器的发送能力、客户端的接收能力没有问题

因此,需要三次握手才能确认双方的接收与发送能力是否正常。

2、避免历史SYN发起连接

三次握手的首要原因,是防止旧的SYN报文初始化连接

比如客户端发送SYN报文尝试连接,由于网络拥塞,这个包没有及时到达。客户端触发超时重传,又发送了一个SYN报文,这两个SYN报文的序列号不一样。

使用两次握手的情况:

  • 服务器一旦收到了客户端的SYN报文,就会立刻响应SYN+ACK,建立连接,等待客户端与其通信。
  • 发送方收到响应,发现确认应答号不匹配,就发送RST报文终止连接
  • 服务器收到RST报文,只好再断开连接。

使用三次握手的情况:

  • 旧的SYN报文先抵达服务器,服务器对其做了SYN+ACK响应,但还没有建立起连接
  • 客户端收到响应,发现头部的“确认应答号”字段不匹配(因为旧报文的初始序列号和新的不一样)。客户端发送RST报文,终止连接。
  • 随后新的SYN报文抵达服务器,服务器对其做了SYN+ACK响应
  • 客户端收到响应,发现头部的“确认应答号”字段成功匹配,就发起第三次握手,建立连接

总结,如果使用两次握手的弊端:

  • 三次握手的情况,服务器在发送SYN+ACK后会等待,如果客户端说没问题再建立连接。

  • 如果只是两次握手,服务器在发送SYN+ACK后会立即建立连接,如果客户端说有问题才能断开连接

    这就造成服务器建立起很多无效的连接,浪费了资源。

    解决方法就是,每次建立连接之前都先询问一下客户端,这就是第三次握手的思想。

3、交换初始序列号

在可靠数据传输过程中,序列号非常重要,它的作用是:

  • 接收方判断分组是否重复
  • 接收方判断分组是否按序到达
  • 发送方判断哪些数据被成功接收

所以在建立连接时,需要客户端告诉服务器自己的序列号,然后服务器告诉客户端成功收到,服务器也告诉客户端自己的序列号,客户端随后告诉服务器成功接收。

每次发送一个消息,都需要对方回应才能证明对方成功收到。如果是两次握手,那么肯定有一方无从得知对方是否正确收到了自己的序列号

过程:

  • 客户端告诉服务器自己的序列号
  • 服务器回应收到
  • 服务器告诉客户端自己的序列号
  • 客户端回应收到

其中的第2、3步可以合并,所以成了三次握手

4、避免服务器资源浪费

如果采用两次握手,客户端恶意连续发送超级多个SYN报文

服务器每收到一个SYN就建立一个连接,就会建立很多冗余的连接,耗尽服务器的资源。

4、为什么不选择更多次握手

三次握手是理论上握手次数最少的可靠连接方式,追加更多次握手也不能使连接更可靠。

世界上不存在完全可靠的通信协议。从通信时间成本空间成本以及可靠度来讲,选择了“三次握手”作为点对点通信的一般规则。

5、关于序列号

1、为什么每次建立连接都随机指定序列号

有两个原因:

  • 避免旧数据被一个具有相同四元组的连接接收到

    • 如果该四元组的上次连接异常关闭,那么网络中可能残留着上次连接的旧数据
    • 如果每次都从一个数开始递增序列号,如果旧数据的序列号正好处于接收方的接收窗口内,就会错误接收到不属于本次连接的数据。
    • 只要每次都随机指定序列号,那么旧数据的序列号基本不可能在新的接受窗口范围内,也就不会收到旧数据
  • 保证安全性,防止黑客模拟出一个符合序列号规则的报文,让接收端接收

    • 每次连接都随机指定序列号,而不是以一个固定的数字开始,这样黑客就无从得知序列号的数值。

TCP断开连接时四次挥手,设计了TIME_WAIT状态,为什么还会存在历史报文?

按理说TIME_WAIT会等待2MSL,这个时间足够这次连接产生的所有报文都被丢弃了,再建立起新的连接,网络中不可能有旧连接的数据包存在才对。

但是,并不能保证每次连接都正常通过四次挥手关闭,还是要多重保险。

不过服务器进程被强制停止后,内核会自动触发四次挥手的正常流程。

有两种异常断开会残留旧数据的情况:

  • 服务器掉电,不过连接不存在了,不会接收旧数据
  • 服务器重写了内核的MSL时长,而且设得较小,导致TIME_WAIT时长较小,不足以让所有的报文丢弃

而且如果在TIME_WAIT状态下收到了合法的SYN报文,会复用之前的四元组连接,所以还是可能存在旧数据。

2、为什么发送方和接收方的初始序列号不一样

TCP是全双工的,也就是说通信的任意一方都可以充当发送端,向另一方发送消息。

假设通信双方为A和B,此时就相当于有两条线路:

  • A的序列号,对应B的接收窗口
  • B的序列号,对应A的接收窗口

它们双方是互不影响的,如果让它们使用相同的序列号规则,比如建立连接时由建立连接的一方随机一个序列号,作为双方的初始序列号,正常情况下也没有问题。

但是TCP的设计中,单边通信也是涉及到双方的通信的,因为接收方要向发送方响应ACK,所以发送方和接收方的序列号初始值绝对不能相同。

3、初始序列号如何产生

随机生成序列号,还是有可能随机成一样的,就还是有收到历史报文的风险。

解决方案是,生成序列号时加上时间戳的逻辑。

早期,初始的序列号(ISN)是基于时钟的,每4毫秒+1,绕一圈需要四个多小时。

后来RFC提出了一个更好的随机生成ISN的算法:

ISN = M + F (localhost, localport, remotehost, remoteport)

  • M是一个计时器,每4毫秒+1
  • F是一个哈希算法,根据源IP、源端口、目的IP、目的端口,生成一个随机数。

4、通信过程中序列号的回绕

虽然初始序列号已经不可能重复,但是通信过程中,这个序列号要一直递增,它并不是无限递增的。

序列号是一个32位的数据,上限是4G,在到达上限后会回到0继续递增。

在频繁的网络场景中,序列号很快就会回绕,导致序列号重复出现,还是有可能出现历史报文被接收的问题。

5、TCP时间戳

由于序列号存在回绕的问题,所以无法根据序列号的大小关系来判断两个数据包发送的时间关系。

使用TCP时间戳来解决序列号回绕的问题。

tcp_timestamps 参数是默认开启的。开启之后,TCP头部就会启用时间戳选项,它可以避免序列号回绕。

它的思想是:

  • 发送每个数据包,都会携带一个递增的时间戳
  • 收到数据后,如果时间戳小于最近收到数据的时间戳,就能说明这是一个历史数据,可以丢弃。

6、什么时候消耗序列号

TCP的控制报文,比如SYN和FIN,也是会消耗一个序列号(seq)的。

但是ACK不会消耗序列号

7、通信过程中序列号的增长

建立连接后,双方都有一个初始序列号

后续通信时,会在初始序列号的基础上增加序列号

序列号的含义是,这个数据包的第一个位在整个数据流中的相对位置

6、某次握手丢失了怎么办

1、第一次握手丢失

第一次握手是指,客户端想建立连接,它向服务端发送一个SYN报文,此时客户端处于SYN_SENT状态

如果第一次握手丢失,也就是这个SYN报文丢失,客户端迟迟得不到回应,就会触发超时重传机制,重新发送SYN报文。

这个时间是写死在内核中的,有的是1秒。

如果SYN报文一直丢失,客户端也不会无限重传,而是有一个最大重传上限,可以自己设置,默认是5

而且每次超时重传的时间间隔是上次的2倍。比如第一次等1秒重传,第二次就要等2秒。

如果发生了第五次重传,就会等待32秒,然后就没有重传机会了,客户端就不再尝试发起连接。

总耗时是1+2+4+8+16+32,大概1分钟左右。

2、第二次握手丢失

第二次握手是指,服务端收到客户端发来的SYN报文,它做出SYN+ACK的响应,此时服务端处于SYN_RCVD状态。

这个第二次握手其实包含两个信息:

  • 对客户端发来的SYN报文做ACK响应
  • 给客户端发送自己的SYN,包含连接信息

那么如果第二次握手丢失,会发生两件事情:

  • 客户端收不到ACK回应,就重传SYN报文
  • 服务器收不到第三次握手,就重传SYN+ACK报文,到达重传上限就会把该连接信息从半连接队列中删除。

服务器重传第二次握手也是有最大次数的,默认是5

3、第三次握手丢失

第三次握手是指,客户端收到了服务器发来的SYN+ACK,它对服务器做出ACK响应,此时客户端处于ESTABLSH状态。

这个ACK是对服务器发送的第二次握手的响应,如果它丢失了,服务器迟迟没有获得响应,就会重传第二次握手,直到达到最大重传次数。

7、半连接队列与全连接队列

1、概述

半连接队列叫SYN队列,全连接队列叫ACCPET队列

服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立起连接。

服务器会把此种状态下请求连接放在内核的“半连接队列”中,向发送方响应SYN+ACK。

如果服务器收到了该客户端的ACK,此时连接已经建立,服务器将其从”半连接队列“移除,放入”全连接队列“。

应用可以通过Socket接口的accept()函数 ,从“全连接队列”取出连接来使用。

2、全连接队列满了怎么办

如果应用处理连接的速度过慢,就可能导致全连接队列被占满。

这没有办法,因为全连接队列中的都是正常的连接,不能把它们丢弃去维护新的连接。

3、半连接队列满了怎么办

服务器发送完SYN-ACK包,就会把连接放入半连接队列。

如果之后未收到客户端的ACK,服务器就会进行重传。

如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。

如果半连接队列已满,那新的连接就无法建立。

可以设置这个参数:

net.ipv4.tcp_syncookies = 1
  • 当半连接队列满了后,后续服务器收到SYN包,不会进入半连接队列

    但也不会直接丢弃,而是给客户端发一个Cookie,包含SYN+ACK的序列号

  • 客户端向服务器发起第三次握手,会携带这个Cookie。服务器收到客户端的ACK后,检查cookie的合法性,然后直接放入“全连接队列”。

  • 思想是,既然服务器没有地方保存这个连接的状态,就交给客户端来保存,下次检查合法性就能知道它的连接状态

举个例子:

  • 三面过了就可以入职(进入全连接队列),二面过了就会登记在面试官的本本上(半连接队列)
  • 如果本子写满了,就给通过二面的候选人发一个短信,之后就算本子上没有他,他也能拿着短信证明自己通过了二面,开始三面。

8、关于SYN攻击

服务器端的资源分配是在二次握手时分配的,而客户端的资源是在完成三次握手时分配的,所以服务器容易受到SYN洪泛攻击。

SYN攻击就是Client在短时间内伪造大量不存在的IP地址,并向Server不断地发送SYN包,Server则回复确认包,并等待Client确认,由于源地址不存在,因此Server需要不断重发直至超时,这些伪造的SYN包将长时间占用半连接队列,导致正常的SYN请求因为队列满而被丢弃,从而引起网络拥塞甚至系统瘫痪。SYN 攻击是一种典型的 DoS/DDoS 攻击。

检测 SYN 攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。在 Linux/Unix 上可以使用系统自带的 netstat 命令来检测 SYN 攻击。

netstat -n -p TCP | grep SYN_RECV

常见的防御 SYN 攻击的方法有如下几种:

  • 缩短等待第三次握手的超时时间,更快地移除不可用的连接
  • 增加最大半连接数
  • 过滤网关防护,对请求做限流和分发
  • SYN cookies技术

6、TCP的数据分段

一个应用层的数据,会经过TCP加一层头部,再经过IP加一层头部,最后被数据链路层传输。

数据链路层中,一个包的最大长度为MTU,以太网是1500字节。

而除去TCP头部和IP头部后,实际的数据长度称为MSS。

既然IP层会分片,为什么TCP层也要分片?

比如一个TCP报文,包含TCP头部和数据,它超过了MTU大小,IP层就会对其进行分片,分成若干个IP数据报。

发送给目标主机后,由目标主机的IP层进行重新组装,最后交给接收方的TCP层。

但是IP分片是不靠谱的,如果一个TCP报文被分成了多个IP分片,其中一个IP分片丢失,全部的IP分片都要重传。

  • 期间如果发生分片丢失,接收方没有收到完整的TCP报文,IP层就不会把报文交给TCP层
  • 接收方的TCP迟迟没有收到数据包,也就不会给发送方响应ACK
  • 发送方的TCP就得超时重传整个TCP报文。
  • 这是因为TCP的最小单元(报文)被经过了二次切分,只有第一个分片才有TCP头部,后面的分片都只有数据。只能全部重传。

所以,由IP层进行分片效率很低,TCP协议在连接建立时会协商双方的MSS值,由TCP进行数据分片,保证每个数据包在加上IP头部后都不会超过MTU,IP层就不用分片了。

TCP层全权负责分片,每个IP报文都有TCP头部,都会被接收方的IP层交给TCP层,而不用考虑重新组装。如果发生分片丢失,那么TCP是可知该分片的具体细节的,只需要重传该分片即可

7、深入研究四次挥手

1、四次挥手的过程

连接的双方都可以主动发起断开连接。

刚开始双方都处于 ESTABLISHED 状态,假如是客户端先发起关闭请求。四次挥手的过程如下:

  • 第一次挥手(客户端向服务器发FIN):

    • 客户端调用close()函数,发送一个 FIN 报文,TCP首部的FIN置为1
    • 此时客户端处于 FIN_WAIT_1 状态。
  • 第二次挥手(服务器向客户端发ACK):

    • 服务端收到 FIN 之后,内核会自动发送 ACK 报文,此时服务端处于 CLOSE_WAIT 状态。
    • 此时的TCP处于半关闭状态,客户端到服务端的连接已经释放了,但服务器到客户端的连接还没有释放
    • 客户端收到服务端的确认后进入FIN_WAIT_2状态,等待服务端发出的FIN报文。
  • 第三次挥手(服务器向客户端发FIN):

    • 服务器处理完数据后,调用close()函数,给客户端发 FIN 报文
    • 此时服务端处于 LAST_ACK 的状态。
  • 第四次挥手(客户端向服务器发ACK):

    • 客户端收到 FIN 之后,发送一个 ACK 报文作为应答

    • 此时客户端处于 TIME_WAIT 状态。

      此时客户端的TCP未释放掉,需要经过时间等待计时器设置的时间2MSL后,客户端才进入CLOSED状态

    • 服务端收到 ACK 报文之后,就关闭连接了,处于 CLOSED状态。

2、挥手为什么需要四次

假如客户端主动断开连接:

  • 客户端发送FIN报文,表示客户端不再发送数据了,但还能接收数据

  • 服务器响应一个ACK,表示收到了信息。

    但服务器可能还没结束本次连接数据的发送,等本次连接的数据全部发出后,服务端发出FIN报文

  • 客户端收到FIN报文,给服务器响应一个ACK

    客户端独自等待2MSL,然后断开连接。

每个方向都需要一个FIN和ACK响应,所以挥手需要四次。

与三次握手不同,三次握手中服务器的ACK和SYN是一起发送的

而断开连接时,服务器通常需要等待数据的发送和处理,所以FIN和ACK需要分开发送

如果服务器没有什么资源需要发送,也可以将ACK和FIN合并成一次挥手,这样只需要三次挥手。

3、某次挥手丢失了怎么办

1、第一次挥手丢失

第一次挥手,主动断开连接的一方,暂且称为客户端,会发送FIN报文,进入FIN_WAIT_1状态

如果第一次挥手丢失了,客户端就迟迟收不到ACK,就会重传FIN报文

重传达到上限次数,客户端就直接进入CLOSE状态,断开连接。

2、第二次挥手丢失

被关闭的一方,暂且称为服务器,在收到客户端发来的FIN后,会响应一个ACK,进入CLOSE_WAIT状态。

如果这个ACK丢失了,客户端就迟迟收不到服务端的响应,它就会重传FIN报文,重传到上限次数,客户端就直接进入CLOSE状态。

3、第三次挥手丢失

服务器发送完所有的数据,向客户端发一个FIN报文,进入LAST_ACK状态。

  • 对服务器来说,如果FIN报文丢失,会迟迟得不到客户端的回应,它会超时重传FIN,直到到达上限次数

  • 对客户端来说,它收到服务器的第二次挥手后就已经进入FIN_WAIT_2状态了,这个状态不会持续太久

    • 如果等到了服务器的第三次挥手,客户端就会进入TIME_WAIT状态
    • 如果60秒内没有等到第三次挥手,客户端的连接就会直接关闭

4、第四次挥手丢失

当客户端收到服务器发来的FIN后,就会响应一个ACK。服务器收到这个ACK后就会进入CLOSE状态。

如果服务器迟迟没有收到ACK,就处于LAST_ACK状态,它会超时重传FIN报文,直到到达上限次数。

4、TIME_WAIT 状态

TIME_WAIT状态也称为2MSL等待状态。

主动断开连接的一方,才有 TIME_WAIT 状态

1、2MSL的具体值

什么是MSL

MSL是Maximum Segment Lifetime的英文缩写,可译为“最长报文段寿命”,它是一个数据包在网络上存在的最长时间,超过这个时间报文将被丢弃。

因为TCP协议是基于IP协议的,而IP头部有一个“TTL”字段,是IP数据报可以经过的最大路由数,此值为0时该报文段被废弃,同时发送ICMP控制报文通知源主机。所以说,一个数据包在网络中是有寿命的,TTL为0就代表着寿终正寝了。

MSL是时间,而TTL是跳数。不过MSL必须要大于等于TTL消耗为0的时间,来确保报文已经自然消亡。

它的思想就是,TTL有最大跳数,那么在MSL时间内,它差不多已经跳够上限了,要消亡了。

为什么要等待2个MSL

2MSL是从客户端收到服务器的FIN后发出ACK时开始计时的。如果期间又收到了服务器的FIN,会再次发送ACK,然后重新开始计时。

等待2个MSL的原因:

  • 客户端向服务器发送最后一个ACK,最慢的情况是,它花费了1MSL到达了服务器,此时服务器关闭
  • 如果这个ACK丢失,服务器会超时重传FIN报文,这个动作的时间是1、2、4、8、16,也就是说服务器可以在半分钟内重传5次FIN
  • 这些FIN都丢失的概率是非常低的,而一个FIN报文最慢需要1MSL才能到达服务器,此时客户端能够再次发送ACK,然后关闭。这个ACK再次丢失的概率很小。

2、为什么设计TIME_WAIT状态

这样设计有两个原因:

  • 延迟新的连接的建立,防止具有相同四元组的旧数据包被接收
  • 保证“被动关闭连接”的一方能够正常关闭

防止具有相同四元组的旧数据包被接收

如果没有 TIME_WAIT 状态或者等待时长过短,网络中可能还残留着本次通信的数据包。

如果客户端和服务器立即建立了下一次通信,就有可能错误接收到旧的数据包,产生数据错乱。

所以设计了一个等待状态,这个状态的客户端是无法再次发起连接的。

等待2MSL的时间,足以让两个方向上的旧数据包都被丢弃,此时就能再次发起连接了,再出现的数据包肯定是新连接产生的

保证“被动关闭连接”的一方能够正常关闭

TIME_WAIT 状态还有一个作用:保证“被动关闭连接的一方”能正确收到最后一个ACK,帮助其正常关闭。

  • 如果客户端在发送最后一个ACK后,立马关闭连接,那如果这个ACK发生了丢失,服务端会一直处于LAST_ACK状态。
  • 服务端如果没有收到ACK,就会多次超时重传FIN,但此时客户端已经接收不到了。

后果:相当于服务端的连接没有完全关闭,那如果客户端试图与其建立新的连接,服务端就会返回RST报文,无法建立新的连接。

如果有了 TIME_WAIT 状态,那么客户端就能收到服务器重传的FIN,进而重新发送ACK,重新设置2MSL计时器,直到不再收到FIN报文。

不过服务器在重发FIN到达一定次数后,也会主动关闭连接

3、等待时间过长的危害

服务器也能主动断开连接,所以服务器也会有TIME_WAIT状态。

在Linux系统中,MSL默认是30,2MSL是60。如果想修改,只能直接去修改Linux内核。Linux不允许设置2MSL的值,也说明这个值比较危险。

设置太小没作用,设置太大有两个后果:

  • 连接迟迟不断开,占用系统资源

    • TCP 连接会占用系统资源,包括文件描述符、内存资源、CPU 资源、线程资源等
  • 一堆即将关闭但还没关闭的连接持续占用端口资源。

    • 客户端端口被占用过多,就无法发起新的连接

一般来说,尽量不要由服务器主动断开连接,而是让客户端去断开连接,分别独自承受TIME_WAIT,不要让服务器集中承受TIME_WAIT

4、在TIME_WAIT收到SYN

场景是,服务端刚结束连接,处于TIME_WAIT状态,此时收到了和上一次连接相同的四元组发来的SYN报文,希望再次建立连接。

首先会判断SYN的合法性,合法要求:

  • 客户端SYN报文的时间戳比服务端上次收到的报文的时间戳大
  • 客户端SYN报文的序列号比服务器期望收到的下一个序列号大

如果这个SYN是合法的,服务器会停止TIME_WAIT,转变为SYN_REVD状态,发送SYN+ACK进行第二次握手。

相当于是重用了上次的四元组连接。由于序列号是随机生成的,所以不会收到旧数据。

5、TCP连接异常断开的场景

一个TCP连接,没有开启keepalive,没有数据交互,如果一端的主机突然掉电,或者一端的进程突然崩溃,有什么区别?

1、没有数据传输,主机掉电

主机突然掉电,它来不及告诉客户端。而且没有开启保活机制,客户端也无法探测服务端是否还存活。

所以客户端的TCP连接会一直处于连接状态,直到客户端重启该进程。

所以,在没有数据交互且没有开启保活机制的场景下,一方的TCP连接处于连接状态,并不代表这个连接是可用的

2、没有数据传输,进程崩溃

由于只是进程崩溃,内核还在正常工作,所以内核会自动发起四次挥手的流程,正常断开连接。

3、有数据传输,主机掉电重启

首先,客户端向服务器发数据,服务器还在重启,客户端收不到ACK,会触发超时重传

重传会持续5次,期间如果服务器启动了,内核就会收到重传的数据

内核会检查数据包:

  • 如果服务器上没有进程在监听该端口号,就发送RST报文,重置该连接
  • 如果服务器上有进程在监听该端口号,但是服务器重启后,之前的连接已经丢失了,内核找不到对应的Socket,就会响应RST,终止连接

总之,服务器重启之后,内核收到之前的连接发来的数据,都会响应RST报文来终止连接

4、有数据传输,主机宕机

主机一直没有重启,客户端在超时重传达到上限后,内核就会判断这个连接出了问题

通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是客户端的 TCP 连接就会断开

这个上限有两个方面:

  • 超时重传最大次数,默认15
  • 超时最大时间,每一轮的超时时间是成倍增长的

在重传报文且一直没有收到对方响应的情况时,先达到「最大重传次数」或者「最大超时时间」这两个的其中一个条件后,就会停止重传

4、有数据传输,进程崩溃重启

内核依然在正常工作。进程崩溃后,内核会主动发起四次挥手

如果是在进程崩溃阶段收到了数据包,内核发现没有进程在监听该端口号,就会响应RST终止连接

6、拔掉网线,连接还在吗

拔掉网线几秒再插回去,或者关闭无线网再开启,TCP连接还能正常运行吗?

首先,物理层断开并不会影响到传输层。

TCP连接底层是一个保存在内核中的Socket结构体,它包含连接的状态信息。

当拔掉网线后,操作系统不会对它做任何修改,所以不会影响到TCP连接,已经存在的连接依然是连接状态。

真正影响连接的并不是拔网线这个动作,而是拔了网线之后双方做了什么

1、拔掉网线后有数据传输

比如服务器掉线,客户端向服务器发送数据:

  • 服务器此时在网络上属于不可达的状态,所以服务器不会收到数据包。

  • 客户端得不到ACK响应,就会触发超时重传

    • 在超时重传的过程中,如果服务器插上了网线,它就能接收到重传的数据包,正确响应ACK。客户端只会认为是网络波动导致丢包了

    • 如果服务端一直没有插网线,客户端达到最大重传上限,内核就会判定出该 TCP 有问题

      然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是客户端的 TCP 连接就会断开

      之后服务器重新插上网线,如果服务器向客户端发送了数据,由于客户端已经没有该连接的信息,就会响应RST报文

      服务端收到RST之后就会释放连接

2、拔掉网线后没有数据传输

要看是否开启了keepalive,如果开启的话,客户端还是能主动探测到服务端的状态

  • 如果服务器一直没有插上网线,对连续多个探测报文都没有回应,客户端就能知道服务器出现问题,进而主动断开连接
  • 如果服务器插上网线了,收到探测报文就会回应,客户端重置保活机制

8、TCP的保活机制

1、建立连接后客户端挂掉

如果已经建立连接,但客户端发生错误挂掉了怎么办?

这个客户端不会发送有用的数据,但也不会主动断开连接,导致服务器的这个连接迟迟释放不掉。

所以TCP设计了保活机制 keepalive

  • 一个时间段之后,如果没有任何连接相关的活动,就会每隔一段时间发送一个探测报文
  • 如果连续的几个探测报文都得不到回应,系统内核就将错误信息通知给上层应用程序,认为连接已经死亡。

相关的内核参数:

默认是7200秒内(两个多小时),如果没有活动就开始保活,每隔75秒探测一次,连续9次不回应就认为连接死亡。

一旦保活机制启动,有三种情形:

  • 客户端正常,正确响应了探测报文,那么保活时间重置。
  • 客户端崩溃后重启了,客户端丢失了连接的相关信息,就会给探测报文响应一个RST报文,表示连接出错
  • 客户端崩溃,一直不响应探测报文,达到规定次数后,服务器判定连接已死亡。

应用层实现心跳监测

TCP的默认保活策略间隔时间太长,可以去修改内核参数,也可以在应用层实现心跳监测。

可以设置一个定时器,每次有请求就重置,如果到达了60秒还没有请求,就主动断开连接。

2、建立连接后服务端挂掉

比如建立了TCP连接,之后进程崩溃了,服务端会自动发送FIN报文,与客户端开始正常的四次挥手流程。

3、TCP的keepalive和HTTP的Keep-Alive

这是两个完全不同的东西。

  • HTTP是应用层协议,Keep-Alive是在用户态实现的,叫做HTTP的长连接
  • TCP是传输层协议,keepalive是在内核态实现的,叫做TCP的保活机制

HTTP的长连接是指:

  • 使用同一个TCP连接,进行多个HTTP请求和响应的通信工作,避免频繁建立连接和断开连接带来的开销
  • HTTP1.1默认开启Keep-Alive,可以通过请求头来设置
  • 长连接也允许HTTP流水线技术,即连续发送多个请求,服务器按序响应

TCP的保活机制:

  • 相当于心跳监测,在一个连接长时间没有通信时,会隔段时间发一个探测报文,检测对方是否还存活
  • 如果连续多次都没有回应,就认为对方已经死亡,探测方主动断开连接

9、重传机制

TCP实现可靠数据传输的基础,是序列号+确认应答。

TCP的重传机制有四种:

  • 超时重传
  • 快速重传
  • SACK
  • D-SACK

1、超时重传

发送方在发出数据后,启动定时器,如果在规定时间内没有收到响应,就重传刚才的数据包。

1、超时重传的情形

有两种触发超时重传的情形:

  • 发出的数据包丢失
  • 确认应答丢失

2、超时时间的计算

RTT与超时时间

定时器的超时时间对性能影响很大,TCP如何处理?

RTT:包的往返时间

超时时间必须大于RTT,否则没有意义:

  • 如果太大,对丢失的响应过慢,性能很差
  • 如果太小,会发生多次不必要的重传,增加网络拥塞,导致更多的超时,恶性循环。

RTT并不是常量,会根据网络的实际情况变化。也就是说,RTT是动态的,那么超时时间也应该设置成动态的,跟随RTT来一起变化。

产生两个问题:

  • 如何测量RTT
  • RTT和超时时间的具体关系

如何测量RTT

测量从数据包发出,到接收到ACK的时长,称为SampleRTT。

测量多个SampleRTT,求平均值,就可以估计RTT。估值叫做EstimatedRTT,它用来模拟此时的RTT。

EstimatedRTT是逐渐迭代的,而不是每次都计算新的值。使用加权平均算法,这样的算法可以更加均衡,过滤掉一些网络波动。

公式:EstimatedRTT = (1- a)EstimatedRTT + aSampleRTT,此处的a是典型值,通常为0.125

RTT和超时时间的具体关系

最好的情况是,比如当时的RTT是2ms,那么在2ms之后的一瞬间,没有收到ACK,就立马触发重传机制,此时效率是最高的。

所以,超时时间只要比RTT略大即可

超时时间 = EstimatedRTT + “安全边界”

安全边界:RTT的波动值(DevRTT),即SampleRTT与EstimatedRTT的差值

使用指数加权移动平均算法:DevRTT = (1- β)*DevRTT + β *|SampleRTT-EstimatedRTT| ,此处的β是典型值,通常为0.25

超时时间:TimeoutInterval = EstimatedRTT + 4*DevRTT

如果再次超时

如果超时重传的数据再次超时,说明此时网路不太好,TCP的策略是将超时等待时间加倍,也就降低了发送数据包的频率。

每当遇到一次超时重传,就会将下一次的超时时间设置为之前的两倍。这样可能导致重传周期较长,所以设计了“快速重传”机制。

2、快速重传

TCP还有另一种触发重传的情况:

由于发送方会间歇性返回ACK,表明当前接收到了哪个数据。如果分组丢失,接收方在乱序接收到其他数据包后,肯定会导致发送多个重复的ACK。

  • 比如接收方响应ACK 100,发送方发了一个100的数据
  • 但接收方一直响应ACK 100,ACK 100,说明它还没有收到那个报文,报文大概率是丢失了。

TCP规定,如果接收方收到了同一个数据的3个以上ACK,就直接重传

这就是快速重传机制:在定时器超时之前就进行了重传

1、快速重传的弊端

如果只是使用上面提到的这种,收到三个连续ACK就触发快速重传,存在一个问题。

  • 由于TCP使用滑动窗口,接收方会连续发送多个数据包,比如发出了1、2、3、4、5
  • 这些数据包在网络中传输,它们到达接收端的顺序是不一定的
  • 1先到了,接收端回ACK2
  • 但是2丢失了,接收端收到了4、5、3,但由于缺少2,所以回应三次ACK2
  • 发送端收到了三个连续的ACK2,根据刚才的分析,应该重传2

此时就出现了问题:

  • 在TCP的不同实现中,多次收到重复的ACK,可能是这个分组没有接收到,后续的分组接收到了;也可能是后续的分组也没接收到。
  • 发送端只能知道2出了问题,不能知道刚才发出去的那么多数据包还有哪些出了问题
  • 只能等到接收端收到2后,再告诉发送端还缺少哪个数据包
  • 这样就拉低了滑动窗口的效率,如果能触发重传时一次性把所有缺少的数据包发出是最好的

3、SACK

为了解决到底该重传哪些报文的问题,就设计了SACK。

SACK( Selective Acknowledgment),选择性确认。

具体做法:

  • 接收方在TCP头部的“选项”字段加一个SACK,它可以将接收缓冲区的情况发给发送方
  • 这样发送方就可以具体地知道哪些数据收到了,哪些数据没收到,只重传没收到的数据即可

可以设置内核参数来开启,Linux2.6之后默认开启

4、D-SACK

1、D-SACK的作用

接收方可以使用D-SACK告诉发送方,哪些数据被重复接收了。用于接收方收到了数据,但响应报文丢失的情形。

比如:

  • 接收方收到了发送方的10、11,响应ACK10、ACK11
  • 这两个ACK都丢失了,发送方触发了超时重传
  • 接收方再次收到了10,它就响应一个D-SACK,告诉发送方10已经收到了。
  • 发送方就可以具体地知道,刚才的ACK丢失了

总结D-SACK的好处

  • 可以让发送方知道,导致超时重传的是发出的数据包丢了,还是响应的ACK丢了
  • 可以让发送方知道,发出的数据是否被网络延迟了

5、选择重传

选择重传是指,接收方会缓存乱序到达的数据包,发送方只需要重传丢失的那个具体的数据包即可,不需要重传很多重复的数据包

好处是提高了效率,坏处是接收方必须缓存乱序到达的数据包,等待全部数据包都收到之后才能把完整的数据交给应用层

10、滑动窗口

1、为什么设计滑动窗口

设计一个最简单的,符合TCP思想的通信协议,可以这样设计:

  • 假设只是单边通信

  • 发送方维护一个序列号,每次发包就递增。

    接收方维护一个期望收到的序列号,用来和收到的数据序列号比对,不一样就丢弃,一样就响应ACK

  • 思想是,发出一个包,等待ACK后才能再次发送下一个包。一一确认的思想

这样一一确认的坏处:

  • 网络的性能瓶颈在于:一个数据包在网络上的传输速度不确定,如果网络环境很差,它就跑的很慢,这对于数据包和ACK都是一样的

  • 所以要提高网络传输效率的思想应该是:

    • 尽量频繁地持续占用网络发送有效数据,这样在整场通信中,网络的延迟对通信的整体就越来越小了。
    • 相反,如果使用一一确认,相邻的两个数据包之间的发送间隔是RTT,效率非常低
  • 举个例子,一个面试官在上海组织一场面试,10个应聘者需要坐飞机前往。

    • 最合理的方案是,10个人都提前坐飞机到面试现场,然后面试官很快地筛选,面试就结束了,耗时 = 飞机 + 筛选 * 10
    • 一一确认的方案是,1个人坐飞机来了,面试官进行筛选,然后发送ACK告诉下一个候选人,他再坐飞机来,耗时 = (飞机 + 筛选) * 10
    • 这里的飞机指的就是网络,面试官筛选指的就是目标主机接收数据包、处理数据包的工作
    • 我们假设一段时间内只有一架飞机飞往上海,那么这10个人都坐着同一架飞机去面试,这个飞机就是滑动窗口范围。
    • 滑动窗口机制,使得TCP不用一个数据包一个数据包地发送、响应

2、滑动窗口的思想

1、窗口的含义

滑动窗口的大小表示:无需等待应答还可以继续发送的字节数

窗口的实现,是操作系统开辟的一段缓存空间。窗口是以字节长度来工作的,而不是报文的个数

发送方在收到ACK之前,必须把已经发送的数据缓存起来,因为可能还要重传。如果收到了ACK,就可以把对应的数据从缓存中删除

接收方收到数据,也是先放在缓存中,再拿出来处理。只有应用程序拿到数据包后才会响应ACK,数据到了接收方缓冲区内不会响应ACK。

2、窗口大小怎么确定

TCP头部有一个window字段,窗口大小是由接收端告诉发送端的,表示自己还有多大的缓冲区,用于流量控制。

发送方根据接收方的处理能力来发送数据,避免发送数据太多接收端处理不过来,最后还得重传,没有意义。

发送方发送的数据大小不能超过接收方的窗口大小。

发送方和接收方的窗口大小

由于它们各自维护自己的滑动窗口,接收方读取完数据后,需要通过window字段告诉发送方新的窗口大小,而网络存在延迟。

那么某一时刻,发送方和接收方的窗口大小很可能不同,导致一些由于接收方窗口已满而丢弃数据的丢包情况,造成资源的浪费

操作系统缓冲区与滑动窗口的关系

一端的发送窗口或接收窗口,都是放在操作系统内核缓冲区中的。

由于窗口而丢失数据有两种情形:

  • 应用程序繁忙,无法及时读取缓冲区的内容,造成无法及时响应ACK,进而造成缓冲区已满,滑动窗口剩余空间为0
  • 系统资源紧张,操作系统直接减小了缓冲区的大小,就可能导致数据的丢失。

对于后一种情况,发送方的滑动窗口大小是根据接收方上一次报文的window字段来决定的。

如果接收方刚刚响应了一个较大的window,之后又缩小了缓冲区,就可能导致发送端发来一个大于剩余缓冲区大小的报文,就只能将其丢弃。

这样的丢包是由于先减小了缓冲区,再缩小窗口导致的。因此,TCP规定必须先缩小窗口,过段时间再减小缓冲区

3、发送方的滑动窗口

某一时刻的发送方滑动窗口细节:

发送方的窗口分为四个部分:

  • 已经发出并且收到ACK的数据

  • 窗口范围内,已经发出但还没收到ACK的数据,有三种可能:

    1. 数据还在路上
    2. 数据已经被接收方收到,但放在了缓冲区中,还没来得及读取
    3. 响应的ACK还在路上
  • 窗口范围内,还没发出的数据

  • 窗口范围外,还没发出的数据

思想是:

  • 接收方还有多大的缓冲区,发送方的窗口就有多大,就能连续发送这么多个数据包,到达接收端后就被缓存起来
  • 接收端每次响应ACK时就会带上自己的缓冲区大小,发送方会及时调整自己的窗口大小
  • 发送方收到了一个ACK,这个ACK之前的数据都被正确接收了,窗口右移,发送一些新数据

发送方的滑动窗口如何由程序表示

使用三个指针来划分四个区域,其中两个是绝对指针,指向特定的序列号。一个是相对指针,做偏移计算。

  • SND.WND:表示发送窗口的大小,由接收方指定
  • SND.UNA:绝对指针,指向窗口内已发送但还没收到ACK的第一个数据包的序列号
  • SND.NXT:绝对指针,指向窗口内可发送但还没发送的第一个数据包的序列号
  • 有这些信息就能计算出来最后一部分,超过窗口范围的还没发送的第一个数据包的序列号

4、接收方的滑动窗口

某一时刻的接收方滑动窗口细节:

  • 接收方也需要维护滑动窗口,底层是操作系统的内存缓冲区。
  • 窗口内的数据,是还没有被应用程序处理的,暂存在这里。

如何记录接收方窗口的状态?三个区域,需要两个指针:

  • RCV.WND:接收窗口的大小,会告诉发送方
  • RCV.NXT:“期望接收到的下一个数据包”的第一个字节的序列号

3、工作方式

TCP采用的是“累计确认”机制:ACK一个序列号,表示这个序列号以及之前的数据包都被正确接收到了。

累计确认的方式非常适合滑动窗口机制。

11、流量控制

1、为什么需要流量控制

流量控制主要还是服务于滑动窗口机制,用来规范窗口大小的。

如果数据发送过快,接收方处理不过来,就不会对消息做出响应,那发送方就会一直超时重传,浪费网络资源。

合理的做法是,发送数据时充分利用接收方的资源,但也保证不会超出接收方的处理能力

流量控制:控制数据发送的速度,防止数据发送的速度超过接收方处理的速度。本质是速度匹配机制。

2、具体做法

缓冲区buffer

接收方为TCP连接分配buffer:

绿色:buffer中存储有数据的部分

蓝色:buffer中空闲的部分

上层应用可能处理buffer中数据的速度较慢,如果发送方传输数据过快,就会造成buffer溢出。

流量控制机制

接收方通过报文段的window字段,将“剩余的缓冲区容量”告诉发送方,发送方以此来限制自己的发送行为,不会发送总长度大于此值的数据包。

3、窗口关闭的处理

假如发送方得知,接收方的window=0,那么发送方不会发送新的报文段,接收方也不会接收到新的报文段。

等到接收方处理完数据,就会响应ACK。但是如果这个ACK丢失了,接收方肯定不会重传。

即使此时缓冲区有了空闲,发送方也无法得知,产生了死锁。怎么解决?

TCP的解决方案是:

  • 得知接收方的window=0,发送方就启动一个计时器,隔段时间发一个“窗口探测报文”
  • 接收方收到这个探测报文,就会响应当前的window值
  • 如果依然是0,就重置计时器。如果不是0,就继续发送

探测3次,每次间隔半分钟到一分钟,如果三次的响应结果都为0,有些TCP实现会让发送端发送RST报文来终止连接。

4、小窗口大脑袋问题

如果接收方比较繁忙,接收窗口内积压的数据很多,就会导致空闲缓冲区越来越小,发送方的窗口也越来越小

  • 如果接收方腾出了几个字节,响应了ACK
  • 那么发送方如果也是挑出几个字节去发送,就得不偿失,因为TCP头部都占20多个字节呢,IP头部也有20个字节,加起来就40多个字节了
  • 如果数据只占几个字节,那么这次发数据就非常不划算,所以应该等到数据较大后再发送出去。

举个例子,一架能坐100人的飞机,上了1个人也能起飞,而不是选择先等等其他人,这个开销肯定巨大。

解决思路:

  • 接收方等到窗口较大了再通知发送方

    • 做法是,当窗口小于MSS和缓冲区大小一半中较小的值时,直接告诉发送方,窗口大小为0。MSS是一个TCP包的最大数据长度
    • 等到接收方处理了一部分数据,空闲空间大于MSS或者一半缓冲区后,再通知最新的窗口大小
  • 发送方等到窗口较大了再发送数据

    • 发送方默认开启了Nagle算法,满足两个条件其中之一才能发数据:

      • 窗口大小超过MSS,或者数据大小超过MSS
      • 收到之前发出的数据的ACK。这个策略是为了保持连续发送,避免浪费网络资源
    • 只要两个条件都不满足,发送方就一直囤积数据,先不发送。

    • 对于一些交互性比较强的场景,可以关闭Nagle算法。

5、TCP的粘包问题

TCP的粘包问题可能由发送方或者接收方引起。

1、发送方的TCP粘包问题

由于“小窗口大脑袋”问题,发送方默认开启了Nagle算法,它的做法是,把多个较小的数据包合并成一个TCP包发送。

这样导致的后果是,可能该四元组的连接上层存在多个不同的请求,比如开启长连接后的HTTP协议。

这样,来自多个不同请求的数据会紧挨着一起发给接收方,如果不做特殊处理,接收方的应用层是无法正常响应这些请求的。

  • HTTP的话没问题,因为每个HTTP请求都有规范的请求头部,可以区分不同的请求。
  • 而如果是设计一个基于TCP的应用层协议,就必须考虑这个问题。

2、接收方的TCP粘包问题

TCP在接收到数据包之后,不会立即交给应用层,而是先缓存在接收缓存中,然后由应用层去主动读取数据。

如果接收数据包的速度远大于应用层处理数据的速度,接收方就会缓存多个包,这些包会被去掉TCP头部,而且都是挨着的,也存在粘包问题。

3、如何解决

有两种途径:

  • 关闭发送方的Nagle算法。不过这样就会导致小窗口大脑袋问题,浪费资源,而且接收方的粘包问题依然存在
  • 在应用层规范数据的格式。

一般会在应用层解决:

  • 设计规范的数据格式,比如每个数据设计一个开始符、结束符,但是必须保证数据中不包含规定的开始符和结束符
  • 也可以在发送时,把数据的实际长度作为数据的头部,也可以达到区分的目的

4、UDP没有粘包问题

UDP不是流水线连续发送的,而是一个包一个包发送的,所以不存在粘包问题。

12、拥塞控制

1、为什么需要做拥塞控制

如果网络拥塞,就会导致RTT(数据包往返时间)增大,可能会触发TCP的超时重传,进而继续发送数据包,造成网络越来越拥堵。

合理的做法是,如果当前网络拥堵,就少发或不发数据包。

如果网络上的所有通信协议都有拥塞控制机制,网络拥堵的持续时间就会大大减少,这是很好的策略。

2、拥塞控制和流量控制

从结果来看,都是调整了发送方的速率,但它们迎合的目的不一样:

  • 流量控制,是发送方在迎合接收方的窗口范围,避免发送的数据包过多导致接收方处理不过来
  • 拥塞控制,是发送方在迎合网络的状态,避免发送数据过多加剧网络拥堵

3、如何做拥塞控制

拥塞控制需要解决两件事情:

  • 如何知晓当前的网络拥塞程度:Loss事件
  • 如何动态调整发送速率:拥塞窗口+拥塞控制算法

1、网络拥塞程度

每次发送方的超时重传、快速重传,都会判定为一次Loss事件,因为超时重传肯定要么是数据丢了,要么是ACK丢了,丢就是因为网络环境差。

发生Loss后,发送方降低发送速率。

2、拥塞窗口

为了限制发送方发送数据,TCP的做法和流量控制类似,设置一个属性:cwnd,用于反映网络拥塞程度。它叫做“拥塞窗口”。

所以TCP发送方的发送窗口大小,等于接收窗口和拥塞窗口中的较小值。

拥塞窗口的变化规则:

  • 网络中没有拥塞,拥塞窗口变大,全力运行滑动窗口
  • 网络中有拥塞,拥塞窗口变小,限制滑动窗口的运行

4、拥塞控制算法

有四种拥塞控制算法:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

1、慢启动

TCP连接刚建立时,有一个慢启动的过程,意思是慢慢地提高数据包的发送数量

TCP连接刚建立时,cwnd初始化为1,表示能发送1个MSS大小的数据

但此时的可用带宽通常远远高于初始的速率,所以如果发送速率很慢,就没有合理利用网络的性能,也属于一种对资源的浪费。

所以TCP的策略是:

  • 每收到一个ACK,就让cwnd+1,发包的个数是指数增长的,可以较快地达到可用带宽
  • 比如上次发了4个包,收到4个ACK,下次就能发8个包

2、拥塞避免

慢启动阶段,发送的数据包个数也不能无限地指数增长下去,而是碰到一个慢启动门限ssthresh就停止慢启动策略,转而使用拥塞避免策略。

这个慢启动门限的含义就是,它是一个比较稳定不会发生Loss的发包数量,大于它就需要谨慎探测网络带宽了。

一般来说 ssthresh 的大小是 65535 字节。

拥塞避免阶段,cwnd的规则是:

  • 每收到1个ACK,cwnd增长1/cwnd,相当于线性增长
  • 比如上次发了8个包,下次能发9个

拥塞避免阶段相当于点了一脚刹车,减缓了增长的速度,但还是在增长。

如果很多个TCP连接都在增加发包数量,网络就慢慢变得拥堵,此时就需要对发包的数量进行限制。

3、拥塞发生

当网络发生Loss事件,说明发送方进行了重传。而重传分为超时重传和快速重传,它们的起因不一样,所以处理策略也不一样。

1、超时重传的拥塞发生算法

在有快速重传机制的情况下发生了超时重传,说明要么是数据包丢了,要么是一堆ACK都丢了,此时网络环境非常差,必须大力整治。

做两件事情:

  • 当前拥塞窗口重置为1,相当于立即减小当前的发包个数
  • 慢启动门限变为原先拥塞窗口的一半,相当于限制最大的发包个数

这种方式相当激进,急刹车

2、快速重传的拥塞发生算法

快速重传情况,还能连续收到3个ACK,说明网络环境还比较好,只是偶尔丢了个包,整治力度会小一些。

做两件事情:

  • 当前拥塞窗口大小改为原先的一半
  • 慢启动门限变为原先拥塞窗口的一半

之后进入快速恢复算法

4、快速恢复

快速恢复算法用于发生快速重传之后的情况。

在发生快速恢复之前,cwnd和慢启动阈值已经调整了:

  • 拥塞窗口大小改为原先的一半
  • 慢启动门限变为原先拥塞窗口的一半

快速恢复的策略:

  • 拥塞窗口大小变为当前慢启动门限 + 3的大小(+3是因为已经有3个重复ACK离开了网络,网络多出来一些资源,窗口大小可以适当+3)
  • 重传丢失的数据包
  • 如果再收到重复ACK,cwnd+1
  • 如果收到新数据的ACK,就把慢启动门限设置为快速恢复刚开始的拥塞窗口大小,恢复拥塞避免策略,线性增长拥塞窗口大小。

13、TCP的性能分析

TCP的吞吐率 throughput

给定拥塞窗口大小和RTT,TCP的平均吞吐率是多少?

忽略慢启动的过程,假定发生超时时,CongWin大小为w,吞吐率是W/RTT

超时后,CongWin=W/2,吞吐率是W/2RTT

平均吞吐率为0.75W/RTT

TCP的公平性

如果K个TCP连接,共享相同的瓶颈带宽R,那么能保证带宽分配相对公平吗?

TCP具有公平性,因为存在拥塞控制机制

可以看出,在数据传输的过程中,资源会逐渐趋向平均分配。

UDP的公平性

UDP没有拥塞控制机制,不会限制速率,所以多媒体应用经常使用UDP。

同一带宽上,UDP比TCP占用资源更多,因为网络拥堵时TCP会降低发送速率,而UDP一直以恒定速率发送,相当于TCP为它停车让路了。

并发TCP连接

有些应用并不满足与单个TCP连接的资源,它会并发地发起多个TCP连接,一同为自己服务。

比如链路速率为R,已有9个连接。如果新来的应用请求1个TCP,它能获得 R/10的速率。但如果它请求11个TCP,就能获得 R/2的速率。

所以,一个TCP连接是公平的,但使用TCP的应用可以占用多个TCP连接资源

14、TCP的缺陷

  • 升级工作困难

    • TCP协议是在内核中实现的,应用程序只能使用而不能修改
    • 想要升级协议必须升级内核,而升级内核需要慎重,协议需要双方都支持才行,所以很多新特性得不到迅速推广
  • TCP建立HTTPS连接的延迟

    • 大多数网站都是HTTPS的,需要先进行TCP的三次握手之后,再进行TLS的四次握手,才能开始传输数据,延迟较大
    • 如果能把TLS内置在TCP中,减少握手的次数,就可以改善。但是TLS是应用层实现的,而TCP是内核实现的,所以很难结合
    • 而且,TLS无法对TCP头部进行加密,存在一些安全问题
  • TCP存在队头阻塞问题

    • TCP必须保证收到的数据是有序的,如果收到乱序的数据,就需要等待缺失的数据包发过来以后才能把数据交给应用层
    • 这就意味着如果多个请求使用同一个TCP连接,如果有一个请求丢了包,那么就会阻塞该连接中的所有请求
  • 网络迁移需要重新建立TCP连接

    • TCP是通过四元组来确定连接的,如果一方的网络环境发生变化,比如更换了IP地址,原有的连接就必须作废,重新建立连接
    • 由于TCP的慢启动特性,所以网络会短暂卡顿一下