接收是发送的逆过程:一层层剥头、校验、上交,最后交给应用。 UDP 保留消息边界,TCP 不保留——这是写网络程序最容易踩的坑。
2.5.3 讲的是「怎么过链路」,这一节讲「到了之后怎么处理」。 这一节的实测是「UDP 保边界、TCP 合流」,两个结果直接对比。
一、剥四层
接收端做的事,正好是发送端反过来:
| 步骤 | 动作 | 检查什么 | |---|---|---|---| | 链路层 | 看目的 MAC、算 CRC | 是不是给我的、有没有坏 | | 网络层 | 看目的 IP、减 TTL | 是不是给我的、还能不能转 | | 传输层 | 看端口、算校验和 | 给哪个程序、数据对不对 | | 应用层 | 按协议解释 | 语义对不对 |
第一行那个「是不是给我的」有三种答案: 是我的(收下)、是广播或组播(也收)、不是我的(丢掉)。 所以网卡在混杂模式下能收到「不是我的」包, 正常模式则直接丢弃——这是硬件层面的过滤,不消耗 CPU。 第二行那个 TTL 减一不只是转发时要减:即使是发给本机的包,TTL 也会被处理, 减到 0 就丢并回 ICMP 超时。 所以「ping 不通但能看到超时」说明路径是通的,只是跳数用完了。 第三行那个「算校验和」的结果是「不匹配就静默丢弃」:接收端不会告诉发送端「你的包坏了」, 发送端只能靠超时重传。 所以网络故障常常表现为「慢」而不是「错」。
二、UDP 保边界,TCP 不保
同一份数据,两种协议收到的东西不一样:
| 协议 | 发了几次 | 收到几次 | 边界 |
|---|---|---|---|
| UDP | 3 次(5/10/20 字节) | 3 次 | 保留 |
| TCP | 2 次(5+10 字节) | 1 次 15 字节 | 合并 |
第一行那个「保留」是 UDP 的定义:一个数据报就是一整条消息,
接收端 recvfrom 一次拿到一个完整的数据报。
所以 UDP 不需要「自己定边界」。
第二行那个「合并」是 TCP 的定义:它是「字节流」,不保留任何消息边界。
发两次不代表收两次,可能合成一次、也可能拆成三次。
这不是 bug,是设计:TCP 的设计目标是「可靠地传一串字节」,
它根本不关心「这几个字节属于同一条消息」。
所以「消息边界」这件事,用 TCP 就得自己造。
三、为什么要自己定边界
三种常见做法:
| 做法 | 怎么做 | 缺点 |
|---|---|---|
| 长度前缀 | 每条消息前加 4 字节长度 | 要自己解析 |
| 分隔符 | 用 \r\n\r\n 之类标记 |
内容里不能出现 |
| 定长 | 每条固定长度 | 浪费、不灵活 |
第一种是 HTTP 的做法:Content-Length 就是长度前缀,
接收端先读头拿到长度,再读那么多字节的正文。
这也是为什么「少一个 Content-Length 就会串包」。
第二种是 SMTP/Redis 的做法:用分隔符表示「这条消息完了」。
但内容里要是出现了分隔符,就得转义——这就是「转义」这套麻烦事的来源。
HTTP 还有第三种:分块编码(chunked):每块前面加长度、最后用 0 长度块结尾。
它解决了「不知道总长度时怎么发」的问题——这在流式响应里是必需的。
这个「边界」问题在回环上测不出来:因为回环太快,发两次几乎总是合成一次、或者正好分成两次。
所以「在本机测着好好的,上真实网络就出问题」是这类 bug 的典型症状。
四、动手 Demo:接收处理时,UDP 保边界而 TCP 不保
UDP 发三个不同大小的包,TCP 发两次。 看接收端各收到几次、每次多少字节。
import socket, threading, time
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(('127.0.0.1', 0))
port = s.getsockname()[1]
c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for n in (5, 10, 20):
c.sendto(b'x' * n, ('127.0.0.1', port))
s.settimeout(2)
print('UDP 收到:', [len(s.recvfrom(9999)[0]) for _ in range(3)])
srv = socket.socket()
srv.bind(('127.0.0.1', 0))
srv.listen(1)
p2 = srv.getsockname()[1]
def acc():
cc, _ = srv.accept()
time.sleep(0.2)
cc.sendall(b'%d' % len(cc.recv(9999)))
cc.close()
threading.Thread(target=acc, daemon=True).start()
c2 = socket.create_connection(('127.0.0.1', p2), timeout=3)
c2.sendall(b'y' * 5)
c2.sendall(b'z' * 10)
print('TCP 一次收到:%s 字节' % c2.recv(100).decode())
c2.close()
实跑输出:
--------------------------------------------------------------------
接收处理时,UDP 保边界而 TCP 不保
--------------------------------------------------------------------
**「UDP 是数据报、TCP 是字节流」这句话,接收端最能体现。**下面各发三个包看收到什么。
**UDP 连收三次,长度是 5/10/20**——**发送端发几个包,接收端就收到几个包,边界完整保留。**
**TCP 发了两次(5 字节 + 10 字节),接收端一次 `recv` 拿到 15 字节**——**两个包被合成了一段。**
**这不是 bug,是 TCP 的定义**:**它是「字节流」,不保留任何「消息边界」。**
**所以用 TCP 传消息必须自己定边界**——**要么给每个消息加长度前缀,要么用分隔符。**
**这是写网络程序最容易踩的坑:在回环上测着好好的,一上真实网络就「一次 recv 只收到半个消息」。**
**UDP 相反,它保留边界,但代价是「一个包就是一整条消息」**——**大了发不出去,小了效率低。**
UDP 连收三次,长度是 5/10/20:发送端发几个包,接收端就收到几个包,边界完整保留。
TCP 发了两次(5 + 10),接收端一次 recv 拿到 15 字节:两个包被合成了一段。
注意这里加了 time.sleep(0.2),
是为了让两次发送的包都到齐再读——否则可能只读到 5 字节。
这个细节本身说明「TCP 什么时候给你多少字节,完全不确定」。
所以用 TCP 传消息必须自己定边界:要么给每个消息加长度前缀,要么用分隔符。
这是写网络程序最容易踩的坑——在回环上测着好好的,一上真实网络就「一次只收到半个消息」。
UDP 相反,它保留边界,但代价是「一个包就是一整条消息」:
大了发不出去(超过 MTU 要分片),小了效率低。
所以选 UDP 就要接受「消息不能太大」。
30 秒实验
- 把
time.sleep(0.2)去掉,看 TCP 那次读到几字节——多跑几次。 - 把 UDP 的
recvfrom缓冲区改成 10,看超长包会怎样(会被截断)。 - 把 TCP 改成发三次,看接收端是一次读完还是分几次。
常见误区
- 以为 TCP「发几次就收几次」。 不保留边界,可能合也可能拆。这是设计,不是 bug。
- 以为 UDP 一定不丢包。 它保边界但不保证送达,回环上不丢不代表真实网络不丢。
- 以为在回环上测通了就没问题。 回环太快,边界问题暴露不出来, 这类 bug 往往上真实网络才现形。
自测三问(点开对答案)
1. 接收端要剥几层?每层检查什么? —— **链路层看目的 MAC 与 CRC,网络层看目的 IP 与 TTL,传输层看端口与校验和,应用层看语义。** 2. UDP 与 TCP 在「消息边界」上有什么不同? —— **UDP 保留边界(发几次收几次),TCP 不保留(可能合也可能拆)。**因为 TCP 是字节流, 它的目标只是可靠地传一串字节。 3. 用 TCP 传消息,怎么定边界? —— **长度前缀(如 `Content-Length`)、分隔符、或分块编码。** 不定边界就会「一次只收到半个消息」。第二章到此结束。下一章进入 3:数据链路—— 从「一段线」这个尺度,把以太网、无线、PPP 逐个讲透。
- ☐ (本章完,下一节 3.1 数据链路的作用)