📚 第二章 TCP/IP基础知识
📄 一句话概括

接收是发送的逆过程:一层层剥头、校验、上交,最后交给应用。 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 秒实验

  1. 把 time.sleep(0.2) 去掉,看 TCP 那次读到几字节——多跑几次。
  2. 把 UDP 的 recvfrom 缓冲区改成 10,看超长包会怎样(会被截断)。
  3. 把 TCP 改成发三次,看接收端是一次读完还是分几次。

常见误区

  1. 以为 TCP「发几次就收几次」。 不保留边界,可能合也可能拆。这是设计,不是 bug。
  2. 以为 UDP 一定不丢包。 它保边界但不保证送达,回环上不丢不代表真实网络不丢。
  3. 以为在回环上测通了就没问题。 回环太快,边界问题暴露不出来, 这类 bug 往往上真实网络才现形。
自测三问(点开对答案) 1. 接收端要剥几层?每层检查什么? —— **链路层看目的 MAC 与 CRC,网络层看目的 IP 与 TTL,传输层看端口与校验和,应用层看语义。** 2. UDP 与 TCP 在「消息边界」上有什么不同? —— **UDP 保留边界(发几次收几次),TCP 不保留(可能合也可能拆)。**因为 TCP 是字节流, 它的目标只是可靠地传一串字节。 3. 用 TCP 传消息,怎么定边界? —— **长度前缀(如 `Content-Length`)、分隔符、或分块编码。** 不定边界就会「一次只收到半个消息」。

第二章到此结束。下一章进入 3:数据链路—— 从「一段线」这个尺度,把以太网、无线、PPP 逐个讲透。

相关笔记