TCP 协议简述

1,684 阅读16分钟

本文的目的在于简单提炼 TCP 协议的知识,让读者对 TCP 协议形成大致完整的框架理解,留下一些初步印象,以便项目中遇到问题时,知道从哪个方向进行突破。

对于 TCP 协议更详细的内容,还是推荐翻阅《TCP/IP 详解》进行深入理解。

简介

相对于 UDP 的不提供可靠性,TCP 提供一种面向连接的、可靠的字节流服务。

其有如下特点:

  • 面向连接:两个使用 TCP 的应用,在传输数据前必须先建立一个 TCP 连接。
  • 可靠传输:
    • 数据被分割成合适的段(segment)传递给 IP。
    • 对发送的报文段有自适应的超时重传策略。
    • 目的端收到数据时,将发送确认。
    • 目的端会检测首部和数据的检验和,若有差错,将丢弃该报文段和不发送确认。
    • IP 数据报的到达可能会失序,TCP 将对收到的数据重新排序,以正确的顺序交给应用层。
    • 由于超时重传,接收到的报文段可能会重复,TCP 接收端会丢弃重复的数据。
    • TCP 提供流量控制,其每端都有固定大小的缓冲空间,接收端只允许发送端发送其缓冲区能收纳的数据,防止缓冲区溢出。
  • 面向字节流:TCP 不在字节流中插入记录标识符,且对字节流内容不作任何解释,而是交由应用层解释。

Header

对 TCP 协议的学习,首先要去理解其 header 的构成,以及 header 中每个 bit 的设计和意义所在。再从 header 的设计去理解协议的每部分内容,就会相对清晰。

首先来看一下 TCP 协议的 header:

image.png

上图中每行为 4 bytes(1 word),前 5 行刚好是 header 固定的 20 字节,让我们以 4 字节为一个单位来了解 header 中的字段:

  • 源端口和目的端口:各占 2 字节,范围为 0 ~ 65536,这两个值与 IP header 中的 IP Address 组成唯一的 TCP 连接。
  • 序号:
    • TCP 是可靠传输协议,会对字节流中的每个字节进行标号追踪。
    • 一个报文段中可能会有多个字节的数据,序号用于标识当前报文段中第一个数据字节的标号
    • 出于安全考虑,TCP 连接的初始序号一般为随机数而不是 0
    • 序号占据 4 字节,是 32 bit 无符号数,达到最大值后会取余从 0 开始
  • 确认序号:
    • 包含接收端所期望收到下一个报文段第一个数据字节的序号,因此是上次已成功收到数据字节序号加 1
    • 只有 ACK 标志为 1 时该字段才有效
  • 数据偏移:
    • 标识报文段中数据的起始位置,也指报文段首部长度
    • 其占 4 bits,单位为 32 bits(word),因此最大偏移数位 15 * 4 = 60 字节,故 TCP header 长度不能超过 60 字节,即可选首部不能超过 40 字节
  • 标志位:
    • URG(紧急):Urgent Pointer(紧急指针)字段有效,表明该报文段中有紧急数据,应该尽快传送,无需按原来的排队顺序传送
    • ACK(确认):确认序号有效,TCP 规定,在建立连接后的所有报文段都必须将 ACK 设为 1
    • PSH(推送):接收方应尽快将这个报文段数据交给应用层,而不是等待缓存填满后再向上交付
    • RST(复位):为 1 时表示 TCP 连接出现严重差错,必须立即释放,重建连接
    • SYN(同步):用同步序号发起一个连接
    • FIN(终止):表明报文段发送方完成发送任务,要求释放连接
  • 窗口大小:
    • 用于控制 TCP 流量,占 16 bits,单位为 byte,窗口最大大小为 65535 字节。
    • 用于指明接收端允许发送端发送的数据量
    • 该字段受可选首部中的窗口扩大选项影响而保持动态变化
  • 检验和:
    • 占 2 字节,检验和字段的计算范围包括首部和数据
    • 这是一个强制性的字段,一定是由发送端计算和存储,由接收端进行验证
    • 先将检验和字段设为 0,再对首部和数据中每 16 bit 进行二进制反码求和,将结果存在检验和字段中。
    • 若数据长度为奇数字节,则在最后增加填充字节 0 进行计算,填充字节不会被传输。并且 TCP 段包含一个 12 字节长的伪首部,是为了计算检验和而设置的。
    • 接收方在收到后进行同样的计算,由于接收方计算的包括了首部中的检验和,因此计算结果应全为 1,若不是则丢弃。
  • 紧急指针:占 2 字节,单位为 byte,是一个正的偏移量,用于标识紧急数据结束的位置
  • 可选字段:长度可变,最长可达 40 字节,常见选项如下
    • 最长报文大小(MSS, Maximum Segment Size):表示 TCP 传往另一端的最大数据块的长度。连接建立时,双方都要在首个 SYN 报文中通告各自的 MSS,用于限制另一端发送的数据报长度。
    • 窗口扩大选项:使 TCP 的窗口定义从 16 bit 增加为 32 bit。通过定义一个偏移移位选项完成窗口大小的扩大操作。该选项只能出现在 SYN 报文中,且被动建立连接方只能在收到带有该选项的 SYN 报文后才能发送该选项。若主动连接放发送了扩大因子,但没有从另一端收到扩大选项,其也会将移位计数器设为 0,与兼容较旧的、不理解新选项的系统进行兼容。
    • 时间戳选项:使发送方的每个报文段中放置一个时间戳值,接收方在确认中返回这个数值,从而允许发送方为每一个收到的 ACK 计算 RTT。

建立与终止连接

TCP 是面向连接的协议,即在发送数据前,双方必须建立连接。

连接建立协议

我们一般将 TCP 建立连接的过程称为「握手」,发送端和接收端之间通过交换三个报文段建立连接,所以又称为「三次握手」,具体步骤如下:

  • 发送端发送一个 SYN 报文段,表明想要连接接收端,并附上初始序号。
  • 接收端收到 SYN 段后,发回一个 ACK 报文段作为应答,并将确认序号设为发送端的初始序号 + 1(SYN 占用一个序号);同时,该报文段的 SYN 标志位也被标为 1,且附上了接收端的初始序号。
  • 发送端收到报文段后,再次发送一个 ACK 报文段进行应答,确认序号为接收端初始序号 + 1。

为建立连接而发送 SYN 报文段的端会为连接选择一个初始序号(ISN),ISN 随时间而变化(每 4ms + 1),因此每个连接都将有不同的 ISN。

发送第一个 SYN 的一端将执行主动打开,接收这个 SYN 并发回下一个 SYN 的端执行被动打开。

示意图:

image.png

为什么建立连接需要交换三次报文段?

交换三次报文段是为了确定双方的通信能力:

  • 第一次:接收端确认了自己的接收能力和发送端的发送能力。
  • 第二次:发送端确定了双方的收发能力。
  • 第三次:接收端确认了双方的收发能力。

什么是 SYN 洪水攻击?以及如何防御?

SYN 洪水攻击是 DDos 攻击中最常见的攻击类型之一,攻击者通过发送大量伪造 TCP 连接请求,使得被攻击方的主机资源耗尽。

接收端在收到一个 SYN 报文后,会处于 SYN-REVD 状态,此时连接还未完全建立,接收端会将该连接维护在一个队列中,将这个队列称为半连接队列。

若客户端在短时间内伪造大量源 IP 不存在的 SYN 请求,接收端的半连接队列就会不停增长,又因其回复的 SYN 请求得不到应答,从而导致不断超时重发直至上限。在这期间半连接队列中的连接数量足够多,会占据接收端资源,导致其无法接收正常的 SYN 请求,从而引起拥塞甚至瘫痪,是典型的 Dos/DDos 攻击。

接下来简单罗列下防御方法,可根据实际业务进行调整:

  • 可对系统中半连接队列中的数量增加监控和告警机制,以便受到攻击时能及时应对。
  • 缩短连接超时时间
  • 适当增加半连接队列长度
  • 降低超时重试次数
  • 限制单个 IP 的并发连接数量
  • 增加 IP 频控,并对异常 IP 进行拉黑
  • SYN Cookies 算法
  • ...

连接终止协议

我们一般将 TCP 终止连接的过程成为「挥手」,建立一个连接需要三次握手,而终止一个连接要经过四次挥手,即发送端和接收端之间要通过交换 4 个报文段终止连接。

首先发送 FIN 的一方将执行主动关闭(A),另一方将执行被动关闭(B)。

  • A 发送一个 FIN 报文,用来关闭从 A 到 B 的数据传输。
  • B 收到这个 FIN 后,发回一个 ACK 报文,确认序号为收到序号 + 1(FIN 占用一个序号)。
  • B 的数据发送完后,向 A 发送一个 FIN 报文,用于关闭连接。
  • A 收到后回发一个 ACK 报文,确认序号设为收到序号 + 1。B 收到该报文后直接关闭连接,而 A 在报文发出后还需等待 2MSL,再关闭连接。

示意图:

image.png

为什么建立连接是三次握手,但终止连接是四次挥手?

半关闭:一端在结束其发送后,仍能接收来自另一端的数据。
全双工:数据在两个方向上能同时传递。

终止连接需要四次握手是由 TCP 的半关闭造成的。TCP 连接是全双工的,因此每个方向必须单独的进行关闭。每个端在完成其数据发送任务后,必须发送一个 FIN 报文来终止该端的连接。而一个端收到 FIN 报文后,必须通知应用层另一端已经终止了数据传输。

为什么主动关闭方最后要等 2 MSL 时间?

MSL(Maximum Segment Lifetime,最长报文寿命):报文在网络上存活的最大时间,一旦超过该时间,报文会被丢弃。

等待 2 MSL 是为了确保 A 发送的最后一个 ACK 能到达 B。若 B 未收到最后一个 ACK,会进入超时重发 FIN 报文,A 将收到重发的 FIN 报文,并重发最后 ACK。

另外,等待 2MSL 时间也为了确保本次连接产生的所有报文段都已从网络消失,避免下一个使用该端口的新连接中出现旧连接的报文段,因此在 2MSL 等待期间,该端口不能再被使用。

通常来讲,客户端执行主动关闭并进入 TIME-WAIT 状态是正常的,服务端通常执行被动关闭,不进入 TIME-WAIT 状态。因为服务器使用熟知固定端口,若终止一个已建立连接的服务器程序,并试图立即重启,该服务器程序将无法立刻用原固定端口打开连接。

FIN-WAIT-2 状态是半关闭吗?

主动关闭方处于 FIN-WAIT-2 状态,表明其已经发出 FIN,且收到了另一端的 ACK,此时除非是实行半关闭,否则将等待另一端发出 FIN 来关闭它的连接,在收到另一端的 FIN 后才会进入 TIME-WAIT 状态,否则可能永远保持这个状态。

可靠传输

为保证可靠传输,需要考虑数据的损坏、丢失、重复、乱序等问题,TCP 通过实现各种机制来应对这些问题。

滑动窗口协议

该协议允许发送方在并等待确认前可以连续发送多个分组,由于无需每发一个分组就停下来等待确认,因此该协议可以加速数据传输。发送方在每收到一个 ACK 时就把窗口向前滑动一个分组的位置。

通常接收方一般采用累积确认的方式,不对收到的每个分组逐个发送确认,而是在收到几个分组后,对按序到达的最后一个分组发送确认,表示之前的所有分组都已正确到达。

image.png

滑动窗口协议可简单转成如下视图:

image.png

超时与重传

TCP 为提供可靠的传输层,使用的方法之一就是从另一端收到数据时需要进行确认。但是数据和确认报文都可能丢失,所以 TCP 在发送报文时会设置一个定时器,当定时器溢出时还未收到确认,就判定超时并重传该报文。而该实现的关键之处就是超时时间的设定和重传的策略。

对每个连接,TCP 会管理 4 个不同的定时器:

  • 重传定时器用于管理超时时间。
  • 坚持定时器动态维持窗口大小信息,即使另一端关闭了其接收窗口。
  • 保活定时器可检测到一个空闲连接的另一端何时崩溃或重启。
  • 2MSL 定时器测量一个连接处于 TIME-WAIT 状态的时间。

超时时间的设定是超时重传机制中最重要的部分,其会根据连接的往返时间(RTT)的测量来界定,由于路由器和网络流量均会变化,因此 RTT 可能经常会发生变化,TCP 会跟踪这些变化相应的改变其超时时间。

为测量 RTT,TCP 发送端会对部分报文段进行计时,在每次调用 500ms 的 TCP 定时器例程时,增加计数器来完成计时。这意味着超时时间是以 500ms(1 个滴答) 为单位控制的,偏差值最多为 500ms。如果重发后的数据依旧收不到确认,则每次再重发的超时时间将会 2 的指数级增长,这个倍乘关系被称为「指数退避」,直到重发次数上限。此时会认为该 TCP 连接已经发生异常,发送方将发送一个复位信号并强制关闭连接。

慢启动算法

连续向网络发送多个报文段可以提升效率,但由于发送方和接收方之间可能存在多个路由器和速率较慢的链路,一些中间路由器必须缓存分组,并有可能耗尽存储空间,所以这种方式可能会严重降低 TCP 连接的吞吐量。

由于以上原因,TCP 支持一种叫「慢启动」的算法,其为发送方增加了一个窗口:「拥塞窗口(cwnd)」。发送方将取拥塞窗口与通告窗口中的最小值作为发送上限,当连接建立时,拥塞窗口被初始化为一个报文段,每收到一个 ACK,拥塞窗口就以 2 的指数级增长。之后在某些点可能达到互联网容量上限,中间路由器开始丢弃分组,发送方就知道其拥塞窗口过大。

拥塞避免算法

在慢启动算法中我们提到,当数据达到中间路由的极限时,分组将被丢弃,而拥塞避免算法就是一种处理丢失分组的方法。

该算法假定由于分组受到损坏而引起的丢失是非常少的,因此分组丢失就意味着源主机和目的主机之间的某处网络上发生了拥塞,其会通过发生超时和收到重复确认来进行分组丢失判定,其在使用中会和慢启动一起实现。

  • 对一个给定的连接,初始化一个拥塞窗口(cwnd)和一个慢启动门限(ssthresh),cwnd 被初始化为 1 个报文段,ssthresh 被初始化为 65535 个字节。
  • 拥塞窗口先以慢启动方式增长(指数),报文段数量上限为拥塞窗口与通告窗口中的最小值。
  • 当拥塞发生时,ssthresh 被设置为 cwnd/2 与通告窗口中的较小值,最少为 2 个报文段。此外,若拥塞为超时引起,cwnd 被设为 1(重新慢启动)。
  • 在拥塞发生调整后,新的数据被接收方确认时,增加 cwnd。此时若 cwnd <= ssthresh,判定为正在执行慢启动,cwnd 按指数增长。否则判定为正在进行拥塞避免,cwnd 按线性增长(每次 +1)

快速重传算法

TCP 在收到一个失序的报文段时,需要立即产生一个 ACK(重复的),该 ACK 不会被延迟,其目的在于让发送方知道收到了一个失序的报文段,并告诉对方自己期望收到的序号。

发送方不知道一个重复的 ACK 是由一个丢失的报文段引起的,还是由于几个连续报文段到达时间不一致引起的,因此需要等待少量重复的 ACK 到达。如果只是几个报文段失序到达,那么这些报文段在重排序后并产生一个正确 ACK 之前,只可能产生 1~2 个重复 ACK。如果连续收到 3 个或以上重复 ACK,则非常可能是一个报文段丢了。于是发送端就重传丢失的数据报文段,无需等待超时定时器溢出,这就是快速重传算法。

快速恢复算法

快速恢复算法会使 TCP 发送端触发快速重传后,执行拥塞避免算法而不是慢启动算法。这是由于收到了多个重复的 ACK 不仅表示一个分组丢失了,也表示其后续的分组被接收了,两端间仍有数据流动,而非发生拥塞导致的丢失。

  1. 当收到第 3 个重复 ACK 时,将 ssthresh 设置为 cwnd/2,重传丢失的报文段,设置 cwnd 为 ssthresh 加上 3 倍的报文段大小(+ 3 是因为重复 ACK 后 3 个报文段是已收到的,避免 cwnd 突然变得很小)。
  2. 每次收到另一个重复的 ACK 时,cwnd 增加一个报文段大小并发送 1 个分组(若允许)。
  3. 当下一个确认新数据的 ACK 到达时,设置 cwnd 为 ssthresh(第 1 步中设置的值)。
    1. 该 ACK 应该是对步骤 1 中重传的确认,也是对丢失的分组和收到的第 1 个重复 ACK 之间的所有中间报文段的确认。
    2. 这步采用的是拥塞避免,当分组丢失时,将当前的速率减半。

image.png