云计算百科
云计算领域专业知识百科平台

句柄上限 512,并发 254 就撞墙:预算怎么算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。

把那段二十几行的脚本在本地跑一遍,软上限压到 512,进程起来时已经占了 4 个 fd,然后每组"请求"占 2 个且不释放,屏幕上会依次停在这两行:不释放:成功占用 254 组后,第 255 次分配失败,预算公式:并发上限 = (512 – 4 – 预留 16) / 每请求 2 = 246。同一台机器、同一个上限,一个说 254,一个说 246。

这两个数字没有谁算错,它们在回答两个不同的问题:254 是"最多能塞多少",246 是"能塞多少还留得出余量"。工程上真正能写进配置的是后者,而绝大多数容量估算出问题,不是公式写错,是把前者当成了后者。

先把「并发数」和「句柄数」拆开:它们不是一回事

并发是"同一时刻在处理多少件事",句柄是"进程同时攥着多少个内核对象"。这两者只有在"每件事占用固定个数"这个前提下才成正比,而这个前提恰恰是最容易不成立的一环——所以估容量时先别问并发多少,先问每个请求占几个。

句柄是进程手里的插座,不是线程的

文件描述符(file descriptor,下面简称 fd)是进程打开文件、套接字、管道时,内核发回来的一个小整数。你拿到的不是那个对象本身,只是一个编号;以后每次读写,都是把这个编号递回内核,让它替你去操作真正的对象。这个设计有个直接后果:编号是有限资源,用完了就再也开不了新的,哪怕内存还剩一大半、CPU 还空着。

Python 文档对这条上限的定义很干脆:「RLIMIT_NOFILE — The maximum number of open file descriptors for the current process.」主语是 the current process,不是机器、不是容器、也不是账号。同一个进程里开 8 个线程还是 1 个线程,账都记在同一本上。所以"我这台机器内存够"这句话,对句柄问题一点解释力都没有。

软上限是此刻生效的线,硬上限是它碰不到的天花板

同一份文档把这两条线的关系写清楚了:「Each resource is controlled by a pair of limits: a soft limit and a hard limit. The soft limit is the current limit, and may be lowered or raised by a process over time. The soft limit can never exceed the hard limit.」

翻成人话:软上限是现在正在生效的那个数字,进程自己就能往上调;硬上限是它调不过去的那道坎,通常要更高的权限才动得了。这条语义直接决定排障顺序——先读软上限,再看它离硬上限还有多少空间。如果软上限已经贴着硬上限,"把数字调大"这条路当场就断了,只能回头去压每请求占用。

本文不去背各系统、各发行版的默认上限,一是那些数值随内核版本和启动方式变化,二是背下来也用不上:正确的做法是在你自己的进程里读一次,读到的才是你的。下面第 4 章那段脚本第一步干的就是这件事。

三条线经常被混成同一条

排障时最常听到的描述是"并发上不去"。可并发、线程数、句柄数是三条独立的线,卡在不同的地方,表现也不一样。分不清是哪条线,改的东西就是错的。

指标它数的是什么谁在限制它撞上时你先看到什么
并发数 同一时刻在处理多少个请求 你的代码与框架的并发模型 请求开始排队,延迟抬升
线程数 同时有多少个执行栈 线程栈内存与调度开销 上下文切换变多,内存上涨
句柄数 进程同时握着多少个内核对象 RLIMIT_NOFILE 的软上限 新连接根本建不起来

把这张表读完,一句话就够用了:并发高不一定句柄高,但句柄高到顶,并发一定上不去。反过来,句柄够用也不代表并发能上去——那可能是线程池或者下游在卡你。这就是为什么"加机器"经常没用:瓶颈在进程内的编号表上,加一台机器只是多了几个同样会被塞满的进程。

每一个请求到底占几个句柄,要数出来而不是猜

每个请求的句柄占用记作 k。k 是个能数出来的整数,不是"大概一两个"。把它估小一半,容量就会算大一倍,然后在最不该出问题的时候出问题。

一组 socketpair 就是两个 fd,不是一

先看一条最干净的曲线:请求数从 50 一路加到 400,看进程的 fd 数怎么变。

🧪 实测环境:Python 3.13.12 / macOS / 仅用标准库

import os
import socket

def fd_count():
return len(os.listdir("/dev/fd"))

print("请求数 忘了关时的 fd 数 用完即关的 fd 数")
for n in (50, 100, 200, 400):
leak = [socket.socketpair() for _ in range(n)]
leaked = fd_count()
for a, b in leak:
a.close()
b.close()
for _ in range(n):
a, b = socket.socketpair()
a.close()
b.close()
print(f"{n:>6} {leaked:>17} {fd_count():>17}")

请求数 忘了关时的 fd 数 用完即关的 fd 数
50 104 4
100 204 4
200 404 4
400 804 4

四行读数:104、204、404、804。把它们和第一列的请求数对一下,规律是 2n + 4——每个请求贡献 2 个,另外有 4 个是进程自己的家底。这个 2 不是"一个连接一个 fd"的常识,而是因为一组 socketpair 有两端,两端都在同一个进程里,就都记在你头上。

第三列更有意思:不管跑 50 次还是 400 次,用完立刻关掉之后,fd 数恒为 4。这一列是"没有泄漏"长什么样,记住它,后面所有排障都是拿当前 fd 数跟这个基线比。

要说明的是,脚本把一组 socketpair 当成"一次请求的两端",是为了把 k=2 这种情况摆到台面上。真实服务里 k 由请求路径决定:只有入站连接时可能是 1,接了上游、又写临时文件时就可能是 3、4。所以下面这张清点表,你要按自己的路径重填一遍。

把一次请求的路径摊开数

数 k 的方法不是猜,是把请求走一遍,凡是"开了没关"的地方都记一笔。下面是一个接了上游、又落临时文件的请求,逐项清点:

句柄来源本例占几个什么时候释放漏了之后的表现
入站连接 1 请求处理完关闭连接 请求堆积时阶梯上升,不回落
上游连接 1 用完归还给池 池被借空后请求开始排队
临时文件 1 写完即删 临时目录里残留文件越来越多
日志文件 0(进程常驻) 进程退出时 不随请求增长,不计入 k

加起来 k=3。换成你的服务,把这张表按真实路径重填,加出来的和才是你的 k。注意最后一行:常驻的日志句柄、监听套接字、配置文件句柄,都算在基线里,不乘并发数——它们天天在,只是不涨。

k 要取峰值,不是均值

一个请求在中途可能同时持有比稳态更多的句柄:读配置文件时开着文件、同时又向上游要数据,两个同时在手;等数据处理完,文件关了,上游连接还占着。算容量要用这个峰值,因为撞墙只发生在峰值那一刻,而均值会把这个瞬间抹平。

还有一条更好用的性质:泄漏的曲线是线性的,斜率就是 k。上一节那张表里,忘了关时 fd 数是 2n+4,一条笔直的斜线;这条斜线和软上限的交点,就是你撞墙的准确位置。所以看到 fd 数随时间单调上升,不用怀疑,先去找那条漏关的路径。

预算公式:软上限、基线占用、预留三块

能拿来分配的从来不是软上限本身,是"软上限减去基线、再减去预留"之后剩下的那一截,再除以 k。这三块少算任何一块,算出来的并发都是纸面上的。

三块开销各自怎么读

软上限一行代码就能读:resource.getrlimit(resource.RLIMIT_NOFILE) 返回一对值,第一个就是当前生效的软上限。基线也好读:进程启动完、还没接第一个请求时,数一次 fd 个数,本文这台机器上是 4。难的是第三块。

预留是留给"不在请求路径上、但照样要开 fd"的动作的:日志轮转要新建文件、健康检查要建连接、连接池重建要重新握手、配置热加载要打开新文件。这些动作不会因为你在跑满负载就停下来等你。本文统一取 16,理由有两条:一是 2 的整数倍,除下来整齐;二是够 8 组临时对,突发时够用。

把三块写成一个式子,就是本文唯一的公式:

并发上限 =(软上限 − 基线 − 预留)÷ 每请求占用 k,向下取整

向下取整不是抠门,是必须——多算半个请求也是多算,而多算的那半个正好会在满载时开不出 fd。

把同一个公式代进四种情形

情形软上限基线预留k并发上限
理论装满(不留预留) 512 4 0 2 254
本文取值 512 4 16 2 246
k 变大到 3 512 4 16 3 164
基线被吃掉一大截 512 100 16 2 198

第一行 254 和第 4 章的实测输出对得上,因为脚本本来就是按这个式子铺的。它同时暴露了一个容易被忽略的状态:装满 254 组的时候,进程手上一个空闲 fd 都没有了。这时候哪怕只是想写一行日志、想把错误信息回给客户端,都可能因为开不出新 fd 而失败——数字是对的,状态是不能上线的。

第三行是在说另一件事:k 从 2 变成 3,并发上限就从 246 掉到 164,掉了三分之一。这就是为什么"数出来"比"猜一个"重要,k 每多一,容量就少一大块。

第四行更值得警惕:基线从 4 涨到 100,公式里直接少掉 48 个并发。基线为什么会长?最常见的是启动时预加载了一堆文件句柄、或者框架自己开了监听端口和内部管道。这类开销在压测时看不见,因为它不随请求变化,只在算容量时咬你一口。

想调大上限之前,先确认 k 里没有泄漏

"跑 1000 次后 fd 数仍为 4"和"50 到 400 时恒为 4"其实在回答同一个问题:关掉之后有没有回来。回来了,说明 k 里没有泄漏,容量问题就是纯算术,套公式就行;没回来,说明你在跟一条斜线赛跑,调大上限只是把撞墙时间往后推,斜率不变。

判据可以写得很死:压 1000 次请求,结束后 fd 数与基线的差在 2 以内,算没有泄漏;差值随请求数线性增长,就是泄漏。这条判据不需要任何工具,数一下 /dev/fd 目录里的条目就够了——本文脚本里的 fd_count() 干的就是这件事。顺带说明,这个路径是 macOS 上的写法,换到别的系统不一定存在,脚本那一行要换成对应平台的等价写法(本文未在其他系统核验,标待验证)。

用一个最小服务跑通,看它在哪一步撞墙

三个常数不用猜,二十几行脚本就能量出来。量完你会拿到四个数字:软上限、基线、撞墙点、以及带上预留之后的答案。

三步量出三个常数

第一步把软上限压到一个好算的值,同时读出硬上限,确认压得下去;第二步一组一组地占、不释放,直到分配失败,记下成功了多少组;第三步改成用完即关,同样跑 1000 次,看 fd 数有没有回到基线。三步都在同一个进程里做完,中间不需要重启。

🧪 实测环境:Python 3.13.12 / macOS / 仅用标准库

import os
import resource
import socket

def fd_count():
return len(os.listdir("/dev/fd"))

soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (512, hard))
limit, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
base = fd_count()
print(f"软上限 {limit},启动基线占用 {base} 个 fd")

held, iters = [], 0
try:
while iters < 1000:
held.append(socket.socketpair()) # 每次请求占 2 个 fd,忘了关
iters += 1
except OSError as e:
print(f"不释放:成功占用 {len(held)} 组后,第 {iters + 1} 次分配失败,错误类型 {type(e).__name__}")
print(f"不释放:可用 {limit – base} 个,每组 2 个,理论装满 {(limit – base) // 2} 组")

for pair in held:
for s in pair:
s.close()

for _ in range(1000):
a, b = socket.socketpair() # 用完立刻关闭
a.close()
b.close()
print(f"正确释放:连跑 1000 次后 fd 数仍为 {fd_count()}")

fds_per_req, reserve = 2, 16
print(f"预算公式:并发上限 = ({limit} – {base} – 预留 {reserve}) / 每请求 {fds_per_req} "
f"= {(limit – base – reserve) // fds_per_req}")

软上限 512,启动基线占用 4 个 fd
不释放:成功占用 254 组后,第 255 次分配失败,错误类型 OSError
不释放:可用 508 个,每组 2 个,理论装满 254 组
正确释放:连跑 1000 次后 fd 数仍为 4
预算公式:并发上限 = (512 – 4 – 预留 16) / 每请求 2 = 246

输出里那四个数字分别在说什么

第一行的 512 和 4 是公式的分子:软上限和基线。这两个数换台机器、换个启动方式就会变,所以必须自己跑一次,抄别人的没有意义。

第二行的 254 和 255 是"刚好装满"的证据,必须连着读。254 组用掉 508 个 fd,正好等于 512 减 4;第 255 次要 2 个,手上一个都没有,于是分配失败。前一个数字告诉你最多能装多少,后一个告诉你从哪一次开始装不下。

第三行是把理论值再说一遍,防止你把"成功占用"和"理论装满"当成两回事——它们在这里数值相同,是因为 512 减 4 恰好能被 2 整除。换个不能被整除的上限,这两个数就会差一组,那时候差的那一组就是取整吃掉的空间。

第四行的 4 是"关掉就回来"的证据。这一行比前几行都值得抄进你的巡检脚本:它给出的是这台机器、这个框架下的基线,以后任何时候 fd 数明显高过它且不回落,就是泄漏。

第五行才是可以写进配置的数:246。

254 到 246 差的不是 8 个请求,是突发空间

两者相差 8 组,正好是预留的 16 除以每请求的 2。这 8 组不是"多接 8 个请求"的余量,而是在已经跑满的时候,还能腾出手来写一行日志、接一个健康检查、重建一条断掉的上游连接。

没有这 8 组,服务不会在 254 个请求时礼貌地拒绝,而是在第 255 个请求到来时连错误信息都发不出去——因为发错误信息也要 fd。这是句柄类故障最难看的地方:它不给你留下解释的机会。

这份资料是什么:一张把"上限、基线、预留、每请求占用"四件事串起来的容量估算清单,配合本文的公式填一遍就能落到你自己的服务上。放在资料包里,扫码即可获取:

配套资料获取

按现象排障:看到什么现象就改哪一层

句柄类故障只有三种形状,形状决定了你该改的是代码、是 k、还是上限。改错层的代价是反复返工,因为改完还会以同样的形状回来。

三种现象对应三层

现象更可能的原因先改哪一层具体处置
重启后能跑,跑一阵才开始失败,失败前 fd 数单调上升 某条路径漏关 代码 压 1000 次请求,对比结束时的 fd 数与基线
一上线就失败,几十个并发就撞 k 太大,每请求占 3 个以上 请求路径 把上游连接改成池,让 k 里的可变项变成常数
软上限已经贴着硬上限,调不动 上限本身太小 运行环境 先压 k 与预留,再考虑提硬上限
只在高峰期失败,平时正常 没有留预留 预算 按公式重算,预留取 k 的 8 倍以上

四行的读法是一样的:先看 fd 数随时间的形状,再看失败发生的时间点。单调上升是泄漏,一上来就撞是 k 太大,调不动是上限到顶,只在高峰撞是没留余量。形状对了,改的地方就只有一个。

什么时候该上连接池,什么时候只改数字

连接池只做一件事:把"每个请求向上游要一个 fd"改成"固定若干条连接反复借还"。原来 k 里那一项随并发线性增长,进池之后它变成一个常数项,从公式的分子里被挪走了——这就是池子能提容量的真实原因,不是它更快,是它把乘法变成了加法。

判断条件也就一条:数一下 k 里上游连接占的比例。超过一半,上池;不到一半,先把泄漏修掉。池子治不了泄漏,只会把泄漏点从"每请求一次"变成"每条连接一次"——症状变慢,但没消失,而且更难查,因为它不再随请求数线性上升。

阈值取多少,什么时候停手

三条能直接抄的:

  • 告警线取公式值的八成。 本文这组数是 246,八成就是 197 附近,落到监控上取整成 200 触发,留出人工介入的时间。
  • 预留取 k 的 8 倍,且不小于 16。 k=2 时是 16,k=3 时是 24;小于 16 的话,一次日志轮转加一次健康检查就能把余量吃干净。
  • 停手条件:压 1000 次请求后 fd 数回到基线上下 2 以内,且峰值不超过告警线。 两条同时满足就不用再动上限,容量问题已经在算术层面解决了;只满足前一条说明 k 还是偏大,该去压请求路径。
  • 最后给一个判断顺序,遇到"并发上不去"就照着走:先读软上限和基线,算出公式值;再压 1000 次,看 fd 数回不回落;回落就把并发上限配成公式值、告警线配成八成;不回落就先修泄漏,修完再重算。什么时候该上连接池,看你数出来的 k 里上游占几成,超过一半再上——不到一半就上池,等于花钱把一个代码问题藏起来。

    这份资料是什么:一份按"现象 → 原因 → 处置"整理的服务容量排障清单,和上面这张表是同一套口径,可以直接对着勾。放在资料包里,扫码即可获取:

    配套资料获取

    附表 A:本文引用事实与出处对照表

    事实出处本文位置
    每种资源由软上限与硬上限一对值控制,软上限是当前生效值、进程可调,且永远不能超过硬上限 Python 文档 resource,Python Software Foundation,https://docs.python.org/3/library/resource.html(2026-11-08 抓取) 第 1 章「软上限是此刻生效的线」
    RLIMIT_NOFILE 指当前进程可打开的文件描述符最大数量 同上(2026-11-08 抓取) 第 1 章「句柄是进程手里的插座」
    软上限 512、基线 4 时,不释放的第 255 次分配失败,理论装满 254 组 本文实跑 work/exp1108/e03_fd.py 的真实输出 第 4 章实测块
    正确释放时连跑 1000 次后 fd 数仍为 4 同上 第 3 章「想调大上限之前」、第 4 章
    预算公式 (512 − 4 − 16) ÷ 2 = 246 同上(脚本第五行输出) 第 3 章公式与算例表
    请求数 50/100/200/400 时,未释放的 fd 数为 104/204/404/804,正确释放时恒为 4 本文实跑 work/exp1108/e03b_curve.py 的真实输出 第 2 章实测块
    本文实测环境为 Python 3.13.12 / macOS / 仅用标准库 本地实跑记录(2026-11-08) 两处实测块的运行环境标注
    非 macOS 系统上读取进程 fd 的等价路径 未在本文核验,标待验证 第 3 章末「顺带说明」

    附表 B:术语速查表

    术语一句话解释在本文哪里用到
    文件描述符(fd) 进程打开文件、套接字等对象后拿到的整数编号,读写都靠它 全篇,尤其是第 1 章的插座比喻
    软上限 / 硬上限 软上限是此刻生效、进程可调的那条线;硬上限是它调不过去的天花板 第 1 章、第 3 章公式的第一项
    连接池 预先建好一批上游连接反复借还,把每请求的连接开销变成固定开销 第 5 章「什么时候该上连接池」

    写在最后:这篇用到的资料

    写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:

    • 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
    • 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
    • AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
    • 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
    • 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多

    资料是我自己整理的,放在下面这个码上,扫码即可获取:

    配套资料获取
    课程目录:私有化部署与 LangChain
    课程总目录:7 个模块
    在线知识库:Agent Skills 创建全流程
    课程目录:LangGraph 开发智能体
    在线知识库:Claude Skills 完全指南

    添加时备注「AI」,优先通过。

    资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 句柄上限 512,并发 254 就撞墙:预算怎么算
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!