listen(5) 只排得住 5 个连接:第 6 个客户端卡在 connect 超时,进程活着端口却显示假活不放行

listen(5) 只排得住 5 个连接:第 6 个客户端卡在 connect 超时,进程活着端口却显示假活不放行

先说最难受的那种线上现场:服务没挂,ps 能看到进程,lsof 能看到端口在 LISTEN,监控面板一片绿灯,但外面客户端就是连不上,curl 一直转到超时。运维上去重启一下又好了,第二天再来一遍。

这种状态我一般叫"假活":进程活着,端口也在监听,但新连接进不来。它和"服务挂了"最大的区别在于,你所有的存活检查都会通过,只有真正发起连接的人受罪。

这篇不讲框架,就用 Python 标准库在本机起一个靶站,把 listen(backlog) 这个参数拆开量清楚"到底能排几个"。所有数字都是本机跑出来的,代码和输出一并贴上,你可以在自己机器上复跑一遍对数字。

1 先把故障长得什么样子说清楚

假活的现场有四个特征,同时出现基本就锁定了。

  1. 进程活着:ps aux | grep python 有它。
  2. 端口在监听:lsof -nP -iTCP:8761 -sTCP:LISTEN 能打出那一行。
  3. 客户端行为随机:有时候 nc -z 一次就通,有时候卡住;本地自测常常通,连的人一多就开始有请求挂住。
  4. 没有任何错误日志:因为第 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 节自己量一遍。

相关推荐
梯度下降者1 小时前
我校学子斩获 2025 全国高新技术竞赛 Python 赛道全国一等奖
python·教育·竞赛·信息技术·人才培养
小小张说故事1 小时前
Python 的 memoryview 为什么能零拷贝:拆开 buffer protocol、cast 和那道 BufferError
python
Hvitur2 小时前
Java - Lambda
java·开发语言·python
2601_967980112 小时前
win10如何把python加入环境变量
python·win10·安装教程·命令行·环境变量
锋行天下10 小时前
langgraph 后端发自定义数据,前端接收方法概括
人工智能·python
benchmark_cc12 小时前
用 QuantDash 读取单只股票五档盘口:抽样、缺失值检查与复现记录
python·数据分析·pandas·量化交易·股票数据·quantdash
曲径海盗12 小时前
【Python 学习第 55 天】列表基础
python·学习笔记·编程入门
专注仿真12 小时前
知识问答模型用到的算法集详解及代码实例
python·算法·代码·大模型训练·控制算法
jyOverQ13 小时前
我写了一个 CSDN Markdown 图片助手:告别本地图片逐张上传
python·工具·csdn