目录
[UDP 协议](#UDP 协议)
[UDP 协议的报头](#UDP 协议的报头)
[UDP 协议的特点](#UDP 协议的特点)
[UDP 协议的缓冲区](#UDP 协议的缓冲区)
[TCP 协议](#TCP 协议)
[TCP 协议的报头](#TCP 协议的报头)
[序号 - 解决数据包乱序问题](#序号 - 解决数据包乱序问题)
[确认号 - 确认应答机制](#确认号 - 确认应答机制)
[控制标志位 - 核心状态机](#控制标志位 - 核心状态机)
[窗口大小 - 流量控制机制](#窗口大小 - 流量控制机制)
UDP 协议
User Datagram Protocol,用户数据报协议
UDP 协议的报头
在传输层的报文又可以称作数据段

| 字段名 | 长度 (位) | 长度 (字节) | 作用说明 |
|---|---|---|---|
| 源端口号 | 16 | 2 | 发送端应用程序的端口号。用于服务器回复客户端时知道发往哪里。可省略,若不需回复则置为0。 |
| 目的端口号 | 16 | 2 | 接收端应用程序的端口号。这是数据最终要送达的目标服务(如 DNS 的 53 端口)。 |
| 长度 | 16 | 2 | 整个 UDP 报文的总长度(头部 + 数据),单位为字节。最小值是8(即只有头部,没有数据)最大值是 2^16 即 64 KB。64K在当今的互联网环境下, 是一个非常小的数字。如果我们需要传输的数据超过 64 KB,就需要在应用层手动的拆分,多次发送并在接收端手动拼装。 |
| 校验和 | 16 | 2 | 用于检测报文在传输过程中是否出现错误。可选,但在 IPv6 中强制要求。发送方计算并填入,接收方重新计算。如果计算结果为 0(在 UDP 中,全0表示未使用校验和,但计算结果全0时会用全1表示),则说明数据有误,接收方会直接丢弃该报文,且不发送任何错误通知 |
UDP 的报头固定占 8 字节,所以 UDP 协议将报头和有效载荷分离十分简单粗暴:前 8 字节是报头,剩下的是有效载荷。
UDP 协议的特点
UDP传输的过程类似于寄信.
UDP 是一种无连接的、不可靠的传输协议。
工作机制 :通信前不需要建立连接,直接将数据包(数据报)发往目的地。
开销小、速度快 :头部固定为 8 字节,没有复杂的确认和重传机制,是一种"尽最大努力交付"的协议。
支持多播和广播:可以将数据同时发送给多个主机。
全双工:UDP的 socket 既能读, 也能写
不可靠性:不保证数据包一定到达(但是一旦到达应用层,一定是完整的),也不保证到达顺序。如果应用需要可靠性,必须由上层应用(如应用层协议)自己实现。TCP 协议和 UDP 协议的可靠性的高低没有褒贬之分,只是应用场景不同罢了。
面向数据报:应用层交给 UDP 多长的报文, UDP 原样发送, 既不会拆分, 也不会合并(如果发送端调用一次 sendto, 发送 100 个字节, 那么接收端也必须调用对应的一次recvfrom, 接收 100 个字节; 而不能循环调用 10 次recvfrom, 每次接收 10 个字节;如果发送端调用 10 次 sendto, 每次发送10 个字节, 那么接收端也必须调用对应的 10 次recvfrom, 每次接收10 个字节; 而不能只调用一次 recvfrom, 一次性接收 100 个字节;)注意:接收方不能保证收到的 UDP 报的顺序和发送 UDP 报的顺序一致。
应用场景:适用于对实时性要求高、允许少量丢包的场景。例如:视频直播、语音通话(VoIP)、在线游戏、DNS(域名系统)查询。
UDP 协议的缓冲区
- UDP 没有真正意义上的发送缓冲区. 调用 sendto 会直接交给内核, 由内核将数据传给网络层协议进行后续的传输动作;
- UDP 具有接收缓冲区. 因为可能短时间内收到多个 UDP 报文。但是这个接收缓冲区不能保证收到的 UDP 报文的顺序和发送 UDP 报文的顺序一致; 如果缓冲区满了, 再到达的 UDP 数据就会被丢弃

TCP 协议
TCP全称为 "传输控制协议(Transmission Control Protocol"). 人如其名, 要对数据的传输进行一个详细的控制;它是传输层最重要的协议,没有之一。客户端和服务器是 HTTP 协议的两个不同角色,在 TCP 协议中,发送方和接收方地位是完全对等的,比如发送方对接收方有流量控制,接收方对发送方也有流量控制。
TCP 协议的报头
TCP 协议的有些解决问题的方法就体现在 TCP 协议的报头的字段,比如解决数据包乱序问题 - 序号字段、确认应答机制 - 确认号字段、流量控制机制 - 窗口大小字段、报文分类和控制 - 控制标志位字段等等。还有些解决问题的方法没有体现在报头,比如超时重传机制。

源端口号 (Source Port) 和目的端口号 (Destination Port)
长度:各 16 位。
作用:与 IP 地址一起,唯一标识一个网络通信的端点。源端口标识发送方应用程序,目的端口标识接收方应用程序。
在学习序列号 和确认号字段之前,先看一个例子:

- 没有应答的消息,我们无法保证该消息是否被对方接收
- 应答的消息发送给对方后,在对方眼里,又成为了最新的消息,需要应答
- 所以我们无法保证我们发送的消息是 100% 被对方接收的
在实际的 TCP 确认应答机制中,我们只需要保证通信双方两个方向的可靠性即可。在上面的例子中,"我们去吃饭吧"、"好的"、"收到" 这些消息都有应答,其中 "好的" 确认了从小明到小红方向通信的可靠性,"收到" 确认了从小红到小明方向通信的可靠性,之后不必要再对应答做应答。

在现实生活中,我们并不会说完一句话后等待应答后再等待对方的实际消息,比如我说"我们去吃饭吧",你说"好吧,我们去吃饺子",你的话可是既带有应答又有实际消息的。这种将应答和消息结合称作捎带应答
同时,客户端向服务器发送TCP报文时,并不会发送一个报文,等待服务器的应答之后,再发送下一个报文,而是并行发送多个报文,统一等待服务器的应答,这样做提高通信的效率。

客户端向服务器按顺序发送多个报文,这些报文并不一定被服务器接收的顺序并不一定是发送时的顺序,比如全班同学约定去北京玩,同学们出发的顺序不一定是到达的顺序,有的同学走得早,因为要赶高铁,有的同学走得晚,但是坐的是飞机。TCP 报文经过不同的中间网络设备,这些中间网络设备的传输效率可能有所差异,导致报文到达的顺序并不一定是发送时的顺序。这种问题称为数据包乱序问题。乱序是不可靠的,TCP 协议要避免这种情况,如何解决这个问题?这就与下面要介绍的字段序号有关了。
序号 - 解决数据包乱序问题
长度:32 位。
作用:这是 TCP 最关键的字段之一。
字节流编号 :TCP 将发送的每个字节都进行编号。序号字段的值,指的是本 TCP 报文所发送数据的最后一个字节的序号。
可靠性基础 :接收方通过序号来确认 收到了哪些数据,并可以按正确的顺序重组 数据流(即使它们乱序到达),还可以对它们去重(超时重传机制)。
握手时 :在建立连接时,双方会随机生成初始序号 (ISN, Initial Sequence Number)。初始序号采用某种随机算法,保证不同时间建立的连接,初始序号大概率不同。为什么要随机生成初始序号而不是每次都默认从 0 开始?假设你访问网站,连接 A(ISN=1),发送了
[1,2,3,4,5],然后断开。过了一分钟,你重新访问网站,连接 B(ISN 也等于 1)。这时候,上一轮那个延迟的"幽灵包"(序列号=3)突然到达了服务器。后果:服务器发现序号 3 在自己的接收窗口内(因为新连接也是从 1 开始的),错误地把旧连接的数据接收了,导致数据错乱。如果采用随机生成初始序号,会极大概率避免上述情况。另外,即使 TIME_WAIT 状态可以避免幽灵包的问题,但也无法完全避免,所以才有了上述机制。

确认号 - 确认应答机制
长度:32 位。
作用 :仅当
ACK标志位为 1 时,该字段才有效。它表示期望收到对方下一个TCP 报文的第一个字节的序号,也就是收到的报文的序号 + 1。确认机制:
确认号为
N,代表序号N-1及之前的所有数据都已成功收到。这是一个累积确认的机制。提醒发送方从确认号开始的序号发送。允许应答少量的丢失:比如说下图,客户端收到了确认号 3001,而没有收到确认号 1001、2001,客户端也认为 3001 序号之前的字节服务器成功收到了。
捎带应答:为什么 TCP 报头用确认号实现确认应答机制?直接复用序号不行吗?复用发送方的序号,返回给发送方的应答,序号为原序号,意为该序号之前的数据我全部收到了,这样乍听可能有点道理,可不要忘记了,服务器返回的不单单是应答,而是捎带应答,即 TCP 报文既是应答(确认号体现),又是数据(应答携带实际数据),既然服务器的应答携带数据,那就必须一同携带数据的序号以供接收方按顺序接收和确认,显然复用序号不是解决方案。

快重传
假设上图中,序号为 2000 的报文出现了丢包,即服务器根本没有收到序号为 2000 的报文,那么服务器往后对客户端的应答的确认号都是 2001。客户端只要收到了三个重复的确认号,就知道有报文出现丢包了,就会触发快重传

数据偏移 (Data Offset,上图的4位首部长度)
长度:4 位。
作用 :指示 TCP 头部的总长度(以 4 字节为单位)。TCP 的标准报头大小为 20 字节,但是因为选项
Options字段长度可变(范围 0 到 40 字节),所以需要这个字段来明确有效载荷从哪里开始。
- 例如:如果该值为
5,则表示头部长度为5 * 4 = 20字节(无选项)。最大值为15,对应60字节(选项占 20 字节)。
保留位 (Reserved)
长度:3 位(最初为 6 位,后来定义了 3 位用于 CWR 和 ECE 标志,现通常说保留 3 位或 4 位,标准图示常为 3 位保留)。
作用:为未来使用而保留,必须设置为 0。
控制标志位 - 核心状态机
为什么要有该字段:TCP 通信时,不管是建立连接(三次握手)、正常的数据通信,还是断开连接(四次挥手),都要发送完整的 TCP 报文,也就是说TCP 报文是有类型的,客户端和服务器要根据报文的类型做出不同的动作,控制标志位就是用来区分不同的报文类型的
长度:共 9 位(最初 6 位,后扩展了 3 位),每位是一个标志。
ACK (Acknowledgment):为 1 时,确认号字段有效。三次握手的第一次握手请求连接建立后的几乎所有报文都会设置此位为 1(因为往后的通信都是捎带应答),表示该报文为应答,如果该报文还携带了数据,那就是捎带应答。
SYN (Synchronize) :用于建立连接。在连接请求中,
SYN=1, ACK=0;在连接响应中,SYN=1, ACK=1。FIN (Finish):为 1 时,表示发送方没有数据要发送了,请求释放连接。
PSH (Push):为 1 时,指示接收方应立即将数据交付给应用层,而不必等待缓冲区满。发送方使用该标志位要么是因为该报文十分紧急,要么是因为接收方的接收缓冲区空间不足,接收方的接收缓冲区空间不足的原因要么是接收方的应用层太忙碌,没有时间进行 read 系统调用,要么是接收方的应用层根本就不打算读取数据(这就是单纯是服务器的 BUG 了)
RST (Reset):为 1 时,表示连接出现严重错误,必须立即释放连接并重新建立。用于拒绝非法请求或重置异常连接。TCP 虽然保证通信的可靠性,但并不保证每次三次握手建立连接都必定成功,它的可靠性体现在如果连接失败,TCP 协议提供解决的策略
URG (Urgent):为 1 时,表示紧急指针字段有效,该报文携带紧急数据,应优先处理。TCP 协议规定报文在接收方处应该按序号顺序排列,而该字段可以做到让该报文插队。什么时候会用到紧急数据?比如当服务器十分忙碌,来不及给客户端响应时,客户端可以发送询问服务器状态的报文,该报文就应该被优先处理。
NS (ECN-nonce):用于显式拥塞通知 (ECN) 的隐藏保护。
CWR (Congestion Window Reduced):拥塞窗口减少标志,用于拥塞控制。
ECE (ECN-Echo):显式拥塞通知回显标志,用于告知发送方网络出现拥塞。
窗口大小 - 流量控制机制
长度:16 位。
作用:
指示接收方(自己)接收缓冲区剩余空间大小。这是 TCP 流量控制 的核心。它的含义是告诉对方:" 从本数据段的确认号开始,我还能接收多少字节的数据**"**。
如果接收方的缓冲区的空间即将耗尽,这个字段相当于告诉发送方:"我的接收缓冲区空间即将耗尽,请你降低发送速度",
由于接收方的接收缓冲区被打满而导致的大量丢包是会浪费网络带宽资源的,因为丢包会超时重传,流量控制就是为了避免这种情况
对于发送方,发送速度由接收方的接收缓冲区剩余空间大小决定。
客户端和服务器可以互相发送应答消息,应答消息是完整的 TCP 报文,即客户端和服务器可以相互知道对方的接收缓冲区剩余空间大小,从而相互协调发送速度。
通信双方在第一次通信时,如何得知对方的缓冲区大小?答案是双方在三次握手时,就交换了双方的缓冲区大小。第三次握手时可以携带实际数据,说明交换缓冲区大小发送在前两次握手中
窗口探测和窗口更新通知:两台主机在通信时,如果一台主机的接收缓冲区被打满,那么另一台主机就会等待。在等待时,会主动周期性发送的一个特殊报文,用来试探接收方的窗口是否有空闲空间。当接收方的缓冲区有了空闲空间后,会主动发送一个 ACK 包,专门告知发送方"我的接收缓冲区有空闲了,你可以继续发送了",这两个机制可以确保:只要接收缓冲区有空闲,就可以尽快利用,提高网络通信的效率。双方都主动发送探测或通知,最大限度防止因为网络原因导致对方无法获取探测的结果或通知。
- 流量控制机制首先是体现 TCP 协议的可靠性(减少了丢包),其次是提高了通信的效率(只要接收缓冲区有空闲,就可以尽快利用)。
- 范围 :默认情况下 0 到 65535,但为了支持更大的窗口(提高吞吐量),
窗口扩大因子选项可以将该字段值左移一定的位数,使窗口最大可达 1GB。
校验和 (Checksum)
长度:16 位。
作用 :与 UDP 类似,计算范围包括 TCP 伪头部、TCP 头部和数据。伪头部包含源 IP、目的 IP、协议号 (6) 和 TCP 报文总长度。该字段由发送方计算并填充,接收方重新计算并验证,如果错误则丢弃报文(不发送通知)。
紧急指针 (Urgent Pointer)
长度:16 位。
作用 :仅当
URG标志位为 1 时有效。它是一个正的偏移量,与序列号字段相加,指向紧急数据的最后一个字节。用于发送带外数据 (Out-of-band data),现已很少使用。紧急数据最多占一个字节
可选选项 (Options)
Options 字段长度可变,从 0 到最多 40 字节(60 字节头部减去 20 字节基础头部)。常见选项有:
MSS (Maximum Segment Size):在建立连接(SYN 包)时告知对方自己能接收的最大 TCP 报文段长度(不含 TCP 头,仅数据部分)。通常为 1460 字节(以太网 1500 - IP头20 - TCP头20)。
窗口扩大因子 (Window Scale):在 SYN 包中协商,将窗口大小字段左移,实现更大的接收窗口。
SACK (Selective Acknowledgment):允许接收方告知发送方具体哪些数据块丢失了,而不是只告诉最后一个连续收到的序号,从而提高拥塞控制效率。
时间戳 (Timestamps):用于计算 RTT (Round-Trip Time) 和保护 PAWS (Protection Against Wrapped Sequence numbers)。
其他解决方案
超时重传机制
客户端向服务器发送 TCP 报文时,可能因为网络拥堵等种种不可预料的情况,导致客户端在特定的时间间隔之后没有收到服务器的应答,此时客户端要重新向服务器发送报文,要重新发送报文,一定是客户端暂时保存了报文,所以:已经发送到网络,但是还没有收到应答的报文,要被暂时保存起来。触发超时重传机制的情况:1、客户端的报文丢包 2、服务器的应答丢包 3、网络拥堵
在上图的右边的情况中,服务器可能会收到两份一模一样的报文。服务器收到重复报文是一种不可靠的情况,TCP 协议要避免它,如何避免?报文的序号字段又可以起作用了,即通过序号来去重。
如何规定特定的时间间隔到底多长?
- 如果超时时间设的太长, 会影响整体的重传效率;
- 如果超时时间设的太短, 有可能会频繁发送重复的包;
- 也就是说超时时间要根据通信双方的网络状态动态调整,最理想的情况下, 找到一个最段的时间, 保证 "确认应答一定能在这个时间内返回".如果通信双方网络状态良好,可以设置得稍微短一些,反之可以长一些
实际的超时时间设置规则:
- Linux中(BSD Unix和 Windows 也是如此), 超时以 500ms 为一个单位进行控制, 每次判定超时重发的超时时间都是 500ms 的整数倍.
- 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
- 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
- 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接
有了快重传,为什么还要有超时重传机制?
- 快重传的前提是发送方可以收到接收方的 ACK,如果收不到接收方的 ACK,那么快重传机制就失效了,也就是说超时重传机制是应对收不到接收方的 ACK 时的重传策略。
- 接收方只有收到 3 个及以上的重复确认号才会快重传,如果接近通信的末尾,比如倒数第二个报文出现丢包,那么发送方最多收到两个重复确认号,不会触发快重传,这个时候只有等待特定的时间间隔触发超时重传机制
连接管理机制
为什么要进行三次握手建立连接, 四次挥手断开连接
在正常情况下, TCP要经过三次握手建立连接, 四次挥手断开连接。为什么要进行三次握手建立连接, 四次挥手断开连接呢?根本的原因是要确认通信双方两个方向的可靠性。
- 第一次握手是客户端向服务器的连接请求
- 第二次握手是服务器对客户端的应答,对于客户端来说确认了从客户端到服务器通信方向的可靠性
- 第三次握手是客户端对服务器的应答,对于服务器来说确认了从服务器到客户端通信方向的可靠性
- 三次握手的每一次握手都缺一不可,它保证了在正式通信之前通信双方的收发信息的功能正常,因为三次握手对于通信双方都至少进行了一次信息的收发。
为什么四次挥手不改为三次挥手
其实四次挥手也可以是三次挥手,因为服务器对客户端的 FIN 的 ACK 和 FIN 可以合并成为捎带应答,四次挥手的作用和三次握手相同,都是要确认通信双方两个方向的可靠性。那为什么四次挥手不改为三次挥手呢?原因是**两个方向的数据流必须独立关闭。**客户端如果想断开连接,只是关闭了客户端 → 服务端方向的发送能力,并不代表服务器也要立刻断开连接,客户端想要断开连接时,可能服务器应用程序还在往发送缓冲区里写数据,或者还有未发送完的数据在发送缓冲区中,等到服务器将数据全部发送完毕,再向客户端发送 FIN,得到客户端的 ACK 后才关闭了服务端 → 客户端方向的发送能力。那为什么三次握手就可以是三次呢?因为客户端向服务器发送链接请求,服务器可以立刻接受请求并建立连接。
为什么三次握手是三次
如果是一次握手:
客户端发一个 SYN,服务端不回复。
客户端无法知道服务端是否收到,更无法确认服务端是否能发送数据。
SYN 洪水攻击:黑客不断的向服务器发送 SYN 请求,服务器每收到一个 SYN 请求,都要分配资源,这样服务器的资源就被消耗殆尽而崩溃。
如果是两次握手:
问题一:造成服务器资源浪费
客户端 → 服务端:SYN(我想连接,我的初始序列号是 X)
服务端 → 客户端:SYN+ACK(我同意,我的初始序列号是 Y,同时确认收到了你的 X)
服务端发完 SYN-ACK 后,会分配资源(如缓冲区、控制块)并进入
SYN_RCVD状态。但如果客户端根本没收到 SYN-ACK(比如丢包了),客户端会认为连接没建立,不会发任何数据。服务端却傻傻地等,造成资源浪费(半开连接)。服务器通常要服务多个客户端,如果所有客户端的错误都影响到服务器,那么服务器迟早有一天会崩溃。
- 三次握手中的第三次 ACK 的作用正是:让服务端知道客户端确实收到了 SYN-ACK,从而安全地进入
ESTABLISHED状态。如果服务器没有收到客户端的 ACK,就不会分配资源,链接失败的成本由客户端承担。也就是说奇数次握手可以将链接失败的成本由客户端承担,而三次是理论上的最小次数。问题二:无法防御历史连接(重复的旧 SYN)
假设这样一个场景:
客户端曾发过一个 SYN(seq = 100),因为网络延迟,这个 SYN 滞留了。
客户端超时重传,发了新的 SYN(seq = 200),这次顺利建立了连接,传输数据,然后关闭了。
过了很久,那个旧的 SYN(seq = 100) 终于到达服务端。
如果只有两次握手:
服务端收到这个旧 SYN 后,立即回复 SYN+ACK(seq = Y, ack = 101)
服务端认为连接已建立,分配资源,进入等待状态
但客户端根本不知道这个连接(它早就结束了之前的连接,或者根本没想连这个端口),不会发任何数据,也不会回复 ACK
服务端的资源被白白占用,直到超时释放
如果加上第三次 ACK:
服务端收到旧 SYN 后,回复 SYN+ACK(ack = 101)
客户端收到这个意外的 SYN+ACK 后,发现 ack 不是自己期望的(因为客户端当前根本没在连接),会发送 RST 重置包
服务端收到 RST,知道这是个假连接,立即释放资源

服务器状态变化:理解全链接与半链接
- [CLOSED -> LISTEN] 服务器端调用 listen 后进入 LISTEN 状态, 等待客户端连接;
- [LISTEN -> SYN_RCVD] 一旦监听到连接请求(同步报文段), 就将该连接放入内核等待队列中并向客户端发送 SYN 确认报文.
- [SYN_RCVD -> ESTABLISHED] 服务端一旦收到客户端的确认报文, 就进入ESTABLISHED 状态, 可以进行读写数据了.此时链接就已经建立,服务器为该链接分配了资源,不管应用层是否调用 accept 。这些建立好的链接被放在了一个队列里,由 accept 取出这些链接。系统调用 listen 的第二个参数可以表示该队列可以容纳链接数+1,该队列叫做全链接队列。全链接队列一般不要设置的得太长,因为全链接队列如果满了,一定是因为服务器太忙了,而全链接队列由一直占用内存资源而不创造价值。也不能没有全链接队列,因为如果服务器有空闲,在全链接队列的客户链接可以立刻使用服务器资源,如果没有全链接队列,服务器必须等待新的链接到来,在等待的这段时间,没有充分利用服务器的资源,创造的价值减少。
cpp#include <sys/socket.h> int listen(int sockfd, int backlog); // 示例 listen(sock_fd, 10); // 允许最多11个连接请求在队列里等待如果全链接队列已经满了(一直没有被 accept),此时仍然有客户端的链接请求,那么服务器会强制丢弃三次握手的第三次握手时收到的 ACK,此时客户端认为链接已经建立好了,但是服务器不这么认为,服务器对该客户端而言仍处于 SYN_RCVD 状态。
在服务器中处于 SYN_RCVD 的链接称为半链接,服务器也要维护半链接,即将半链接放在半链接队列中,但是处于半链接队列的链接不会被服务器长时间保存。SYN 洪水就是非法链接长时间占用半链接队列导致正常链接无法成为全链接被 accept。
- **[ESTABLISHED -> CLOSE_WAIT]**当客户端主动关闭连接(调用close), 服务器会收到结束报文, 服务器返回确认报文并进入 CLOSE_WAIT 状态;
- **[CLOSE_WAIT -> LAST_ACK]**进入 CLOSE_WAIT 后说明服务器准备关闭连接(需要处理完之前的数据); 当服务器真正调用 close 关闭连接时, 会向客户端发送 FIN, 此时服务器进入 LAST_ACK 状态, 等待最后一个 ACK 到来(这个ACK是客户端确认收到了FIN)
- **[LAST_ACK -> CLOSED]**服务器收到了对 FIN 的 ACK, 彻底关闭连接.
客户端状态变化:理解 TIME_WAIT 状态
- **[CLOSED -> SYN_SENT]**客户端调用 connect, 发送同步报文,进入 SYN_SENT 状态;
- **[SYN_SENT -> ESTABLISHED]**向服务器发送最后一个 ACK 后,connect 调用成功, 进入 ESTABLISHED 状态,客户端为该链接分配资源,开始读写数据;客户端在发送最后一个 ACK 报文后,对于客户端就认为链接已经建立完成,而最后一个 ACK 报文对客户端是没有应答的,也就是说客户端在赌最后一个 ACK 报文服务器一定可以收到。如果服务器没有收到客户端的最后一个 ACK 报文,那么服务器会向客户端发送 RST 报文,要求客户端重新建立连接
**[ESTABLISHED -> FIN_WAIT_1]**客户端主动调用 close 时, 向服务器发送结束报文, 同时进入 FIN_WAIT_1 状态;
**[FIN_WAIT_1 -> FIN_WAIT_2]**客户端收到服务器对结束报文段的 ACK, 则进入 FIN_WAIT_2 状态, 开始等待服务器的结束报文;
**[FIN_WAIT_2 -> TIME_WAIT]**客户端收到服务器发来的结束报文, 发出 LAST_ACK,进入 TIME_WAIT 状态 ;
[TIME_WAIT -> CLOSED] 客户端要等待两个 MSL (Max Segment Life, 报文最大生存时间) 的时间, 才会进入 CLOSED 状态.即:主动断开链接的一方,在四次挥手完成之后,要进入 TIME_WAIT 状态,等待两个报文最大生存时间之后自动释放。如果主动断开链接的是服务器,那么服务器正处于 TIME_WAIT 状态时,链接没有彻底断开,端口号还在被使用,此时又启动服务器,也就是启动 server 进程,server 进程又要绑定旧端口号,而一个端口号不能被两个进程同时绑定,所以此时会出现 bind 错误(bind error : Address already in use)。也就是说如果服务器崩溃,还要等待一段时间后才能重新启动服务器,这在有些场景比如双 11 期间是不允许的。如何解决?可以使用 setsockopt 函数设置端口号重用:
cppint opt = 1; // 在调用 bind() 之前设置此选项 if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) { perror("setsockopt SO_REUSEADDR failed"); }客户端通常才是主动关闭的一方,为什么客户端不会出现上面的情况,因为客户端每次启动都是绑定的随机端口号。为什么要有 TIME_WAIT 状态呢 ?主要有两个原因:
1、确保被动关闭方能收到最后一个 ACK
第四次挥手(客户端回复的最后一个 ACK)可能会在网络中丢失。
如果没有
TIME_WAIT:客户端发完 ACK 后直接关闭。如果此 ACK 丢失,服务器收不到,就会重发它第三次挥手的 FIN。但此刻客户端已关闭,会直接回复一个 RST (连接重置包),导致服务器收到错误响应,连接未能优雅关闭。有了
TIME_WAIT:客户端在发完 ACK 后,还会等上 2MSL。如果在这段时间内收到了服务器重传的 FIN 过来了,客户端可以识别到,并再次发送一个 ACK 来响应服务器。这样就确保了连接能完全、正确地关闭。这个 2MSL 的时长,正好是一个 ACK 报文从发出到对方能收到并处理的最坏情况。MSL 是 TCP 段在网络中允许存活的最大时间,2MSL 就是一来一回的上限。
2. 防止旧连接的延迟数据段干扰新连接
问题场景:连接 A 在关闭前,发送了一些在网络中"迷路"或延迟了很久的数据包。
冲突场景:连接 A 关闭后,立刻用相同的 IP 和端口建立了一个新的连接 B(四元组完全相同)。就在建立成功后,那个旧连接 A 的"幽灵"数据包才姗姗来迟,到达了对端。
此时会出现严重问题: 新连接 B 的网络层看到这个包的四元组和自己匹配,就会将它当作一个合法数据包递交给上层应用。应用会收到一个毫无意义甚至错误的旧数据,导致协议混乱。
通过
TIME_WAIT解决 :TIME_WAIT状态的 2MSL,强制要求这条连接的所有"幽灵"数据包,必须在网络中都彻底消失或被丢弃后,才能建立新的相同四元组的连接。
关于 MSL:MSL 在 RFC1122 中规定为两分钟,但是各操作系统的实现不同, 在 Centos7 上默认配置的值是 60s。要把 MSL 与报文最大传输时长区分,MSL 是指在网络传输中最大的生存时长,是秒甚至分钟级别的,而不是最大传输时长(毫秒级别).可以通过 cat /proc/sys/net/ipv4/tcp_fin_timeout 查看MSL的值
滑动窗口
在超时重传机制中,我们知道:**已经发送到网络,但是还没有收到应答的报文,要被暂时保存起来。**问题:这些报文被保存在哪里呢?难道我要在发送之前拷贝一份备用吗?答案是被保存在发送缓冲区中,我们可以将发送缓冲区划分为几个部分:
- 已发送 & 已确认的报文
- 已发送 & 未确认的报文
- 待发送 & 窗口内的报文
- 待发送 & 窗口外的报文
- 其中已发送 & 已确认的报文、已发送 & 未确认的报文,待发送 & 窗口内的报文在发送窗口内。已发送 & 已确认的报文在发送窗口左侧,发送窗口的右侧都是待发送 & 窗口外的报文,发送窗口向右移动。
- 发送窗口可以向右"滑动"故名滑动窗口。滑动窗口一般不会向左滑动,因为滑动窗口的左侧是已发送 & 已确认的报文,向左滑动没有意义。向右滑动会有尽头吗?从物理角度发送缓冲区是有尽头的,从逻辑角度没有尽头,因为 TCP 协议将发送缓冲区看成环形,TCP 报头中用于标识字节序号的字段是 32 位的,这意味着序号的范围是:0 到 2³² - 1 ,字节序号会用尽。此时,下一个字节的序号会重新从 0 开始。
- 因为有了滑动窗口,所以发送方才支持了一次发送一批报文,然后统一等待 ACK。
- 注意:在发送窗口内的报文并不是都是已发送 & 未确认的报文,还有待发送 & 窗口内的报文。
- 已发送 & 已确认的报文可以被重新覆盖,即已发送 & 已确认的报文被删除了
- 滑动窗口的大小:目前可以认为是根据接收方的 ACK 报文的窗口大小字段(即接收方的接收缓冲区剩余空间大小)调整为一个合适的大小,使得既能一次发送一批报文的同时,又不至于将对方接收缓冲区打满。滑动窗口在向右滑动的时候,大小是会动态变化的,即根据对方的接收能力动态调整窗口大小
- 如何实现发送缓冲区的区域划分呢?大体思路是用两个指针标记滑动窗口的起始位置(win_start)和结束位置(win_end)。

允许少量的 ACK 丢失,提高效率:如果发送方一次性发送序号为 3000、4000、5000、6000 的报文,可是只收到了确认号为 5001 的报文,那么窗口该怎么移动?是等待确认号为 3001 的报文然后才向右移动吗?答案是窗口起始位置直接移动到序号为 6000 的报文处,因为确认号的定义为确认号之前的字节序号都被对方收到了。

滑动窗口的大小动态变化
我们知道滑动窗口在向右滑动的时候,大小是会动态变化的,即根据对方的接收能力动态调整窗口大小,调整窗口大小只有三种情况:变大、变小、不变。变小的情况:对方读取报文的速度小于我发送报文的速度,比如我一次发送 4 个报文,对方一次读取 3 个报文,此时滑动窗口的大小会慢慢变小以降低发送速度,具体可能表现为 win_start 向右移动,win_end 不变或者移动的速度比 win_start 慢。窗口不变或变大同理。
延迟应答
发送/接收窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。在一般的应答机制中,如果接收数据的主机接收到数据就立刻返回 ACK 应答, 这时候返回的窗口可能比较小
- 假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
- 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
- 如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的窗口大小就是1M;
但延迟应答并不一定会提高通信的效率,比如接收方在一段时间内都没有消费缓冲区数据。这给我们编程方面的启示是尽快用 read 或 recv 把数据从缓冲区消费掉。延迟应答纯粹是为了提高效率,不体现 TCP 协议的可靠性
延迟应答的具体策略
- 采用数量限制或者是时间限制
- 数量限制: 每收到 N 个包就应答一次;
- 时间限制: 每隔最大延迟时间就应答一次,最大延迟时间肯定小于触发超时重传机制特定时间间隔
- 具体的数量和超时时间, 依操作系统不同也有差异; 一般 N 取 2, 最大延迟时间取 200ms;
拥塞控制
- 结合前面所学,TCP 协议已经有很多策略防止出现丢包,如果再出现大面积丢包(比如发送 100 个报文,对方只收到了两个),TCP 协议不会再怀疑是对方主机出现了问题,而是怀疑网络出现了问题(硬件出现问题或网络拥堵)。为了应对由于网络而出现的丢包问题,TCP 协议还提出了拥塞控制机制。
- 通信双方如果在刚开始阶段就发送大量的数据 , 但是可能网络设备 当前的网络状态比较拥堵 . 所以 在不清楚当前网络状态下 , 不能贸然发送大量的数据,如果那样做,只会加重网络的拥堵。所以如果有大量的报文触发了超时重传机制,那么 TCP 协议不会立刻把所有的超时的报文进行重新发送,而是采用下面的机制。这样做的好处是:当网络出现拥堵,每台主机都放缓发送的速度,就可以缓解网络拥堵的情况。
- TCP 引入 慢启动 机制 , 先发少量的数据 , 探探路 , 摸清当前的网络拥堵状态 , 再决定按照多大的速度传输数据 ;
拥塞窗口
- 此处引入一个概念程为拥塞窗口
- 发送开始的时候, 定义拥塞窗口大小为 1;
- 每次收到一个 ACK 应答, 拥塞窗口的大小翻倍;
- 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际的发送窗口的大小。即:发送窗口的大小 = min(ACK窗口大小,拥塞窗口大小)。其中ACK窗口大小考虑的是对方的接收能力,拥塞窗口大小考虑的是网络情况。
拥塞窗口的增长与缩小
- 像上面这样的拥塞窗口增长速度, 是指数级别的. "慢启动" 采用指数级增长,前期增长缓慢,满足探测网络状况的需要,后期增长迅速,满足在网络状况良好的前提下,迅速恢复正常通信速度的需要.但是不能使拥塞窗口一直指数级增长下去,当拥塞窗口的大小达到慢启动的阈值时,不再按照指数方式增长, 而是按照线性方式增长。这个阈值的初始值设定由不同的操作系统决定。
- 拥塞窗口增长时,一旦出现网络拥堵,拥塞窗口大小立刻又变为 1,慢启动的阈值变为出现网络拥堵的瞬间,那时的拥塞窗口大小的一半。
总结
为什么TCP这么复杂? 因为要保证可靠性, 同时又尽可能的提高性能.
可靠性:
- 校验和 - 确保收到的是完整报文
- 序列号 - 确保收到的报文是有顺序的、不重复的
- 确认应答 - 确保对方知道我确实收到了报文
- 超时重发 - 防止丢包
- 连接管理 - 确保全双工、通信渠道正常
- 流量控制 - 防止丢包
- 拥塞控制
提高性能:
- 滑动窗口 - 允许少量的 ACK 丢失
- 快速重传 - 相对于超时重传机制,提高了效率
- 延迟应答 - 提高网络吞吐量
- 捎带应答 - 将数据和应答合并成一个报文
三次握手:
- 建立连接 - 确保全双工、通信渠道正常
- 协商起始序号 - 防幽灵包
- 协商起始发送接收窗口大小
四次挥手:
- 确保双方的数据都交换完毕再关闭连接
- TIME_WAIT 防幽灵包








