051 传输层 —— TCP(上)

051 传输层 —— TCP(上)
小米里的大麦传输层 —— TCP(上)
1. 传输控制协议
1. TCP 的本质:传输控制协议 —— “控制”二字是灵魂
TCP 全称 Transmission Control Protocol(传输控制协议) —— 人如其名,它的核心职责不是“传输数据”,而是 对数据的传输过程进行精细、动态、可靠的控制。
我们平时调用的 write、send、read、recv 等函数,要么将用户缓冲区的内容拷贝到发送缓冲区,要么将接受缓冲区的内容拷贝到用户缓冲区进行处理,本质上都是 缓冲区拷贝函数:
send/write→ 将用户缓冲区数据拷贝到内核的 TCP 发送缓冲区。recv/read→ 将内核的 TCP 接收缓冲区 数据拷贝到用户缓冲区。
通过 TCP 协议控制网络传输,将可靠性数据从一台主机的发送缓冲区安全地交付到另一台主机的接收缓冲区,要求数据原封不动的发送过去,所谓发送,本质上是一种也是一种跨网络的拷贝!其本质就是不断把数据放到网络中。而数据何时发送?发多少?丢包后怎么重传?拥塞时怎么降速?乱序时怎么重组?—— 这些全部由 TCP 协议栈自主决策,应用程序完全无感知!当然,对方也可以对我们发送,因为 TCP 是全双工的,有自己的发送缓冲区和接受缓冲区!
2. TCP 和文件系统的关联?—— “一切皆文件”的完美体现
在 Linux 中,文件 I/O 和网络 I/O 在抽象层面高度统一,都遵循“缓冲区 → 内核 → 硬件”的模型:
| 对比 | 文件 I/O | 网络 I/O (TCP) |
|---|---|---|
| 用户调用 | write(fd, buf, len) | send(sockfd, buf, len, 0) |
| 数据去向 | 内核页缓存(Page Cache) | TCP 发送缓冲区 |
| 最终目标 | 磁盘(慢速设备) | 网卡(远程主机,更慢+不可靠) |
| 刷新时机 | 缓冲区刷到磁盘盘由 OS 决定 | 由 TCP 协议决定(如窗口、拥塞控制) |
| 出错处理 | 磁盘错误罕见、概率极低,通常忽略 | 文件需经过网络,需要有 TCP 来确保可靠性 |
核心思想:
- “文件”是本地存储的抽象,“Socket”是远程通信的抽象 —— 但它们在 I/O 模型、缓冲区管理、阻塞/非阻塞行为上高度一致。
- 这就是 “Linux 一切皆文件” 的哲学体现 —— 统一接口,统一缓冲,统一调度,只是底层驱动不同。
- 补充:在 Linux 中,socket 也有文件描述符(fd),也能用
read/write,也能设置O_NONBLOCK,也能被select/poll/epoll监控 —— 完全兼容文件接口!
3. 不考虑可靠性,数据在网络中如何流动?
我们想要发送一个网络数据,先在应用层通过相关的协议对结构化数据做序列化,然后放到发送缓冲区中,然后 TCP 会帮我们在适当的时机把数据发送到对方的接收缓冲区,等待对方的应用层通过 read 读取。此时一般会出现两种情况:
- 接收方处理慢 → 数据堆积在接收缓冲区: 对方上层由于忙碌始终无法处理,使得多次发送的报文都堆积在接收缓冲区,当他不忙碌时可能一次 read 就都读上去了!!然后上层再需要对这些数据解析成一个个完整报文(因为是面向字节流的),然后反序列化成结构化数据供上层使用。而如果凑不齐一个报文,就将他们暂时存储在自己的用户层缓冲区,等下次 read 的时候如果凑齐了再一起拿出来解析。
- 发送方不发 / 网络拥塞 → 接收方
recv阻塞: 对方的上层会在合适的时机进行 read,但是如果发送方始终没有发送,或者网络太拥堵导致接收缓冲区一直没有数据,那么 read 就会阻塞住,然后 OS 就会把这个进程设置为 S(Sleep) 状态,而当缓冲区有数据的时候(也就是说 OS 某些资源就绪的时候)OS 会再次调度这个进程去把数据从缓冲区读上来!!!
所以以前我们觉得 OS 某些资源不就绪从而使得进程暂时进入 S 状态大多数指的是硬件的速度比较慢,所以需要等待硬件资源就绪,但是今天我们发现在网络的情况中,也有可能是因为对方始终不给你发数据,或者由于网络拥塞造成接收缓冲区没有数据可处理,也是属于资源不就绪的情况,此时调用 read 的这个进程就必须阻塞住!!
“资源不就绪”不仅指本地硬件(磁盘、键盘),也包括 远程资源(网络数据未到达)。网络 I/O 的“等待”,本质是等待 不可控的外部世界 —— 这是分布式系统复杂性的根源。
4. 为什么 UDP 无发送缓冲区,而 TCP 必须有?
UDP 发了就是发了,他并不关心对方的接收缓冲区是不是满了,如果满了他就会丢包,此时我们是无感的,UDP 不保证可靠性,但是 TCP 需要保证可靠性啊!
核心区别在于:可靠性保证。
- UDP:直接把数据交给 IP 层发出,发完就不管了,对方收没收到、接收缓冲区满不满,它都不关心,应用层必须自己处理丢包、乱序等问题。
- TCP:必须保证数据可靠送达,所以发送缓冲区是必不可少的:
- 暂存应用层写入的数据,等对方确认 ACK 后再清除。
- 应对对方接收缓冲区不足:对方的窗口大小会告诉你“我还能收多少”,如果满了,就得把数据先留在发送缓冲区。
- 应对网络抖动:丢包或超时重传时,也得从发送缓冲区里重新取出数据再发。
如果没有发送缓冲区:应用层写的数据就会“丢在半路”,因为网络或接收方不可能一直实时响应,那就完全无法做到“可靠传输”。
5. TCP 小结 —— 一个“智能缓冲区控制器”
| 层面 | TCP 的角色 |
|---|---|
| 对应用层 | 提供“可靠字节流”抽象,屏蔽网络复杂性 |
| 对内核 | 管理发送/接收缓冲区,实现流量/拥塞控制 |
| 对网络 | 动态调整发送策略,对抗丢包、延迟、乱序 |
| 对哲学 | 体现“控制”本质 —— 不是搬运工,而是调度大师 |
TCP 不是“管道”,而是“智能调度器 + 缓冲区管理器 + 错误恢复引擎”,它让不可靠的网络,看起来像“可靠的本地内存拷贝”。
2. 协议段格式
1. TCP 的协议段格式
这依然是一个 逻辑结构图,仅用来表示 TCP 首部中各字段的位置、大小和含义。实际传输时,TCP 数据是 连续的字节流,按顺序排列在 IP 包中。
在物理传输层面,无论是 TCP 还是 UDP,数据最终都以字节流(比特流)的形式在网络介质中传输。
2. 逐字段详解(重要)
1. 16 位源端口号 & 目的端口号(各 16 位,共 2+2 = 4 字节)
作用:标识通信双方的应用程序,用途:实现多路复用,一台主机同时跑多个 TCP 连接。
源端口:发送方的应用使用的端口(如客户端随机端口 54321)。
目的端口:接收方应用监听的端口(比如 HTTP 默认 80,HTTPS 默认 443)。
示例:
1
客户端 (192.168.1.100:54321) → 服务器 (192.168.1.1:80)
2. 32 位序号(4 字节)
作用:给每个字节编号,实现可靠传输、排序、重传,从而保证数据能按顺序组装。 TCP 是 字节流协议,不是像 UDP 那样一包一包,发出去的每个字节在字节流里都有编号。
- 每个字节都有一个唯一的序号。
- 初始值(ISN)由系统随机生成,防止攻击。
- 发送时递增,接收方根据序号判断是否乱序或丢失。
- 示例:
- 第一个包序号 = 1000,载荷 100 字节 → 下一个包序号 = 1100
- 接收方收到后,会检查序号是否连续,否则要求重传。
3. 32 位确认序号(4 字节)
作用:告诉对方“我下一个想收到的字节号”。 只有当 ACK 标志位为 1 时有效,用于确认对方已成功接收数据。举例:如果确认号 = 2001,说明我已经收到对方 2000 号字节 之前的所有数据,期待下一个是 2001。这是 可靠传输 的核心机制:发送方根据确认号判断数据是否成功送达。示例:
- A 发送了序号 1000~1099 的数据(100 字节)
- B 收到后,回复 ACK = 1100,表示“我收到了 1000~1099,期待下一个是 1100”。提示:示例中 “ACK = 1100” 里的 1100 是 确认序号,不是 ACK 标志位的值。只有当 ACK 标志位为 1 时,确认序号才有效,这里默认 ACK 标志位为 1,表明已收到序号 1100 之前的所有数据。
4. 4 位首部长度(4 位 0.5 字节)
表示 TCP 首部的长度(以 32 位即 4 字节为单位)。最小是 5,单位:以 4 字节为 1 个单位(即 4*5 = 20 字节),因为基本首部固定就是 20 字节。提示:这里单位可能有点不好理解,多读两遍即可。
- 范围:5 ~ 15(即 20 ~ 60 字节)。
- 最小值 5 → 20 字节(无选项)。
- 如果有选项,首部就会变长,从而大于 5,比如三次握手时常见的 MSS 选项。
5. 保留字段(6 位 0.75 字节,了解)
必须设为 0,供未来扩展。
6. 6 个标志位(0.75 字节)
这是 TCP 中最核心的部分之一,控制连接建立、释放和数据传输:
| 标志 | 全称 | 含义/作用 | 常见场景 |
|---|---|---|---|
| URG | Urgent | 紧急指针有效,表示报文中有紧急数据,接收方要 优先 处理这部分数据(跳过普通队列) | 几乎不用,现在应用层自己处理优先级 |
| ACK | Acknowledgment | 确认序号是否有效(几乎总是 1),表明报文头的 ack 字段生效,用于确认收到对方的数据或请求,建立连接之后几乎所有报文都带 ACK | 三次握手、数据传输、四次挥手全过程 |
| PSH | Push | 推送数据,提示接收方立即交给应用层,即提示接收端应用程序立刻从 TCP 缓冲区把数据读走,不要等缓冲区填满再交付 | 偶尔用,Telnet、SSH、交互式应用(实时输入 → 实时输出) |
| RST | Reset | 强制断开异常连接,或拒绝无效请求,对方会要求重新建立连接,我们把携带 RST 标识的称为 复位报文段 | 端口未监听时回应、异常关闭、攻击防御 |
| SYN | Synchronize | 用于同步序号,请求建立连接,同时传递初始序列号,发起连接时置 1。同时,我们把携带 SYN 标识的称为 同步报文段 | 三次握手的第 1 步和第 2 步 |
| FIN | Finish | 请求关闭连接,表示本端数据发完了,准备断开 | 四次挥手的第 1 步和第 3 步 |
特别重要:三次握手(SYN, SYN+ACK, ACK)和四次挥手(FIN, ACK, FIN, ACK)都依赖这些标志位!
7. 16 位窗口大小(2 字节)
作用:告诉对方“我还能接收多少字节的数据”(单位:字节)。比如:窗口 = 3000,说明“从我期望的确认号开始,我还能接收 3000 个字节”。这样,发送方就不会把数据塞爆接收方的缓冲区,直到收到新窗口通知。
- 这是 TCP 流量控制 的关键,防止发送方过快导致接收方缓冲区溢出。
- 动态变化:接收方每收到数据,就更新窗口大小并回传。
8. 16 位检验和(2 字节,了解)
对整个 TCP 段(包括伪首部、首部、数据)进行校验。检测传输错误,若检验和不匹配,丢弃该报文。
注意:TCP 检验和是 必须计算的,而 UDP 检验和是可选的。
9. 16 位紧急指针(2 字节)
搭配 URG 标志使用,仅当 URG 标志位为 1 时有效,用于指出本报文中紧急数据的最后一个字节位置。现代应用几乎不用,简单了解就好。示例:序号 = 1000,紧急指针 = 5 → 紧急数据从 1000 到 1005。
10. 选项(了解)
可变长度,最多 40 字节(总首部最大 60 字节)。常见选项:
- MSS(Maximum Segment Size):最大报文段长度,协商双方最大发送大小。
- SACK(Selective ACK):选择性确认,提高重传效率。
- Timestamps:时间戳,用于 RTT 测量和防重放。
- NOP:填充用,保持对齐。
11. 数据(了解)
紧跟在首部之后,长度由 IP 总长度 - IP 首部长度 - TCP 首部长度 决定,由上层应用(如 HTTP、FTP)提供。
你这四个问题其实正好把 TCP 报文头的关键设计思想 给串起来了,我帮你捋清楚。
3. TCP 报头如何分离数据?
先读 20 字节 → 再看首部长度 → 根据首部长度决定选项大小 → 剩下的就是数据。
- 第一步:读取前 20 字节(最小头部)。 固定部分:TCP 报头最短 20 字节,这部分字段顺序固定(源端口、目的端口、序号、确认号…)。
- 第二步:解析“首部长度”字段。 在这 20 个字节里,有一个 首部长度字段,它是 4 位,单位是 32 bit(4 字节)。提示:这里 4 位是二进制数,最小值是 0101(即十进制的 5),表示 5 个 4 字节,5 × 4 = 20 字节。
- 如果首部长度 = 5,表示 5 × 4 = 20 字节 → 没有选项,只有固定部分。
- 如果首部长度 = 6,表示 6 × 4 = 24 字节 → 有 4 字节选项。
- 第三步:跳过头部,剩余部分就是“数据”。
- 数据起始位置 =
IP 数据报起始位置 + IP 头部长度 + TCP 头部长度 - 数据长度 =
IP 总长度 - IP 头部长度 - TCP 头部长度
- 数据起始位置 =
和 UDP 类似,区别在于 UDP 的报头固定 8 字节,而且直接有个字段长度,所以简单粗暴。TCP 因为要扩展功能,才搞了个“首部长度 + 选项”的自描述方式。有了“自描述头部长度”,然后从 IP 层“借”总长度来推算数据长度。
4. 如何交付给上层?
TCP 用 端口号 来做区分。每个进程通过 套接字 (socket = IP + 端口) 来绑定通信,报文里的 源端口 / 目的端口 就告诉内核:
- 这是谁发的?(源端口)
- 应该交给哪个进程?(目的端口)
所以 TCP 收到数据后,靠目的端口查路由表 → 找到本机监听该端口的进程 → 把数据放进它的接收缓冲区 → 应用层再 read()。
5. 关于 16 位校验和
TCP 校验和不光包含 TCP 头和数据,还要算上一个 伪首部,里面有源 IP、目的 IP、协议号等字段。
为什么要多此一举?
因为要防止“收到了正确的数据,但收错了目的地”这种事。伪首部相当于再确认一次,这数据确实是发给我的。
TCP 校验和覆盖范围 = TCP 头 + TCP 数据 + 伪首部,如果校验失败,接收方直接丢掉报文,不给上层进程。
6. 为什么 TCP 报头没有长度字段?
这个设计正是因为 TCP 面向字节流。UDP 是 面向报文,一发就是一块,必须告诉你这块有多长 → 所以有 length。TCP 把数据看作一个 连续的字节流,应用层想写多少就写多少,内核可能拆成多个报文段发出去。接收方只管把这些字节按顺序拼接起来。
对 TCP 来说,什么时候算一条完整的消息,由应用层自己决定。所以 TCP 报头里没必要放 “长度”字段,只需要:
- 知道首部多长(方便找到数据起点);
- 知道数据流的位置(靠序号
seq字段来标记每个字节)。
- UDP:像快递小哥,一单一单送,包裹上贴着“这是一个 2kg 的包裹”,所以每个报文都带长度。
- TCP:像水管输水,不管你倒一桶还是倒十桶,水流不断过去。水分段传输,但水的“总量”是靠序号控制的,不需要每次标记“这一桶多大”。
7. TCP 连接建立可能失败
TCP 虽然保证 已建立连接后的数据传输可靠性,但 连接建立过程本身可能失败。三次握手过程中任何一个步骤出现问题都会导致连接失败:
- 第一次握手失败:客户端 SYN 包丢失或被防火墙过滤。
- 第二次握手失败:服务器 SYN-ACK 包丢失。
- 第三次握手失败:客户端 ACK 包丢失。
8. 连接怎样才算成功建立?
TCP 连接的“成功建立”需要 双方都进入 ESTABLISHED 状态,但客户端和服务器进入该状态的时机不同:
- 客户端视角: 在收到服务器的 SYN-ACK 后,立即发送 ACK,并 同时进入 ESTABLISHED 状态。此时即使该 ACK 报文后续丢失,客户端仍认为连接已建立,可立即发送数据。
- 服务器视角: 必须 实际收到客户端的 ACK(第三次握手)后,才从 SYN-RCVD 状态转入 ESTABLISHED 状态,认为连接真正建立。
因此,只有当双方都进入 ESTABLISHED 状态时,TCP 连接才算完全成功建立。
若第三次握手丢失,服务器会重传 SYN-ACK(最多tcp_synack_retries次,一般默认 5 次),若仍无响应则放弃连接;而客户端若已发送数据,服务器在收到数据时也可推断连接应已建立,从而完成状态转换(这是 TCP 的优化机制之一)。
3. 确认应答(ACK)机制
TCP 的确认应答机制就是:接收方通过 ACK 告诉发送方“到某个字节为止我都收到了,下一个字节从这里开始发”,发送方据此判断哪些数据已经成功送达、哪些需要重传,从而保证数据传输的可靠性和有序性。
TCP 将每个字节的数据都做了编号,即为序列号。每一个 ACK 都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据,下一次你从哪里开始发。
4. 超时重传机制
TCP 的超时重传机制是为了在网络不可靠的环境中确保数据可靠传输而设计的重要机制。其核心思想是:当发送方(如主机 A)发送数据后,若在一定时间内未收到接收方(如主机 B)返回的确认应答(ACK),就认为该数据可能丢失,从而触发重传。 ACK 没收到,可能是因为数据包丢了,也可能是 ACK 自己丢了。TCP 通过 序列号 来识别重复数据,避免重复交付。
由于网络状况动态变化,固定的超时时间难以适应所有场景:设得太短会导致不必要的重复重传,浪费带宽;设得太长则会降低传输效率。因此,TCP 采用 动态调整的超时时间,并结合 指数退避策略 来平衡效率与可靠性。具体机制如下:
基于序列号去重:接收方利用 TCP 报文中的序列号识别重复数据包,自动丢弃重复内容,确保数据不被重复处理。
初始超时时间:系统通常以 500 毫秒(0.5 秒)为基本单位(如 Linux、BSD、Windows 等),首次重传的超时时间设为 500ms 的整数倍。
指数退避(Exponential Backoff):若重传后仍未收到 ACK,则下一次重传的等待时间按指数增长,例如:
- 第 1 次重传:等待 1 × 500ms
- 第 2 次重传:等待 2 × 500ms
- 第 3 次重传:等待 4 × 500ms
- 第 4 次重传:等待 8 × 500ms
- 以此类推(即每次重传间隔翻倍)。
重传上限:当重传次数达到系统设定的阈值(如 Linux 默认约 15 次),TCP 会判定连接异常(如网络中断或对端宕机),主动终止连接。
通过这种动态、自适应的超时重传机制,TCP 便能在各种网络环境下 兼顾传输的可靠性与效率。
5. 连接管理机制
1. TCP 是“面向连接”的协议
什么是“面向连接”: TCP 不是直接在两台主机之间通信,而是在 两个端点(socket)之间建立一条虚拟的、可靠的逻辑通道,这条通道称为“连接”。 每个连接由 四元组 唯一标识:(源IP, 源端口, 目标IP, 目标端口),一台服务器可以同时与多个客户端建立多个独立的 TCP 连接。
为什么需要“连接”: 如果没有连接的概念,服务器只有一个全局接收缓冲区,所有客户端的数据都会混在一起,无法区分来源,也无法保证顺序、可靠性等。连接的存在,使得每个通信对都有独立的发送/接收缓冲区、序列号、窗口、状态等,从而实现可靠传输。
结论:TCP 的所有可靠性机制(如重传、确认、流量控制、拥塞控制)都是 基于连接 的,而不是主机到主机的。
2. 操作系统如何管理 TCP 连接?
OS 采用“先描述,再组织”的方式管理连接:
描述:用一个内核数据结构(如
struct sock或TCB– Transmission Control Block)来描述一个连接,包含:四元组信息、发送/接收缓冲区、序列号、确认号、状态(LISTEN、ESTABLISHED、CLOSE_WAIT 等)、超时重传计时器、拥塞控制参数等。组织:将所有连接结构体组织成高效的数据结构(如哈希表、链表、红黑树,主要是采用分层 + 哈希 + 链表的混合结构),便于快速查找、插入、删除。
建立连接 = 分配并初始化一个连接结构体 + 插入管理结构 。
断开连接 = 从管理结构中移除 + 释放资源(缓冲区、内存等)。
⚠️ 成本:每个连接都占用内存和 CPU 资源,因此高并发服务器需优化连接管理(如使用连接池、epoll 等)。
3. 为什么建立连接需要“三次握手”?
1. 一次握手行不行?
如果客户端发 SYN → 服务器收到后直接认为连接建立?那么就会产生 问题:服务器无法确认客户端是否真的能收到自己的响应。如果 SYN 是旧的重复包(比如网络延迟很久后到达),服务器会错误地建立连接,浪费资源。
2. 两次握手行不行?
- 客户端发 SYN
- 服务器回 SYN+ACK,认为连接已建立
- 客户端收到后开始发数据
产生的问题:如果客户端的 SYN 丢失了 ACK(即服务器的 SYN+ACK 未被客户端收到),客户端不会重发,但服务器已经认为连接建立了,会一直等待数据 → 资源浪费(SYN Flood 攻击原理),更严重的是:服务器无法确认客户端是否具备接收能力。
服务器在处理 TCP 连接时维护两个关键队列:半连接队列 存放已收到客户端 SYN、回复了 SYN+ACK 但尚未收到最终 ACK 的“半连接”;全连接队列 则存放三次握手已完成、等待应用程序(如 Web 服务)调用
accept()处理的“全连接”。当半连接因客户端未回 ACK 而超时、全连接队列溢出,或客户端与服务器对连接状态认知不一致时,就会出现 连接不一致问题,导致资源浪费甚至正常连接被拒绝。SYN 洪水(SYN Flood) 正是利用半连接队列的弱点:攻击者发送大量伪造源 IP 的 SYN 请求,却故意不回 ACK,使服务器堆积大量无效半连接,耗尽队列资源。为放大攻击效果,黑客通常操控大量被控制的“肉机”(即傀儡机)组成僵尸网络,协同发起攻击。2000 年初那种 “人多 → 肉机多,在浏览器上不断点刷新让服务器无响应” 的攻击,本质是早期简单的分布式拒绝服务(DDoS),虽和 SYN 洪水利用 TCP 漏洞的原理不同,但核心都是靠大量肉机(或真实用户设备)持续提升请求量,耗尽服务器的带宽、CPU、内存等资源,最终让服务器无法响应正常用户。
3. 三次握手如何解决?
- 第一次:客户端证明自己能发
- 第二次:服务器证明自己能收+能发
- 第三次:客户端证明自己能收(确认收到服务器的 SYN)
三次握手确保了:
- 双方都能收、能发(双向通信能力验证)
- 防止历史重复连接请求造成错误建立
- 初始化双方的序列号(避免数据混淆)
三次是最小可靠次数:少于三次无法同时验证双向通信能力;多于三次则冗余。
4. 三次握手绝对可靠吗?
在 正常网络 下,三次握手能可靠建立连接。但在极端情况,仍可能失败。不过 TCP 的设计目标是在 不可靠网络上提供可靠传输,三次握手是理论和实践平衡下的最优解。
4. 为什么断开连接需要“四次挥手”?
因为 TCP 是 全双工 的:数据可以同时双向传输。断开时,每个方向都要独立关闭。
为什么不能合并第 2、3 步?
- 第 2 步是 对客户端 FIN 的确认,必须立即响应(否则客户端会重传 FIN)。
- 第 3 步是 服务器主动发起关闭,但服务器可能还有数据要发送,不能立刻发 FIN。
所以:ACK 和 FIN 通常不能合并(除非服务器在收到 FIN 时恰好没有待发数据,此时可合并为一次:SYN+ACK 类似,但叫 FIN+ACK)。
5. 套接字(Socket)、connect、accept 与三次握手的关系
1. connect() 的本质
客户端调用 connect() → 触发 第一次握手(SYN),内核自动完成后续两次握手,connect() 返回时,三次握手已完成,连接已建立(ESTABLISHED)。
2. accept() 的本质
服务器调用 listen() 后进入监听状态,当收到客户端的 SYN(第一次握手),内核会为该连接创建一个 新的 socket 结构体(子 socket),accept() 的作用是:从已完成连接队列中取出这个新 socket,并返回给应用程序,注意: 三次握手在 accept() 调用 之前 就已完成!
关键结论:三次握手由操作系统内核自动完成,与应用程序是否调用
accept()无关。即使服务器没有调用accept(),只要listen()了,内核仍会响应 SYN,完成三次握手,并将连接放入“已完成队列”。如果accept()不及时调用,队列满后新连接会被拒绝(backlog 限制)。
3. 没有 accept() 能建立 TCP 连接吗?
能!连接在内核层面已经建立(ESTABLISHED 状态)。但应用程序无法通过 accept() 获取该连接的 socket,也就无法读写数据。连接会一直占用内核资源,直到超时或被关闭。
6. 小结
| 问题 | 回答 |
|---|---|
| 为什么 TCP 是面向连接的? | 为了隔离不同通信对,提供独立的可靠传输通道(缓冲区、序列号等)。 |
| 操作系统如何管理连接? | 用结构体描述连接,用数据结构组织,增删查改即连接管理。 |
| 为什么三次握手? | 最小次数验证双方收发能力,防止历史连接干扰。两次不够,一次更不行。 |
| 三次握手绝对可靠吗? | 在常规网络下可靠,是工程与理论的最优平衡。 |
| 为什么四次挥手? | 全双工需双向关闭,每方向关闭需 FIN+ACK。 |
| 第 2、3 次挥手为何不能合并? | 服务器需先 ACK 客户端 FIN,再等自己数据发完才发自己的 FIN。 |
| connect/accept 的本质? | connect 触发握手;accept 从内核队列取已建立的连接。 |
| 没有 accept 能建立连接吗? | 能!三次握手由内核完成,accept 只是应用层获取连接句柄。 |
| 连接建立成功和 accept 有关系吗? | 无直接关系。连接在 accept 前已建立,accept 只是“领取”连接。 |
6. 理解 TIME_WAIT 状态
1. 如何触发 TIME_WAIT?
当 主动关闭连接的一方(即首先发送 FIN 的那一端)完成四次挥手的最后一步(发送最后一个 ACK)后,会进入 TIME_WAIT 状态,并持续一段时间(通常是 2MSL)。
举例:客户端调用
close()→ 发送 FIN → 最终进入 TIME_WAIT。
2. 用 netstat 查看 TIME_WAIT
1 | netstat -nltp # root |
3. 2MSL 是什么?
MSL(Maximum Segment Lifetime)表示 TCP 报文在网络中 最大存活时间(通常为 30~120 秒,Linux 中 MSL 默认 30 秒),超过 MSL 的报文会被路由器丢弃。
TIME_WAIT = 2MSL 是 TCP 可靠关闭的保障机制,即:2MSL = 2 × MSL
- TIME_WAIT 的持续时间 = 2MSL(Linux 默认 2×30 = 60 秒)。
- 目的有两个:
- 确保最后一个 ACK 能到达对方: 如果 ACK 丢失,对方会重发 FIN,本端仍处于 TIME_WAIT 可再次响应 ACK,避免对方 stuck 在 LAST_ACK。
- 防止旧连接的“迷途报文”干扰新连接: 等待 2MSL 后,网络中所有属于该连接的旧报文都已消失,此时即使复用相同四元组(如快速重启服务),也不会混淆数据。
tcp_fin_timeout可以控制 FIN_WAIT_2 状态的超时时间(即本端发了 FIN,对方 ACK 了,但对方迟迟不发自己的 FIN)。但它不影响 TIME_WAIT 的持续时间 TIME_WAIT 的时长仅由 2MSL 决定,在 Linux 中硬编码为 60 秒(不可通过 sysctl 直接修改)。
1
2
3
4 >cat /proc/sys/net/ipv4/tcp_fin_timeout # 默认 60(秒),影响 FIN_WAIT_2
># TIME_WAIT 始终是 60 秒(2 * 30),不受此参数影响
>echo 30 > /proc/sys/net/ipv4/tcp_fin_timeout # 将 FIN_WAIT_2 时间缩短到 30 秒TIME_WAIT 虽不可改时长,但可优化其影响:使用
SO_REUSEADDR,避免服务器主动关闭连接(让客户端先 close),调整net.ipv4.tcp_max_tw_buckets(限制 TIME_WAIT 数量,超限则提前清除)。
7. 解决 TIME_WAIT 状态引起的 bind 失败的方法(作业)
这是我们经常会遇到的一个问题:服务器程序(如 Web 服务)监听 0.0.0.0:8080,程序退出后立即重启 → 调用 bind(8080) 失败,会报错:Address already in use。
原因:
- 上次连接由服务器主动关闭 → 服务器进入 TIME_WAIT。
- TIME_WAIT 期间,四元组(特别是本地 IP+端口)仍被占用。
- 新程序尝试
bind同一个端口 → 内核拒绝(默认不允许复用处于 TIME_WAIT 的地址)。
解决方法:使用 SO_REUSEADDR 选项(man setsockopt)
1 | int opt = 1; |
- 作用:允许新 socket 绑定到处于 TIME_WAIT 状态的地址/端口。
- 注意:
SO_REUSEADDR不能复用 ESTABLISHED 状态的端口,只对 TIME_WAIT 有效。
这就是为什么几乎所有服务器程序在
bind前都会设置SO_REUSEADDR。
8. 理解 CLOSE_WAIT 状态
1. 如何进入 CLOSE_WAIT?
对端(如客户端)发送 FIN → 本端(服务器)收到后,内核自动回复 ACK,此时本端 TCP 状态变为 CLOSE_WAIT,表示:对端已关闭,本端应尽快调用 close() 关闭自己的方向。
2. CLOSE_WAIT 的问题
CLOSE_WAIT 本身是正常状态,但如果长期存在,说明应用程序忘记调用 close()!后果:
- 文件描述符泄漏。
- 内存泄漏(接收缓冲区不释放)。
- 最终耗尽系统资源,服务崩溃。
3. 排查 CLOSE_WAIT
1 | netstat -an | grep CLOSE_WAIT |
根本解决:检查代码,确保在读到 EOF(recv 返回 0)后调用
close()。























