TCP 专章:三次握手之后,可靠管道里发生了什么?

AI2天前发布 beixibaobao
5 0 0

专栏:《计算机网络基础》· 第三篇·传输层
承接:《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       │

直觉目的:

  1. 确认双方 收发能力 正常;

  2. 同步 初始序号,后面才能发现丢包/重复;

  3. 防止「旧的迟到 SYN」莫名其妙建连(历史原因之一)。

TCP 专章:三次握手之后,可靠管道里发生了什么?

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

ss -lnt

建连很慢

丢包重传、路由绕路、策略

curl -v 看 Connect;mtr

传大数据中途卡住

拥塞、窗口、对端不读

ss -ti 看重传/窗口(版本支持时)

大量 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 排障流程图

TCP 专章:三次握手之后,可靠管道里发生了什么?

5.4 和 UDP 排障差异

UDP:常靠超时猜
TCP:常有 refused / reset / 重传计数 / 状态机可观察

六、实验清单(建议全做)

编号

实验

收获

A

Echo 收发

TCP 最小闭环

B

ss 观察 ESTAB/TIME-WAIT

状态不是玄学

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
© 版权声明

相关文章