TCP 专章:三次握手之后,可靠管道里发生了什么?
专栏:《计算机网络基础》· 第三篇·传输层
承接:《UDP 专章:不握手的快件》
讲解维度: 概念 → 原理 → 应用 → 问题定位 + 可运行示例
读完你能: 讲清握手/挥手与常见状态;写出 TCP 服务端/客户端;理解粘包;用 ss/tcpdump 排连接问题
导读:从「明信片」到「水管」
UDP 是一封封明信片;TCP 更像两端之间的可靠水管:
你 write("你好世界") ──► [ TCP 管道 ] ──► 对端 read() 按序得到字节
│ │
│ 丢了会重传、乱了会排好
│ 对方太忙会让你慢点发
本篇先建立正确直觉,再动手写代码,最后用命令看「连接状态」。
一、概念:TCP 是什么?
1.1 一句话
TCP(Transmission Control Protocol):面向连接、可靠、基于字节流的传输层协议。
|
关键词 |
人话 |
|---|---|
|
面向连接 |
传数据前先握手,传完大致要挥手 |
|
可靠 |
尽量做到:不丢、不乱、不重复(在可能范围内) |
|
字节流 |
不替你保留「消息边界」(和 UDP 数据报不同) |
|
全双工 |
同一连接上双方都能同时发 |
1.2 和 UDP 对照(复习)
|
UDP |
TCP |
|
|---|---|---|
|
连接 |
无 |
有 |
|
可靠 |
否 |
是(重传+确认等) |
|
保序 |
否 |
是 |
|
消息边界 |
有(数据报) |
无(流) |
|
速度/开销 |
更轻 |
更重 |
|
典型 |
DNS、直播 |
Web、SSH、数据库 |
1.3 在栈里的位置
HTTP / SSH / 数据库协议 …
│
▼
TCP ← 本专章
│
▼
IP
二、原理:握手、可靠、窗口、状态
2.1 三次握手:为什么是三步?
客户端 服务器
│──────── SYN ────────────►│ 「我想连,序号从 x 起」
│◄────── SYN+ACK ──────────│ 「好,我的从 y 起;确认收到 x」
│──────── ACK ────────────►│ 「确认收到 y;开始传数据」
│ ESTABLISHED │
直觉目的:
-
确认双方 收发能力 正常;
-
同步 初始序号,后面才能发现丢包/重复;
-
防止「旧的迟到 SYN」莫名其妙建连(历史原因之一)。

2.2 四次挥手:为什么分手也麻烦?
TCP 全双工:一边说完了,另一边可能还有数据要发完。
主动关闭方 被动关闭方
│──────── FIN ────────────►│ 「我发完了」
│◄─────── ACK ─────────────│ 「知道了」
│ │ (对方可能继续发剩余数据)
│◄─────── FIN ─────────────│ 「我也发完了」
│──────── ACK ────────────►│ 「好」
之后主动方通常进入 TIME-WAIT(等一会儿再彻底释放),避免旧包干扰新连接。
2.3 可靠传输三件套(核心)
① 序号 seq + 确认 ack
「你发的第 N 号字节我收到了」
② 超时重传 / 快速重传
「等不到确认,再发一遍」
③ 滑动窗口
「一次允许多少数据在路上还没确认」
简化示意:
已确认 已发送未确认 窗口内还可发 不能发
████████░░░░░░░░░░░░░░░░░░░░│░░░░░░░░░░░░░░░░░░░░│░░░░░░░░
▲ ▲
左沿(已确认) 右沿(窗口限制)
2.4 流量控制 vs 拥塞控制(再强调)
|
流量控制 |
拥塞控制 |
|
|---|---|---|
|
怕谁 |
接收方来不及 |
网络路径堵了 |
|
信号 |
接收窗口 rwnd |
丢包/延迟 → 调 cwnd |
|
类比 |
货架满了别送 |
高速堵车大家减速 |
发送方实际可发 ≈ min(rwnd, cwnd)(概念层)。
2.5 常见连接状态(看 ss 会用到)
|
状态 |
人话 |
|---|---|
|
LISTEN |
服务端在窗口等客 |
|
SYN-SENT |
客户端已发 SYN,在等回应 |
|
SYN-RECV |
服务端收到 SYN,回了 SYN-ACK,在等最终 ACK |
|
ESTABLISHED |
已接通,可传数据 |
|
FIN-WAIT-* / CLOSE-WAIT / LAST-ACK |
正在关闭的不同阶段 |
|
TIME-WAIT |
主动关闭方「冷静期」 |
|
CLOSED |
没了 |
高并发短连接 → TIME-WAIT 变多:常见,不一定是故障
半开很多 SYN-RECV → 可能被打或积压
大量 CLOSE-WAIT → 应用没及时 close,值得查代码
2.6 字节流与「粘包 / 拆包」
TCP 不保证「一次 write = 一次 read」。
发送方连续 write:
write("HELLO")
write("WORLD")
接收方可能:
一次读到 "HELLOWORLD" ← 粘在一起
或先 "HEL" 再 "LOWORLD" ← 拆开了
所以应用层必须自己定边界:
-
固定长度
-
分隔符(如
n) -
长度前缀(先发 4 字节长度再发内容)——最常用
下文示例会演示长度前缀。
三、应用:谁在用 TCP?
|
场景 |
说明 |
|---|---|
|
HTTP/1.1、HTTP/2 |
经典网页与 API(HTTP/3 改走 QUIC) |
|
SSH / 数据库 / SMTP |
要完整可靠 |
|
多数 RPC 框架默认 |
gRPC 也可基于 HTTP/2→TCP |
|
文件传输 |
完整性优先 |
开发者日常:你写的业务代码很少手搓 TCP,但 超时、连接池、TIME-WAIT、探活 都踩在 TCP 语义上。
四、示例程序:可运行的 TCP 收发
Python 3;两个终端。先启动 server,再跑 client。
4.1 最小 Echo 服务端
tcp_server.py:
#!/usr/bin/env python3
"""TCP Echo:一行进,一行出(以 \n 为边界,避免粘包教学混在一起)"""
import socket
HOST, PORT = "0.0.0.0", 8888
def handle(conn: socket.socket, addr):
print(f"[server] client {addr} connected")
with conn:
f = conn.makefile("rwb")
for line in f: # 按行读,边界=换行
if not line:
break
print(f"[server] recv {line!r}")
f.write(b"ECHO: " + line)
f.flush()
print(f"[server] client {addr} closed")
def main():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST, PORT))
s.listen(128)
print(f"[server] listening TCP {HOST}:{PORT}")
while True:
conn, addr = s.accept()
handle(conn, addr) # 演示用同步;生产用线程/异步
if __name__ == "__main__":
main()
4.2 最小客户端
tcp_client.py:
#!/usr/bin/env python3
import socket
import sys
SERVER = ("127.0.0.1", 8888)
def main():
msg = (" ".join(sys.argv[1:]) if len(sys.argv) > 1 else "hello TCP") + "n"
with socket.create_connection(SERVER, timeout=3) as s:
print(f"[client] connected, send {msg!r}")
s.sendall(msg.encode())
data = s.recv(4096)
print(f"[client] reply: {data!r}")
if __name__ == "__main__":
main()
运行:
# 终端1
python3 tcp_server.py
# 终端2
python3 tcp_client.py
python3 tcp_client.py 你好挂号件
同时另开终端观察连接:
ss -antp | grep 8888
# 应能看到 ESTAB 或短连接后的 TIME-WAIT
4.3 粘包实验:故意不按行协议
tcp_sticky_server.py(一次性读大缓冲,打印原始字节):
#!/usr/bin/env python3
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 8889))
s.listen(1)
print("sticky server :8889")
conn, addr = s.accept()
print("from", addr)
# 故意等一会儿,让客户端多次 send 先到达
import time; time.sleep(1)
data = conn.recv(4096)
print("one recv got:", data)
conn.close()
s.close()
tcp_sticky_client.py:
#!/usr/bin/env python3
import socket
import time
s = socket.create_connection(("127.0.0.1", 8889))
s.send(b"HELLO")
s.send(b"WORLD")
time.sleep(0.1)
s.close()
python3 tcp_sticky_server.py # 终端1
python3 tcp_sticky_client.py # 终端2
你很可能看到 一次 recv 得到 b'HELLOWORLD' —— 这就是粘包体感。
4.4 长度前缀协议(推荐写法)
约定:先发 4 字节大端长度,再发 payload。
tcp_framed.py:
#!/usr/bin/env python3
"""长度前缀帧:解决粘包/拆包"""
import socket
import struct
import sys
HDR = struct.Struct("!I") # 4字节无符号长度
def send_frame(sock: socket.socket, payload: bytes) -> None:
sock.sendall(HDR.pack(len(payload)) + payload)
def recv_exact(sock: socket.socket, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("peer closed")
buf += chunk
return buf
def recv_frame(sock: socket.socket) -> bytes:
(length,) = HDR.unpack(recv_exact(sock, 4))
if length > 10 * 1024 * 1024:
raise ValueError("frame too large")
return recv_exact(sock, length)
def run_server(port=8890):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", port))
s.listen(1)
print(f"framed server :{port}")
conn, addr = s.accept()
with conn:
print("client", addr)
while True:
try:
payload = recv_frame(conn)
except ConnectionError:
break
print("got", payload)
send_frame(conn, b"ACK:" + payload)
def run_client(port=8890):
with socket.create_connection(("127.0.0.1", port)) as s:
for msg in [b"alpha", b"beta", b"gamma"]:
send_frame(s, msg)
print("reply", recv_frame(s))
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "server":
run_server()
else:
run_client()
python3 tcp_framed.py server # 终端1
python3 tcp_framed.py # 终端2
结论: 业务消息边界要自己定;TCP 只保证字节可靠有序。
4.5 观察握手(可选)
# 终端1:抓 8888
sudo tcpdump -ni lo -c 20 'tcp port 8888'
# 终端2:起 server + client
python3 tcp_server.py &
python3 tcp_client.py
在回环口上应能看到 SYN / SYN-ACK / ACK 交换。
五、问题定位:TCP 翻车怎么查?
5.1 现象速查
|
现象 |
可能原因 |
先做什么 |
|---|---|---|
|
Connection timed out |
路径丢弃、安全组、黑洞 |
ping;抓包有无 SYN-ACK |
|
Connection refused |
到达主机但未 LISTEN |
|
|
建连很慢 |
丢包重传、路由绕路、策略 |
|
|
传大数据中途卡住 |
拥塞、窗口、对端不读 |
|
|
大量 TIME-WAIT |
短连接高频 |
评估连接池/长连接;未必调参 |
|
大量 CLOSE-WAIT |
应用未 close |
查服务端代码与 fd 泄漏 |
5.2 命令清单
# 谁在听
ss -lntp
# 连接状态分布
ss -ant | awk 'NR>1{print $1}' | sort | uniq -c | sort -rn
# 看某端口
ss -antp | grep :8888
# 客户端侧快速测
curl -v --connect-timeout 3 telnet://127.0.0.1:8888
# 或
nc -vz 127.0.0.1 8888
5.3 排障流程图

5.4 和 UDP 排障差异
UDP:常靠超时猜
TCP:常有 refused / reset / 重传计数 / 状态机可观察
六、实验清单(建议全做)
|
编号 |
实验 |
收获 |
|---|---|---|
|
A |
Echo 收发 |
TCP 最小闭环 |
|
B |
|
状态不是玄学 |
|
C |
粘包实验 |
理解字节流 |
|
D |
长度前缀帧 |
正确应用协议写法 |
|
E |
tcpdump 看握手 |
SYN 三步长什么样 |
七、附录:C 语言最小 Echo(Linux)
tcp_server.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main(void) {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int yes = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
listen(lfd, 128);
printf("TCP server :8888n");
for (;;) {
struct sockaddr_in peer;
socklen_t plen = sizeof(peer);
int cfd = accept(lfd, (struct sockaddr *)&peer, &plen);
char buf[4096];
ssize_t n = read(cfd, buf, sizeof(buf));
if (n > 0) {
write(cfd, "ECHO:", 5);
write(cfd, buf, n);
}
close(cfd);
}
}
tcp_client.c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main(int argc, char **argv) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server = {0};
server.sin_family = AF_INET;
server.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &server.sin_addr);
connect(fd, (struct sockaddr *)&server, sizeof(server));
const char *msg = argc > 1 ? argv[1] : "hello";
write(fd, msg, strlen(msg));
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf)-1);
if (n > 0) { buf[n] = 0; printf("%sn", buf); }
close(fd);
return 0;
}
gcc -o tcp_server tcp_server.c && gcc -o tcp_client tcp_client.c
./tcp_server &
./tcp_client world
本章小结
TCP = 面向连接的可靠字节流
握手同步,挥手告别,TIME-WAIT 防旧包
序号/ACK/重传/窗口构成可靠与节奏
流量控制护接收方,拥塞控制护整网
字节流无消息边界 → 应用层自己定帧
示例:socket STREAM + accept/connect + 长度前缀
排障:ss 看状态,tcpdump 看 SYN,区分 refused/timeout