【Linux网络】三十七.TCP 协议通讯流程

一条主线:服务器 socket → bind → listen → accept,客户端 socket → connect,然后 read/write 双向收发,最后 close 四次挥手。 本文按"初始化 → 建立连接 → 数据传输 → 断开连接"四个阶段展开,每一步都标出应用层调用和TCP 协议栈行为的对应关系,并补全 TCP 状态机、TIME_WAIT 坑、SO_REUSEADDR 等实战必知的知识点

目录

  1. 服务器初始化流程

  2. 建立连接:三次握手

  3. 数据传输过程

  4. 断开连接:四次挥手

  5. [TCP 状态机全景图](#TCP 状态机全景图)

  6. [实战必知:TIME_WAIT 与 SO_REUSEADDR](#实战必知:TIME_WAIT 与 SO_REUSEADDR)

  7. [TCP 与 UDP 核心对比](#TCP 与 UDP 核心对比)


1. 服务器初始化流程

cpp 复制代码
socket()  →  bind()  →  listen()  →  accept()
  ①          ②          ③            ④
步骤 系统调用 作用 失败原因
socket(AF_INET, SOCK_STREAM, 0) 创建一个 TCP 套接字,返回文件描述符(如 fd=3) 进程 fd 耗尽
bind(sockfd, &addr, len) 将 fd 与指定 IP + 端口绑定 端口已被占用、权限不足(1024 以下需 root)
listen(sockfd, backlog) 将 fd 标记为监听套接字,开始等待连接 一般不失败(参数合法时)
accept(sockfd, &peer, &len) 阻塞 等待,返回一个全新的已连接 fd 被信号中断返回 EINTR

1.1关键细节:accept 返回的不是原来的 fd

cpp 复制代码
int listenfd = socket(...);
bind(listenfd, ...);
listen(listenfd, 8);          // listenfd 一直是"监听socket"

while (true) {
    int connfd = accept(listenfd, ...);  // connfd 是"已连接socket",是一个全新的 fd!
    // listenfd 继续负责"接客",connfd 负责"服务"
    // 用 connfd 做 read/write,用 listenfd 继续做下一轮 accept
}

初学者最常见的混淆 :以为 accept 返回的就是原来那个 socket fd。实际上 listenfdconnfd 是两个不同的描述符------listenfd 始终负责监听新连接(循环 accept),connfd 负责与这个特定客户端读写数据(用完 close)。一个 listenfd 在生命周期内会派生出很多个 connfd


1.2listen 的第二个参数:backlog

cpp 复制代码
listen(listenfd, 8);   // backlog = 8

backlog 是**全连接队列(accept 队列)**的最大长度。当队列满了且没有线程在 accept,新到的连接会被内核拒绝(客户端看到 Connection resetconnect 超时)。这不是"最多同时服务 8 个客户端",而是"最多允许 8 个已握手但还没被 accept 取走的连接排队等待"。


2. 建立连接:三次握手

2.1两个角色

  • 服务器 :调用 listen 后进入 LISTEN 状态,被动等待;

  • 客户端 :调用 connect 后主动发起,进入 SYN_SENT 状态。


2.2三次握手过程

cpp 复制代码
客户端                              服务器
  |                                   |
  | -------- SYN (seq=x) -----------> |  ① 第一次握手
  |        connect() 阻塞中            |   服务器 SYN_RCVD
  | <------- SYN+ACK (seq=y,ack=x+1)--|  ② 第二次握手
  |   connect() 返回(内核自动发ACK)    |
  | ----------- ACK (ack=y+1) ------> |  ③ 第三次握手
  |       ESTABLISHED                 |   ESTABLISHED
次数 谁发给谁 报文内容 含义
① 第一次 客户端 → 服务器 SYN, seq=x "我想和你建立连接"
② 第二次 服务器 → 客户端 SYN+ACK, seq=y, ack=x+1 "同意,我也想建立连接"
③ 第三次 客户端 → 服务器 ACK, ack=y+1 "确认,连接建立"

2.3应用层与协议栈的对应

应用层调用 TCP 协议栈行为 TCP 状态变化
客户端 connect() 内核发送 SYN CLOSEDSYN_SENT
服务器收到 SYN 内核自动回复 SYN+ACK LISTENSYN_RCVD
客户端收到 SYN+ACK 内核自动回复 ACK,connect 返回 0 SYN_SENTESTABLISHED
服务器收到 ACK 内核自动处理 SYN_RCVDESTABLISHED

注意connect() 是一个阻塞调用 ------它会在 SYN_SENT 等待,直到三次握手完成(收到 SYN+ACK 并内核已发 ACK)才返回 0;若超时或被拒(RST),返回 -1 并设 errno

第三次握手的 ACK 是客户端内核自动发送 的,应用层感知不到------connect 返回就代表握手完成了。


2.4为什么是三次,不是两次?

因为三次才能确认双向通道都通:

  • 第一次(SYN):服务器确认"客户端 → 服务器"方向能收;

  • 第二次(SYN+ACK):客户端确认"服务器 → 客户端"方向能收;

  • 第三次(ACK):服务器确认"客户端 → 服务器"方向能收(确认客户端收到了自己的 SYN+ACK)。

两次握手只能验证单向连通,无法防止历史旧连接的 SYN 延迟到达导致错误建立连接。


3. 数据传输过程

连接进入 ESTABLISHED 后,双方可以全双工 通信:同一条 TCP 连接上,同一时刻双方都可以同时发送和接收数据。

原文说"半双工:同一时刻只能一方发"------这描述的是半双工的概念,不是 TCP 的行为。TCP 是全双工的,同一时刻双方都能发。容易引起误解,特此澄清。

3.1典型的请求-响应流程

cpp 复制代码
客户端                              服务器
  |  --- write("hello") --------->  |  read 返回 >0,读到 "hello"
  |  read 阻塞等待应答...            |  处理请求...
  |  <-- write("world") ----------- |
  |  read 返回 >0,读到 "world"      |  read 阻塞等待下一条
  |  --- write("next") -----------> |  ...
谁在做什么 客户端 服务器
发请求 write(sockfd, req, len) read(connfd, buf, sz) 返回 >0
等应答 read(sockfd, buf, sz) 阻塞 处理请求
回结果 读到应答,read 返回 write(connfd, resp, len)
循环 继续 write 下一条 继续 read 等下一条

3.2read 返回值的三种含义

read 返回值 含义 对端发生了什么
n > 0 成功读到 n 字节 对端 write 了数据
n == 0 对端关闭了连接(读到 EOF) 对端调用了 close(发了 FIN)
n < 0 出错(设 errno 被信号中断(EINTR)、连接异常(ECONNRESET)等

read 返回 0 是一个关键信号:它不代表"没读到数据",而是代表"对端关闭了连接"。这与读普通文件到文件尾的行为完全一致------因为 Linux 一切皆文件,socket 也是文件。


3.3字节流特性:没有消息边界

TCP 是字节流协议,不保留消息边界:

cpp 复制代码
// 客户端连续 write 两次
write(sockfd, "AB", 2);
write(sockfd, "CD", 2);

// 服务器一次 read 可能收到:
// "AB"      (第一次数据)
// "ABCD"    (两次合并!)
// "ABC"     (部分第二次)
// "AB" "CD" (分两次收到)

应用层需要自己定协议来切分消息,常见方案:

  • 定长报文(每条固定 N 字节);

  • 特殊分隔符(如 \r\n,HTTP 用的就是这种);

  • 长度前缀(头 4 字节存 body 长度,最可靠)。


4. 断开连接:四次挥手

4.1四次挥手过程

复制代码
客户端                                        服务器
  |                                             |
close()                                         |
  | --- FIN (seq=m) --------------------------> | ① 第一次挥手
(FIN_WAIT_1)                                    | (收到FIN,read返回0)
  |                                             |
  | <-- ACK (ack=m+1) ------------------------- | ② 第二次挥手
(FIN_WAIT_2)                                    | (CLOSE_WAIT)
  |                                             |
  |                                             | close()  <-- 注意这个动作的位置
  | <-- FIN (seq=n) --------------------------- | ③ 第三次挥手
  |                                             | (LAST_ACK)
  | --- ACK (ack=n+1) ------------------------> | ④ 第四次挥手
(TIME_WAIT, 等 2MSL)                            | (CLOSED)
  |
  V
CLOSED
次数 谁发给谁 报文 含义
客户端 → 服务器 FIN "我没有数据要发了"
服务器 → 客户端 ACK "知道了,你先别发了"
服务器 → 客户端 FIN "我也没有数据要发了"
客户端 → 服务器 ACK "好的,再见"

4.2为什么是四次而不是三次?

核心原因:TCP 是全双工的,每个方向的关闭是独立的 (这叫半关闭 half-close)。

cpp 复制代码
方向1(客户端→服务器):FIN ① → ACK ②    = 2 次关闭一个方向
方向2(服务器→客户端):FIN ③ → ACK ④    = 2 次关闭另一个方向
                                         合计 4 次

与三次握手对比:握手时服务器把 SYN 和 ACK 合成一个报文 发了(SYN+ACK),所以只用了 3 次;但挥手时,服务器收到客户端 FIN 后可能还有数据没发完,所以先回 ACK(②),等自己数据发完了再发 FIN(③),② 和 ③ 之间有时间差,不能合并。

如果服务器收到 FIN 时恰好没有待发数据,② 和 ③ 可以合并成一个 FIN+ACK,这样看起来就像"三次挥手"------但这是特殊情况,标准描述是四次。


4.3应用层与协议栈的对应

应用层调用 TCP 协议栈行为 TCP 状态变化
主动方 close() 内核发送 FIN ESTABLISHEDFIN_WAIT_1
被动方 read 返回 0 内核已收到 FIN,自动回 ACK ESTABLISHEDCLOSE_WAIT
被动方 close() 内核发送 FIN CLOSE_WAITLAST_ACK
主动方收到 FIN 内核自动回 ACK FIN_WAIT_2TIME_WAIT
主动方等 2MSL 定时器超时 TIME_WAITCLOSED

5. TCP 状态机全景图

cpp 复制代码
主动close (FIN_WAIT_1)                        被动close (CLOSE_WAIT)
       |                                            |
       | --- FIN (seq=m) ------------------------> | (第一次挥手)
       |                                            | (收到FIN,进入CLOSE_WAIT)
       | <-- ACK (ack=m+1) ------------------------ | (第二次挥手,注意这里是单独的ACK)
(FIN_WAIT_2)                                       |
       |                                            | close()
       | <-- FIN (seq=n) -------------------------- | (第三次挥手,注意这里是单独的FIN)
       |                                            | (进入LAST_ACK)
       | --- ACK (ack=n+1) -----------------------> | (第四次挥手)
(TIME_WAIT)                                        | (收到ACK,进入CLOSED)
       |
       | 等2MSL
       V
    CLOSED
状态 谁会出现 含义
LISTEN 服务器 listen 后,等待连接
SYN_SENT 客户端 connect 后,已发 SYN,等 SYN+ACK
SYN_RCVD 服务器 收到 SYN,已回 SYN+ACK,等 ACK
ESTABLISHED 双方 连接建立,可收发数据
FIN_WAIT_1 主动关闭方 已发 FIN,等 ACK
FIN_WAIT_2 主动关闭方 收到对方 ACK,等对方 FIN
CLOSE_WAIT 被动关闭方 收到对方 FIN,己方还没 close
LAST_ACK 被动关闭方 己方已 close 发 FIN,等最后 ACK
TIME_WAIT 主动关闭方 已发最后 ACK,等 2MSL 防丢包
CLOSED 双方 完全关闭

排查工具netstat -anp | grep <端口>ss -tan | grep <端口>,能看到当前连接停在哪个状态。


6. 实战必知:TIME_WAIT 与 SO_REUSEADDR

1.TIME_WAIT 是什么,为什么存在

主动调用 close 的一方,发完最后一个 ACK 后进入 TIME_WAIT,持续 2MSL(Linux 默认约 60 秒)。它的两个作用:

  1. 保证最后一个 ACK 能到达:如果 ACK 丢了,对方会超时重发 FIN,我方在 TIME_WAIT 内还能重发 ACK;

  2. 防止旧连接的延迟报文干扰新连接:2MSL 时间足够让本次连接的所有残留报文在网络中消失。


2.坑:服务器重启 bind 失败

cpp 复制代码
$ ./tcpserver 8080
# Ctrl+C 停止
$ ./tcpserver 8080
bind error: Address already in use

原因:上次服务器的 listenfd 刚 close,可能进入 TIME_WAIT,端口还"占着"没释放。


3.解决:SO_REUSEADDR

cpp 复制代码
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
//                                         ↑ 允许绑定 TIME_WAIT 状态的端口

bind(listenfd, ...);
listen(listenfd, ...);

SO_REUSEADDR 告诉内核:"这个端口虽然还在 TIME_WAIT,但允许我重新绑定",服务器可以立即重启。

这是 TCP 服务器开发的标配写法,生产代码几乎都会加。


4.CLOSE_WAIT 过多说明什么

如果 netstat 看到大量 CLOSE_WAIT,说明被动关闭方没有及时调用 close() ------通常是对端发了 FIN,但本方代码逻辑有 bug(如只 break 了循环但忘了 close(fd)),导致 fd 泄漏、连接卡在 CLOSE_WAIT


7. TCP 与 UDP 核心对比

a. 可靠性:可靠传输 VS 不可靠传输

TCP UDP
确认应答 每个包都要 ACK 发完就不管了
超时重传 超时自动重发 丢了就丢了
流量控制 滑动窗口,防压垮接收方 发多快是多快
拥塞控制 慢启动/拥塞避免 不管网络状态
顺序保证 按序到达,乱序重排 可能乱序

b. 连接性:有连接 VS 无连接

TCP UDP
建立连接 必须三次握手 不需要
断开连接 必须四次挥手 不需要
对方状态 双方都清楚(状态机维护) 不知道对方在不在
编程模型 socket→bind→listen→accept + socket→connect socket→bind 直接 recvfrom/sendto

c. 传输形态:字节流 VS 数据报

TCP UDP 示例
边界 无边界,像水流 有边界,像快递 ---
一次 write 100 字节 对方可能分多次收齐 对方一次 recvfrom 读全 TCP:可能收 50+50;UDP:一次收 100
应用层切分 必须自己定协议(定长/分隔符/长度前缀) 天然带边界,无需处理 HTTP 用 \r\n\r\n 分隔
底层抽象 文件 IO(read/write/文件流) 数据报(recvfrom/sendto) ---

总结一句话

UDP 是"写信":信封上写好地址,丢进邮筒就不管了,简单粗暴但可能丢;

TCP 是"打电话":先拨号建立连接,边说边确认对方听清了,说完双方各自挂断,复杂但可靠。

网络编程底层的收发操作,本质上就是文件 IO 操作 ------在 Linux 哲学里,socket 也是文件,read/write 既能操作普通文件,也能操作网络套接字。

相关推荐
顶点多余1 小时前
9.15 面试问题总结(一面挂)
linux·面试·职场和发展
顺风尿一寸2 小时前
从 ls -U 到 Tomcat libs加载jar包:ext4 目录哈希序如何影响 Java 文件排序顺序
linux·jvm
qeen872 小时前
【Linux】操作系统之进程介绍(一)
linux·笔记·学习·进程
邪修king2 小时前
Re:Linux系统篇(二十五):文件系统(一):磁盘硬件底层原理:从物理结构到 CHS/LBA 寻址,搞懂硬盘数据的定位逻辑
java·linux·运维·gpt
小则又沐风a2 小时前
TCP协议讲解-----了解TCP可靠性的基石
linux
刚入门的大一新生2 小时前
Linux-简单设计libc库
linux·c++
科小墨3 小时前
【Linux 开发者系列 · 第 1 节】NPM 完全指南:它是什么、为什么重要、在 Linux 上怎么用
linux
IT北辰3 小时前
Linux运维:sed命令修改配置文件IP
linux
牢姐与蒯3 小时前
Linux文件(三).Ext系列文件系统
linux·运维·服务器