## 现象 **连接频繁建立/断开**会让服务端内存线性增长,且**不收敛** —— 单独跑 6 分钟高密度 churn,前半段斜率 0.71 MB/min、后半段 0.78 MB/min;闲置之后也不归还。 | 负载阶段 | RSS 变化 | 斜率 | |---|---|---| | 32 条常驻连接,纯内存请求(数百万次) | 20.7 → 20.7 MB | **0 MB/min** | | 只做连接建立/断开 | 20.7 → 22.5 MB | **0.69 MB/min** | | 只做 WebSocket 建连/回显/关闭 | 22.5 → 24.1 MB | **0.59 MB/min** | | 完全不发请求(闲置) | 24.1 → 24.1 MB | **0 MB/min** | 长约 **410 字节/连接**。 ## 值得注意的两点 **1. 请求路径是干净的。** 数百万次请求、每请求都分配响应对象与头部,RSS 一动不动。增长**只与"连接建立"有关**,与请求量无关。这条比问题本身更有信息量 —— 它把范围缩到了连接生命周期那一段。 **2. 不收敛。** 分配器膨胀会收敛(斜率趋近 0),这里是线性。按 0.7 MB/min 外推,短连接为主的服务每天增长约 **1 GB**。 长连接(keep-alive)可以完全规避这条路径 —— 与第一行"零增长"互相印证。 ## 与 10 分钟长跑的关系 10 分钟混合负载(64 常驻连接 + 连接 churn + WS churn,4,091 万请求零错误)里 RSS 从 13.6 MB 涨到 20.8 MB(+52%),其中 churn 部分贡献了大头。同一轮里句柄数 254 → 252、停止后残留连接 −2、WS 活动会话 0 —— **登记表、句柄、WS 会话都没有泄漏**,所以这不是"谁没被摘掉",更像"某块内存被留下且没有归还"。 ## 未定位的部分 **根因没有定位**。已知的边界: - 增长与"连接建立次数"成正比,与请求量无关; - 服务端自己的计数器全部回到基线(`connections_now`、`ws_sessions_now`、句柄数),所以不是登记表泄漏; - `UVCPP_ENABLE_MEMORY_POOL=1` 在预编译包里是打开的,**内存池的膨胀/不归还**是一种可能,但没有证实; - **FIN 正常关闭与 RST 强制重置的差异没测完**(那一轮测试被打断),这本来是最能区分"关闭路径漏了收尾"与"分配器不归还"的一条。 ## 复现 `REPORT.md` §7.2,脚本 `tests/06b_leak.js`(把负载拆成四个互不重叠的阶段分别测斜率)。
Activity
sercebr commentedon Sep 19, 2026
定位完成:成因是
~uvcpp_tcp_client()里那条有意的泄漏分支,而它在正常运行中 100% 命中把仓库拉下来编译之后做了几组对照,结论如下。
1. 与内存池无关(与 #7 不是一回事)
同一份源码编两个构建,只差
UVCPP_BUILD_EXPAND:两条曲线一样,都约 0.9 MB/min ≈ 350–400 字节/连接,且都不收敛。
2. 与 HTTP 无关,只在 TCP accept/close 上
bare:只建连即断,一个 HTTP 字节都不发fin:正常请求 + FIN 四次挥手rst:正常请求 + RST 立刻断不发任何请求也涨同样的量 ⇒ 请求处理、响应、会话状态、webapp 那一层全都无关。
3. 不是 libuv 的问题
写了个最小的纯 libuv 服务器(
uv_accept之后立刻uv_close),同样的客户端打同样的次数:uvcpp_tcp_server,接上即关)libuv 平坦、libuvcpp 的 net 层就涨 ⇒ 是 libuvcpp 自己的事,与 libuv 与 OS 无关。
4. 定位到具体分支:每次关闭都走那条"有意泄漏"
uvcpp_tcp_server是这样释放客户端的(close_client_on_callback_error):此刻
loop_->is_running()为真,于是~uvcpp_tcp_client()走这一支:给两条分支各加了计数器实测(2000 次
bare连接):走泄漏分支 2000 次,正常分支 0 次。
5. 为什么这是个真问题,而不只是"文档化的取舍"
那条注释给的取舍理由是有道理的:析构可能发生在读回调里,那时
tcp_、各个*_arg_块、loop_都压在栈上,拆了必然 use-after-free。但判据是
loop_->is_running()—— 它区分不了"我在自己的回调里"和"循环只是恰好跑着"。而服务运行期间后者恒成立,于是:tcp_(uvcpp_tcp包装对象)和五个*_arg_堆块按连接数线性累积,直到进程退出;注意这里泄漏的不是
uvcpp_tcp_client对象本身(那个确实被delete了),而是它持有的包装对象与回调块。6. 可能的修法(供讨论,我没有提交补丁)
这条路的修复要小心 UAF,所以先不动手,列几个方向:
in_own_callback_标志(并记录是哪一个)。有了它,析构就能只保留"正压栈上的那一个",其余照常释放。改动集中在uvcpp_tcp_client。delete client推迟到没有回调在栈上的时机 —— 服务端维护一张待释放表,在一个uv_check/uv_idle回调里统一释放。但注意:光把 delete 挪到另一个回调里没用,判据仍然是is_running(),照样走泄漏分支;要配合第 1 条或改判据。*_arg_那几个块在泄漏分支里也释放掉(它们在关闭回调路径上并不在栈上)—— 能省掉一部分,但治标,且同样需要区分"哪个回调在栈上"。我倾向第 1 条:判据精确了,泄漏分支才真的只在需要时生效。
复现材料
本机已有的东西(
D:\build\):两份构建(池开/关)、uvcppchurn.exe(只用 net 层的接上即关服务器)、uvchurn.exe(纯 libuv 的对照组)、tests/14_churn.js(斜率测量,含bare/fin/rst/keepalive四种模式)。要的话我可以把这些整理成一个最小复现贴上来。sercebr commentedon Sep 19, 2026
补了一版实现:#13
思路是把那条兜底的判据从「循环还在不在跑」换成「句柄还在不在」。两条 churn 复现的形状其实完全不同 —— net 层(对端 RST)那句柄早就摘了、根本没人压着它,webapp(对端 FIN)那句柄还活着、外层那一帧确实还压着 —— 而原来那句「所以一个都不拆」把两种都盖住了,于是它成了默认分支。
同一次测量里的对照:master 这侧 371 字节/连接(20000 连接 +7.07 MB,不收敛),这版 0(6 个采样点全平)。
里面有三处我没替你定,写在 PR 正文最后一节。
fix(net): 回调里析构 tcp_client 时,包装对象不再按连接数累积(#13)
Antruly commentedon Sep 19, 2026
修好了,合入
ce27803(#13,作者 @sercebr)。本地把原症状也量了一遍,A/B 如下。本机 churn 实测(A/B)
形状:裸
uvcpp_tcp_server(接上就由框架登记、起读、断开时回收)+ 裸 socket 连上即关(
SO_LINGER 0⇒ RST)。每 2000 次连接采样一次常驻内存,共 6 点,采样前 500 ms 让收尾跑完。改前那一支(把析构里那句判据退回成"循环还在跑 ⇒ 一个都不拆",即 #13 之前的行为):
改后(master
ce27803):两点如实说明:
这份是同一进程内,服务端还多背了一个
uvcpp_tcp_server的登记开销)。可比的是 A/B:同一装置下线性 vs 平。
deferred(对端 RST 那条路上句柄还在、交给关闭完成回调去释放),skipped恒为 0 —— 也就是每一次断开都真的还回去了。随后的维护者提交
fe0b140:把这条路的判据做成确定性的两条用例(reclaim_paths腿 3 = 传统read_start、read_arg_非空那条)+web_ssl_server_hs_timeout_func.cpp里的回收断言。RSS 是间接量、CI 上没法当断言,所以断言的是三条支路各走了多少次;
退回老写法就是确定性的失败。
error C/error LNK为 0,ctest -C Release76/76 与 87/87,PageHeap 门禁两棵树都 rc=0(裸跑与整页堆下全绿、过期 DLL 0 个)。
reclaim_stats()这个计数留在公开 API 上了 —— 这条路的病全是"悄悄少还一次内存",从外面看不出来。
顺带记一笔(不阻塞):
run_tcp_client_dtor_mutation.py表头里 M7 那行写着"崩(PageHeap)",与正文结论(没抓住、判为等价变异)相反,已在
1b788a5改掉;同一提交里"本机没有 gflags.exe"也不成立(
Windows Kits\10\Debuggers\x64\gflags.exe就在,find_gflags()走的就是它)。perf(web): h3 那条静态腿接上同一个校验档旋钮 + 1.6.26-dev
docs(benchmark): 榜上的位置改成 2026-10-15 这一读