一条主线:服务器 socket → bind → listen → accept,客户端 socket → connect,然后 read/write 双向收发,最后 close 四次挥手。 本文按"初始化 → 建立连接 → 数据传输 → 断开连接"四个阶段展开,每一步都标出应用层调用和TCP 协议栈行为的对应关系,并补全 TCP 状态机、TIME_WAIT 坑、SO_REUSEADDR 等实战必知的知识点。
目录
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。实际上
listenfd和connfd是两个不同的描述符------listenfd始终负责监听新连接(循环 accept),connfd负责与这个特定客户端读写数据(用完 close)。一个listenfd在生命周期内会派生出很多个connfd。
1.2listen 的第二个参数:backlog
cpp
listen(listenfd, 8); // backlog = 8
backlog是**全连接队列(accept 队列)**的最大长度。当队列满了且没有线程在 accept,新到的连接会被内核拒绝(客户端看到Connection reset或connect超时)。这不是"最多同时服务 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 | CLOSED → SYN_SENT |
| 服务器收到 SYN | 内核自动回复 SYN+ACK | LISTEN → SYN_RCVD |
| 客户端收到 SYN+ACK | 内核自动回复 ACK,connect 返回 0 |
SYN_SENT → ESTABLISHED |
| 服务器收到 ACK | 内核自动处理 | SYN_RCVD → ESTABLISHED |
注意 :
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 | ESTABLISHED → FIN_WAIT_1 |
被动方 read 返回 0 |
内核已收到 FIN,自动回 ACK | ESTABLISHED → CLOSE_WAIT |
被动方 close() |
内核发送 FIN | CLOSE_WAIT → LAST_ACK |
| 主动方收到 FIN | 内核自动回 ACK | FIN_WAIT_2 → TIME_WAIT |
| 主动方等 2MSL | 定时器超时 | TIME_WAIT → CLOSED |
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 秒)。它的两个作用:
保证最后一个 ACK 能到达:如果 ACK 丢了,对方会超时重发 FIN,我方在 TIME_WAIT 内还能重发 ACK;
防止旧连接的延迟报文干扰新连接: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 既能操作普通文件,也能操作网络套接字。