listen(5) 只排得住 5 个连接:第 6 个客户端卡在 connect 超时,进程活着端口却显示假活不放行
先说最难受的那种线上现场:服务没挂,ps 能看到进程,lsof 能看到端口在 LISTEN,监控面板一片绿灯,但外面客户端就是连不上,curl 一直转到超时。运维上去重启一下又好了,第二天再来一遍。
这种状态我一般叫"假活":进程活着,端口也在监听,但新连接进不来。它和"服务挂了"最大的区别在于,你所有的存活检查都会通过,只有真正发起连接的人受罪。
这篇不讲框架,就用 Python 标准库在本机起一个靶站,把 listen(backlog) 这个参数拆开量清楚"到底能排几个"。所有数字都是本机跑出来的,代码和输出一并贴上,你可以在自己机器上复跑一遍对数字。
1 先把故障长得什么样子说清楚
假活的现场有四个特征,同时出现基本就锁定了。
- 进程活着:
ps aux | grep python有它。 - 端口在监听:
lsof -nP -iTCP:8761 -sTCP:LISTEN能打出那一行。 - 客户端行为随机:有时候
nc -z一次就通,有时候卡住;本地自测常常通,连的人一多就开始有请求挂住。 - 没有任何错误日志:因为第 6 个连接根本没进应用层,代码里的 try/except 一次都没触发。
第 3 条是最容易被误判成"网络抖动"的地方。accept 队列满了之后,内核对新来的握手要么不响应、要么回 RST,客户端在超时和拒绝之间反复横跳,表现出来就是"连得上又连不上"。
图 1:假活的四个特征,四个同时出现说明问题不在业务代码里
故障能长成这样,根子在一个很多人写完就不看的参数:listen(sock, backlog)。队列满了,新的握手就进不来,客户端只能按 TCP 的重传节奏干等。
2 环境准备:三个东西就够
不开 Docker,不装 nginx,一个 python3 加一个临时目录。
bash
python3 --version
sysctl kern.ipc.somaxconn
sysctl net.inet.tcp.somaxconn
第一行确认解释器,后两行看系统里 accept 队列的上限。有个坑得先提醒:macOS 上有效的是 kern.ipc.somaxconn,net.inet.tcp.somaxconn 在 macOS 14.5 上直接报 unknown oid(Linux 那边才用后者)。凡是照着 Linux 博客抄 net.inet.tcp.somaxconn 的,在这台机器上只会拿到一句报错。
text
$ python3 --version
Python 3.14.3
$ sysctl kern.ipc.somaxconn
kern.ipc.somaxconn: 128
$ sysctl net.inet.tcp.somaxconn
sysctl: unknown oid 'net.inet.tcp.somaxconn'
顺手说一句两个概念的区别,后面会反复用到:
- backlog 参数 :你在
listen()里传的那个整数。 - 内核上限 :
somaxconn,内核最终把队列截断到不超过它。
listen(5) 传的是 5,内核还要在 5 和 somaxconn 之间取一个它能接受的值。至于内核到底拿 5 怎么办,macOS 和 Linux 不一样,这篇的实测只用来说明"这台机器上发生了什么"。
3 最小实验:起一个永远不 accept 的监听端
关键设计是:只 listen,不 accept。这样队列永远不会被消费,撑满的速度最快,数量也最容易数。
先建临时目录,所有东西都放里面,收尾一起删。
bash
mkdir -p /tmp/probe-listen && cd /tmp/probe-listen
图 2:只 listen 不 accept 的最小监听端,队列只进不出
监听端脚本,保存成 server.py:
python
import socket, sys, time
backlog = int(sys.argv[1])
port = int(sys.argv[2])
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
s.listen(backlog)
print(f"listening backlog={backlog} port={port}", flush=True)
while True:
time.sleep(3600) # 故意不 accept
这里别偷懒写成 accept() 再睡,那样第 1 个连接就被消费掉了,队列永远只有 0 个在排,实验不成立。
客户端脚本,保存成 client.py,用它来数"第几个开始超时":
python
import socket, sys, time
port = int(sys.argv[1])
n = int(sys.argv[2])
timeout = float(sys.argv[3])
conns = [] # 故意不关,保证连接留在队列里
for i in range(1, n + 1):
c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
c.settimeout(timeout)
t0 = time.time()
try:
c.connect(("127.0.0.1", port))
conns.append(c)
print(f"#{i} ok {time.time()-t0:.3f}s", flush=True)
except socket.timeout:
print(f"#{i} TIMEOUT after {time.time()-t0:.3f}s", flush=True)
break
客户端把连上的 socket 全存在 conns 里不 close,是有意的。关掉连接,内核会把对端从队列里摘掉,你的计数马上就对不上。跑一次换一次端口,别让上一个实验的残留影响下一个。
4 实测:listen(1) 和 listen(5) 分别能排几个
先跑 listen(1)。一个终端跑服务端,另一个终端跑客户端,超时设 0.5 秒。
bash
python3 server.py 1 8701
另开终端:
bash
python3 client.py 8701 10 0.5
本机实测(Python 3.14.3 / macOS 14.5 arm64):
text
$ python3 client.py 8701 10 0.5
#1 ok 0.000s
#2 TIMEOUT after 0.502s
backlog=1,第 1 个连接秒进队列,第 2 个 0.5 秒后超时。数字和参数对得上,没什么余地。
再跑 listen(5),换端口,让服务端排队。
bash
python3 server.py 5 8705
python3 client.py 8705 10 0.5
text
$ python3 client.py 8705 10 0.5
#1 ok 0.000s
#2 ok 0.000s
#3 ok 0.000s
#4 ok 0.000s
#5 ok 0.000s
#6 TIMEOUT after 0.501s
前 5 个全部秒过,第 6 个卡死在 connect 上,0.501 秒后超时。这就是标题里那个数字的来源。
到这一步,假活的机制就清楚了:从第 6 个请求开始,TCP 握手进不了队列,客户端一直等到超时;服务端这边进程好好的,端口好好的,日志里一个字都没有。排查的时候你会以为是网络,其实是队列满了没人取。
图 3:backlog=5 时第 6 个 connect 卡住,客户端超时、服务端无日志
补一个排错时容易被带偏的点:队列外的连接,这台机器上的内核直接不响应,客户端看到的是超时。别的系统在同类场景里会出现 Connection refused(回了 RST)这种报错,看到它就断定"端口没开"同样会把方向带偏------lsof 明明能看到 LISTEN。
还有一件事要提醒:nc -z 这种"只探通不通"的工具,会自己连上又立刻断开,每探一次队列是进一个又走一个,所以你拿它连续探 10 次全通,也不代表队列没满。要数队列,必须像上面的 client.py 一样把连接攥在手里不关。
5 反常对照:macOS 上 listen(0) 实测 40 个全进
按参数推,listen(0) 应该比 1 还小,一个都排不住。实测不是这样。
bash
python3 server.py 0 8700
python3 client.py 8700 40 0.5
text
$ python3 client.py 8700 40 0.5
...
#38 ok 0.000s
#39 ok 0.000s
#40 ok 0.000s
40 个,一个不落,全部秒过。既然怀疑样本不够,就往上涨,把客户端数量加到 200 再跑:
bash
python3 server.py 0 8711
python3 client.py 8711 200 0.5
text
$ python3 client.py 8711 200 0.5
...
#127 ok 0.000s
#128 ok 0.000s
#129 TIMEOUT after 0.502s
第 129 个才超时。128 这个数与 kern.ipc.somaxconn: 128 完全对齐:listen(0) 在这台机器上不是"排 0 个",而是让内核拿默认深度来填,一封到顶正好是 somaxconn。
这条我不打算绕着写。它是平台行为差异,不是实验误差:backlog 参数最终要被内核"翻译"一遍,传 0 时 macOS 用它自己的默认值,传负数则不生效。你在 Linux 上跑同样的脚本,listen(0) 通常是 1 个都排不住或者只排 1 个,数字完全不同。
所以结论不能写成"backlog 传几就订几个"。准确的说法是:backlog 参数是一个上限请求,最终生效的是内核在参数和 somaxconn 之间裁出来的值 。传 1 生效 1,传 5 生效 5,传 0 落在默认深度 128 上------三层完全不同的逻辑,只有 lsof/连接计数能告诉你现在在哪一层。
6 一行命令判断你机器上的数字
不想摆两个脚本,把下面这段直接粘进终端,它会起监听、自己数、跑完打印结果:
bash
python3 - <<'PY'
import socket
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.listen(5)
conns, n = [], 0
while True:
c = socket.socket(); c.settimeout(0.5)
try:
c.connect(("127.0.0.1", port))
conns.append(c); n += 1
except socket.timeout:
print(f"port={port} backlog=5 max_queued={n}")
break
PY
本机实测输出:
text
port=64781 backlog=5 max_queued=5
把 s.listen(5) 改成 s.listen(1),max_queued 变成 1;改成 s.listen(0),在这台机器上会跑到 128 才停。改成 s.listen(1000),它同样卡在 128------那时封顶的是 somaxconn,和参数无关。
这两件事必须分开记:
- 你写的是 backlog 参数;
- 真正封顶的是 somaxconn;
- 两者之间还有一层"内核怎么解释你传的值",也就是
listen(0)那个反常的来源。
我这次实测到的是前两层同时起作用:backlog=1/5 时参数说了算,backlog=0 时内核默认值说了算、并被 somaxconn=128 截住。
7 靶站:让"假活"可以被别人亲眼看到
本地复现完,最容易出现的情况是你和同事各执一词------你说超时,他说他那边能连。把靶站推到公网,让对方自己撞一次,比发十张截图有用。
靶站就用第 3 节那两个脚本,换一个固定的口:
bash
cd /tmp/probe-listen
python3 server.py 5 8645
cpolar http 8645
text
Forwarding https://xxxx-xx-xx-xx-xx.cpolar.top -> http://127.0.0.1:8645
把这条 https 地址丢给同事,让他用浏览器打开,或者用 curl 连。前 5 个连接会"连上"(注意,只是 TCP 通了,没有响应数据,浏览器会一直转),第 6 个开始连不上直到超时。他看到的不是你的截图,是他自己机器上的超时。
这里有个现象值得专门看一眼:隧道本身是好的,cpolar 后台的隧道连接数正常,问题只出现在你本机的 accept 队列上。这正好反向证明瓶颈在服务端,不在链路上。
三个口径得说清楚,别被"固定地址"四个字带跑:
- 免费随机地址:24 小时内不保证同一个域名,隧道重启就换,临时复现够用,但让同事收藏就不合适了。
- 固定二级子域名:需要基础版及以上,地址长期不变。
- 固定 TCP 端口直连:需要专业版及以上,这条是真正的端口直通,不走 http 转发。
只是让别人复现一次,免费随机地址够用。要放进团队文档、以后天天点,才需要考虑固定地址。
图 4:cpolar 把本机 8645 的靶站挂出去,异地同事连第 6 次超时
8 总结
现在已经做完的是一件很具体的事:在一个从不 accept 的监听端上,量出了 listen(1) 只能排 1 个、listen(5) 只能排 5 个、第 6 个客户端在 0.5 秒内 connect 超时;同时撞见了 macOS 上 listen(0) 一路排到第 128 个才卡住的反常表现,并把它归因到内核解释参数的那一层,而不是含糊带过。
几个要点:
- 假活不是挂,是队列满。进程、端口、监控都正常,只有新连接在 connect 阶段卡住,日志一定干净。
- backlog 参数不等于生效深度 。真正封顶的是
somaxconn,listen(0)这类边界值还多一层内核翻译,各平台结果不一样,必须以自己机器上的实测为准。 - 一行判断法:第 6 节那段脚本改一个数字,就能量出自己环境里的真实排队深度。
真要往生产上加,思路是把 accept 做得足够快------慢的活丢进线程池或者队列,别在 accept 循环里干重活;配套把 backlog 设成和预期并发匹配的值,而不是沿用框架默认。想让异地同事一起复现、一起判断,就把靶站用 cpolar 挂出去让他自己连,比来回复述现象省事得多。
实验环境与清理说明:所有测试只用 127.0.0.1 回环,脚本和日志都在 /tmp/probe-listen 这个临时目录里,没有改动任何系统内核参数、没有申请 root、没有占用已有的服务端口。实验结束后 kill 掉所有测试进程,确认 pgrep -f probe-listen 无输出、lsof 里没有残留监听,再 rm -rf /tmp/probe-listen,临时文件一并删净。
本机实测环境:Python 3.14.3 / macOS 14.5 arm64,kern.ipc.somaxconn=128。文中所有数字都是这台机器上跑出来的,换机器请按第 6 节自己量一遍。