Old-School Networking / 2026 ・ No.07
古法网络入门
第7讲 UDP 与套接字 ── 不打招呼的信,与程序的窗口
发布
第 6 讲的 TCP 会打招呼、编号、丢了就重发,是非常周到的运送方式。这一讲的前半是这些都不做的 UDP。抛开周到之后,什么变快了,又有什么得自己承担?我们用同一条捣乱线来确认。
后半讲的是:无论 TCP 还是 UDP,程序都通过一个叫 套接字 的窗口来使用。我们用 strace 看看发送申请的 Python 和接收申请的 Node.js 里面发生了什么。
选择周到程度的一层
新井TCP 的周到真让我感动。可您上次最后说,周到有时反而碍事。
古井打电话时,对方把 1 秒前的“喂”再说一遍,你也会头疼吧。比起旧的声音,更想要现在的声音。
新井确实……那也有不重说的运送方式吗?
古井那就是 UDP。第 4 层有周到的 TCP 和几乎什么都不做的 UDP 两种,由使用者来选。
全 12 讲路线图(当前:第 7 讲)
- UDP ── 8 字节的头部、分界会保留、没人的端口、分片(实测)
- 队头阻塞 ── 丢了一个时,TCP 和 UDP 后面的通知会怎样(实测)
- 套接字 ── socket、connect、accept,用 strace 看内部(实测)
- 服务器的幕后 ── 等待接受的队列,用 epoll 接待大量客人(实测)
练习网络与第 6 讲相同,在电脑(192.168.1.10)和服务器(203.0.113.20)之间夹着单程 20 毫秒的捣乱线。为了让它也能丢 UDP 包,稍微做了补充。
UDP:不打招呼的信
试着用 UDP 发送申请。服务器上放了一个在 UDP 5000 号接收申请的小程序。
# 用 UDP 申请一次(单程 20 ms 的线)
pc$ python3 cli7.py udp
sent 59 bytes, got 46 bytes in 40.9 ms: {"id": "EXP-2026-0100", "status": "submitted"}
# 线上只有去程和回程 2 个包(第 6 讲的 TCP 是 10 个)
0.000 pc → srv UDP 59437 → 5000 length 67 (frame 101 bytes)
0.041 srv → pc UDP 5000 → 59437 length 54 (frame 88 bytes)
线上只有去程和回程 2 个包。没有招呼,没有确认的 ACK,也没有道别。到收到回复 0.041 秒,正好一个往返。第 6 讲的 TCP 为同样的申请用了 10 个包,到道别结束用了 0.132 秒。
UDP 头部 8 字节,TCP 20 字节以上
看封面照片的详情,UDP 头部只有2 个端口、长度和校验和。没有 Seq,没有 Ack,也没有窗口。也就是说,UDP 只是在 IP 包上加写了房间号,到没到、顺序对不对,一概不保证。
| TCP | UDP | |
|---|---|---|
| 招呼 | 有(1 个往返) | 没有,直接发 |
| 丢了的话 | 重发 | 就此作罢 |
| 顺序 | 重新排列 | 按到达顺序 |
| 接收方与道路的情况 | 用窗口和拥塞控制调节 | 想发多少发多少 |
| 分界 | 没有(字节流) | 保留(发一次就是一个) |
| 主要用途 | 网页、邮件、数据库 | DNS、语音视频、游戏、QUIC |
分界会保留
第 6 讲写过“TCP 不记得分界”。这次和 UDP 并排确认一下。发送 3 次 100 字节,服务器等 0.3 秒后再读取。
# 电脑:发送 3 次 100 字节(TCP 设为不攒包立即发送 TCP_NODELAY)
pc$ send(b'A' * 100); send(b'B' * 100); send(b'C' * 100)
# 线上两者都是 3 个 100 字节的包
TCP Len=100 Len=100 Len=100 UDP Len=100 Len=100 Len=100
# 服务器:等 0.3 秒后再读取
TCP recv() → [300] bytes
UDP recv() → [100, 100, 100] bytes
TCP 是一条流,UDP 是一封一封
线上两者都是 3 个 100 字节的包。可是 TCP 的接收方一次 recv() 就一起收到了 300 字节。包的分界一进入接收缓冲就消失了。UDP 发一次就是一封信(数据报),可以原样一封一封地收。用 TCP 自己设计通信时,需要事先约定分界方式,比如先写长度、用换行分隔等。
没人的端口
TCP 连接没人的端口会收到 RST。UDP 没有 RST,取而代之的是对方操作系统回复 ICMP 的“该端口未使用(Port unreachable)”。
# 向没人的 5009 号发送 UDP。服务器用 ICMP 回复“该端口未使用”
0.000 192.168.1.10 → 203.0.113.20 UDP 47 33355 → 5009 Len=5
0.040 203.0.113.20 → 192.168.1.10 ICMP 75 Destination unreachable (Port unreachable)
# connect() 过的套接字能把这个通知当作错误收到,没有 connect() 的套接字则不知情地一直等
connected socket: ConnectionRefusedError: [Errno 111] Connection refused after 40.7 ms
unconnected socket: TimeoutError: timed out after 2002 ms
同样的通知到了,程序的接收方式却有两种。用 connect() 事先确定对方的套接字,0.041 秒就得到 Connection refused 错误。没有确定对方、直接 sendto() 的套接字,无法把通知和自己对应起来,一直等到 2 秒超时。这是一个小窍门:UDP 也一样,如果对方只有一个,最好先 connect()。不过,如果在途中丢了,什么都不会返回,所以随时都需要准备超时处理。
太大的信
UDP 一封信最多能写 65,507 字节。可是线的 MTU 是 1500 字节(第 2 讲)。装不下的信,IP 会切成分片发送。我们发了一封 3000 字节的信。
# 发送一个 3000 字节的 UDP(线的 MTU 是 1500)
0.000 192.168.1.10 → 203.0.113.20 IPv4 1514 Fragmented IP protocol (proto=UDP 17, off=0, ID=56e0)
0.000 192.168.1.10 → 203.0.113.20 IPv4 1514 Fragmented IP protocol (proto=UDP 17, off=1480, ID=56e0)
0.000 192.168.1.10 → 203.0.113.20 UDP 82 58842 → 5000 Len=3000
0.041 203.0.113.20 → 192.168.1.10 UDP 56 5000 → 58842 Len=14
3000 bytes -> reply {"size": 3000} in 41.2 ms
# 让线丢掉第 2 个分片
3000 bytes -> no reply (2 s timeout)
# 30 秒后,服务器返回“重组超时”
30.470 203.0.113.20 → 192.168.1.10 ICMP 590 Time-to-live exceeded (Fragment reassembly time exceeded)
任何一个分片丢了,整封信都到不了
3000 字节加上 8 字节头部,共 3008 字节,被分成 1480、1480、48 字节的 3 片,在服务器上重组。可是让线丢掉第 2 个分片后,尽管另外 2 个都到了,信一封也没到。服务器等了 30 秒后丢弃分片,返回“重组超时”(30.47 秒)。UDP 不会重发,到此为止。
因此,UDP 的常规做法是让一封信装得进线路。DNS 在 2020 年由各方统一步调,建议默认把 UDP 的回复控制在 1232 字节以内(DNS Flag Day 2020,第 9 讲)。
队头阻塞
新井丢了就作罢,还是 TCP 更让人放心吧。
古井假装在传语音,比一比。每 20 毫秒发一个小通知,共 50 个,只丢第 10 个。
# 每 20 ms 发一个 100 字节的通知,共 50 个,只让线丢掉第 10 个。到达所需时间(ms)
No. 7 8 9 10 11 12 13 14 15
# UDP:到了 49 个。第 10 个没到,其他的不受影响
UDP 20.6 20.6 20.3 — 20.3 20.4 20.4 20.4 20.4
# TCP:50 个都到了。第 10 个因重发而延迟,第 11、12 个也在等它
TCP 20.4 20.4 20.3 80.9 60.6 40.5 20.6 20.3 20.6
丢了一个时,后面的通知会怎样(实测)
UDP 只是第 10 个没到,其余 49 个都在约 20 毫秒内到达。TCP 50 个全都送到了,但第 10 个等重发用了 80.9 毫秒。而且,已经先到的第 11、12 个,也要等第 10 个到齐才交给应用,用了 60.6 和 40.5 毫秒。为了保持顺序,队首的一个堵住了后面,这叫队头阻塞(head-of-line blocking)。
如果是语音,缺了 20 毫秒几乎察觉不到,反倒是迟到的声音更碍事。所以语音视频通话和游戏用 UDP,缺的部分自己弥补。DNS 也是一个往返就完成的短查询,所以用 UDP(第 9 讲)。而 HTTP/3 使用的 QUIC,在 UDP 之上自己构建了重发和顺序管理。它按内部的每条流分别保持顺序,一条流里丢了包,其他流也不会被堵住(第 10 讲)。
套接字:程序的窗口
新井TCP、UDP 在我的程序里怎么用?我只写过 fetch。
古井fetch 在最底层也在用操作系统的套接字。可以说是操作系统准备好的网络窗口。
套接字调用的顺序
服务器用 socket() 创建窗口,用 bind() 确定端口,用 listen() 开始监听,用 accept() 一个一个取出客人。客户端用 connect() 连接。第 6 讲的三次握手就发生在 connect 和 accept 之间。用 Python 写只需要这些。
# 服务器(最小的形式)
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # TCP 的窗口。UDP 用 SOCK_DGRAM
s.bind(('0.0.0.0', 5000))
s.listen()
while True:
c, addr = s.accept() # 取出一个已完成招呼的客人
data = c.recv(4096)
c.sendall(b'ok')
c.close()
# 客户端
c = socket.create_connection(('203.0.113.20', 5000))
c.sendall(b'hello'); print(c.recv(4096)); c.close()
是不是真的按这个顺序调用,我们用 strace(列出程序向操作系统请求了什么的工具)确认。上面是在电脑上发送一次申请的 Python,下面是接收方的 Node.js。
# 电脑:用 strace 观察直接使用套接字发送一次申请的 Python(右边是该调用花费的秒数)
socket(AF_INET, SOCK_STREAM) = 3 0.0001
connect(3, {203.0.113.20:3000}, 16) = 0 0.0408
sendto(3, "POST /api/ex…", 181) = 181 0.0001
recvfrom(3, "HTTP/1.1 201…", 4096) = 175 0.0411
recvfrom(3, "", 4096) = 0 0.0001
close(3) = 0 0.0001
# 服务器:Node.js 内部发生的事(用 epoll_pwait 等待“有东西来吗?”,来了就处理)
epoll_pwait(15, [fd 21 IN]) = 1 1.4631
accept4(21) = 22 0.0001
epoll_pwait(15, [fd 22 IN]) = 1 0.0000
read(22, "POST /api/ex…", 65536) = 199 0.0000
writev(22, ["HTTP/1.1 201…"]) = 203 0.0002
epoll_pwait(15, []) = 0 0.0001
epoll_pwait(15, [fd 22 IN]) = 1 0.0391
read(22, "", 65536) = 0 0.0001
shutdown(22, SHUT_WR) = 0 0.0002
epoll_ctl(15, EPOLL_CTL_DEL, 22) = 0 0.0000
close(22) = 0 0.0000
电脑上 socket() 返回的 3,和打开文件时一样,是“文件描述符”的编号。在操作系统看来,套接字是文件的一种。connect() 用了 0.041 秒,也就是在招呼的一个往返期间,程序停下来等待。等待回复的 recvfrom() 也是 0.041 秒。最后内容为空的 recvfrom(),表示对方发来了道别(FIN)。
等待接受的队列
招呼由操作系统完成,应用只需用 accept() 取出。那么,应用不取出会怎样?我们向 listen(1) 之后一次也不 accept 的服务器连了 4 条。
# 向 listen(1) 但从不 accept() 的服务器连 4 条(连接方 3 秒后放弃)
client 1: connected in 42 ms
client 2: connected in 41 ms
client 3: TimeoutError after 3003 ms
client 4: TimeoutError after 3003 ms
# 服务器一侧:2 条已完成招呼,在等着被取出
srv$ ss -tn sport = :5005 ; ss -tln sport = :5005
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 203.0.113.20:5005 192.168.1.10:48164
ESTAB 0 0 203.0.113.20:5005 192.168.1.10:48160
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 2 1 0.0.0.0:5005 0.0.0.0:*
完成招呼的连接,在队列里等待 accept
第 1、2 条在 0.04 秒内就被告知连上了,尽管应用什么都没做。这是因为操作系统完成了招呼,把它们排进了队列(LISTEN 的 Recv-Q 为 2)。队列满到 2(listen 的数 + 1)之后,第 3、4 条的 SYN 被悄悄丢弃,重发之后 3 秒超时。
服务器太忙、accept 跟不上时,在客户看来不是“连不上”,而是“慢”或“超时”。listen 的默认值,Node.js 是 511,Python 是 128,Linux 的上限(somaxconn)在这个内核上是 4096。
一个人接待很多客人
前面的 Python 服务器在 recv() 等待期间,无法接待其他客人。过去是给每个客人准备一个进程或线程,可是到了几千人就太重了。1999 年,一台机器处理一万个连接的难题被命名为“C10K 问题”。
问监视者(epoll)“有来的吗?”
现在的答案是把等待交给监视者。把监听套接字和客人的套接字一起登记到 Linux 的 epoll,一个线程问“有准备好的吗?”,只处理准备好的那些。前面 strace 的下半部分正是如此:Node.js 用 epoll_pwait 等了 1.46 秒,监听(21 号)来了客人,就用 accept4 取出 22 号;22 号可读了就读取,写回复,再回到 epoll。Node.js 的事件循环和 nginx 都是这种形式。
在 Node.js 里同步地写繁重的计算,期间就回不到 epoll,所有客人都得等。一个线程接待很多客人,代价是不能让一个客人久留。
在自己的电脑上确认
| 想查的内容 | Mac | Windows | Linux |
|---|---|---|---|
| UDP 端口一览 | netstat -an -p udp | netstat -an -p udp | ss -uan |
| 占用该端口的程序 | lsof -i UDP:5353 | netstat -ano -p udp | ss -uanp |
| 发送一个 UDP | nc -u 目的地 5000 | (用 Python 等) | nc -u 目的地 5000 |
| 查看程序的调用 | dtruss(有限制) | Process Monitor | strace -e trace=%network |
在 Mac 上看 netstat -an -p udp,即使自以为什么都没做,也应该能看到 5353 号(寻找同一 LAN 内机器的 mDNS)等好几个开着的 UDP 端口。
结 — 一页总结与下一讲
- UDP 的头部只有 8 字节,没有招呼、重发、顺序和窗口。申请只用去程和回程 2 个包、1 个往返。
- UDP 保留分界。100 字节×3,TCP 一次 recv 得到 300 字节,UDP 分 3 次。
- 发往没人的 UDP 端口会收到 ICMP,只有 connect() 过的套接字能作为错误收到。
- 超过 MTU 的 UDP 会被分片,丢一片就全部失去(30 秒后重组超时)。
- 丢一个包时,TCP 后面也被堵(80.9、60.6、40.5 ms),UDP 只是缺一个、不会延迟。QUIC 想在 UDP 之上兼顾两者。
- 程序通过套接字使用 TCP、UDP。套接字是文件描述符的一种。
- 招呼由操作系统完成,应用用 accept 取出。队列满了 SYN 会被悄悄丢弃。大量客人由一个线程用 epoll 接待。
新井仔细看封面照片,申请的 JSON 原样能读出来……日期和金额 1280 都看得到。
古井没错,到目前为止的申请,途中任何人都看得一清二楚,还能篡改。下一讲是第 5、6 层,TLS,给信封上锁。
参考
- RFC 768 — User Datagram Protocol
- RFC 8085 — UDP Usage Guidelines
- DNS Flag Day 2020
- RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
- Python — socket
- listen(2) — Linux manual page(backlog と somaxconn)
- Node.js — net(server.listen の backlog 511)
- epoll(7) — Linux manual page
- libuv — Design overview(Node.js のイベントループ)
- Dan Kegel — The C10K problem
记录来自笔者在云端 Linux(Ubuntu 24.04,内核 6.18)上用网络命名空间搭建的练习网络,于 2026 年 10 月 25 日抓取。捣乱线在第 6 讲的基础上增加了丢弃 UDP 包(包括 IP 分片)的功能。UDP 申请窗口、分界实验、不接受连接的服务器、通知的收发用 Python,申请 API 用 Node.js。strace 是 Ubuntu 的 strace 包,加上每次调用的耗时(-T)进行记录。表中的数字都是从这些记录和当时命令的输出读出的实际数值。登场人物均为虚构。
古法网络入门 第7讲 — UDP 与套接字 / 2026.10