Repository navigation
fix(net): 回调里析构 tcp_client 时,包装对象不再按连接数累积(#5) - #13
Conversation
issue Antruly#5:连接频繁建立/断开时 RSS 线性涨、不收敛 —— net 层 371 字节/ 连接(20000 连接 +7.07 MB),webapp 0.74–1.17 MB/min。 根因是 ~uvcpp_tcp_client 里那条兜底的判据「循环还在跑 ⇒ 包装对象 (tcp_)一个都不拆」。它区分不了「我正踩在自己的回调里」和「循环只是 恰好跑着」,而服务运行期间后者恒成立,于是每断一次连接就漏一个 uvcpp_tcp 包装对象。 把判据换成「句柄还在不在,以及处在哪一步」: - 句柄已摘(get_handle() == nullptr)→ 当场 delete(net 层那条 churn 路径实测 2000/2000 都走这里); - 句柄还活着、没在关 → read_stop() 之后把包装对象交给它自己的关闭 完成回调去删(webapp 那条路径实测 1000/1000 都走这里)。uv_close 的完成回调在本轮 uv_run 的收尾阶段跑,那时当前这一帧已经解开; callback_close 也已经把回调拷到局部、清空成员、释放了 uv_handle_t 的内存,回调返回之后不再碰 wrapper; - 句柄正在关(关闭回调槽被占)→ 只能留下,实测两条路径上都是 0 次。 read_arg_ 那块不由任何 libuv 请求持有(trampoline_read 不删它), 一并交给同一个关闭回调,它正是 webapp 侧剩下的大头。 实测:net 层 20000 连接斜率 371 字节/连接 → 0(7.79 MB 基线,6 个 采样点全平),webapp 0.74–1.17 → 0.02/0.07 MB/min。 新增 reclaim_stats() 诊断计数(与 close_observer_count 同一用途): 析构走哪条支路从外面看不见,回归用例需要判据。计的是释放真的做成了, 所以把释放动作删掉计数就会停在原地。 新用例 test_reclaim_paths 两条腿各钉一种回调形状,判据两条互相独立: 计数必须随断开次数涨 1 + 收尾后 uv_loop_close() 必须返回 0。 变异验证(tests/tools/run_tcp_client_dtor_mutation.py 新增两条): M6 退回「一个都不拆」→ 两条腿红(不开页堆) M7 句柄还活着也当场 delete → 没抓住,判为等价变异,理由见 tests/tools 的说明与 PR 正文 工具:本机没有 gflags.exe,补一条直接写 IFEO 注册表的路(GlobalFlag + PageHeapFlags=0x1)并照样回查;抓 FAIL 明细的正则改成能匹配带空格的 用例名。 同一个析构里还有一处同形的(上游 d12188c 刚合入的握手超时定时器): 它在「析构发生在定时器回调里」这一支只关句柄、把包装对象留在原地, 而那句注释的理由写的是「同上」—— 上面那句现在不再「留在原地」了, 所以照同一个理由改过来(这一处是推理出来的,不是实测:触发它要 TLS 握手超时,net-only 的 churn 装置碰不到)。 基线:本分支已 rebase 到上游 a02ebf7。上游 master 在这个析构里处理 tcp_ 的代码与 rebase 前逐字相同,所以 master 侧的斜率在同一个新基线 上重测过:7.72 → 9.20 → 10.64 → 11.98 → 13.38 → 14.79 MB(每档 4000 连接),仍是 371 字节/连接;修复侧同机同次为 7.73 → 7.78 ×5 (只在第一次灌连接时涨 0.05 MB 预热),斜率 0。
d12188c 在 ~uvcpp_tcp_client 里处理 tls_hs_timer_ 时,把「析构发生在定时器 回调里」那一支写成「只关句柄、包装对象留在原地」,理由写的是「理由同上」 —— 指的是上面 tcp_ 那段。上面那段现在已经不「留在原地」了(包装对象交给 关闭完成回调去删),所以这个「同上」指不通;这里按同一个理由改过来。 与前一个提交不同,这一处**没有实测**:触发它要 TLS 握手超时,net-only 的 churn 装置碰不到,非 TLS 路径上它恒为 0。依据仍是 uvcpp_handle::callback_close 的顺序保证(先拷闭包、清成员、释放 uv_handle_t 内存,之后不再碰 wrapper) 加上 uv_close 的完成回调跑在本轮 uv_run 的收尾阶段。 计数进同一组 reclaim_stat:它的口径是「本客户端在析构时要放掉的包装对象」。
|
补上正文第 1 条边界欠的结果 —— 配置:上游 结果: 那处
但这条边界仍然要划清:以上证明的是「这一支能编译,且握手超时的路径被真的走到了」,不证明「析构发生在定时器回调里」那一支被走到了 —— 要触发它得让析构恰好落在那个定时器回调的帧里,这我没有构造出来。所以正文 (3) 里「那处改动是推理出来的、我拿不出实测」这个判断不变,仍然请你按推理的价值来决定留不留。 |
合入 #13 的三支路里有两处没有判据盖住: - `read_arg_` 非空那一支。既有各腿全部走 `read_start_events`,那条路的 数据由成员 `net_read_cb_` 交付,`read_arg_` **恒为空** —— 于是"把它的 释放推迟到关闭完成回调"这件事一条腿也没测到。腿 3 与腿 1 同形,只把读 注册换成传统 `read_start`,它同时被 M6(退回"包装对象一个都不拆")抓住。 - 握手超时那条路。原来那两条判据(handle_queue、`uv_loop_alive`)对"包装 对象漏没漏"完全不敏感:句柄关得掉,漏的是 C++ 对象。加一条断言,这条路上 `tcp_` 与 `tls_hs_timer_` 的包装对象各延迟释放一次(deferred += 2 / released == 0 / skipped == 0);把定时器那条支路改回旧写法会让它掉成 1 并变红,实测复现过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
已合入( 三处拍板的答复(1) (2)"句柄还活着"这一支保持三支路 —— 不简化。 现场证据(析构分支加一行探针,跑整份 "无条件 (3)握手超时定时器那一处 —— 留,而且它不再是纯推理。 两条支路都走到了、两个包装对象都真的被删掉。改回 PR 之前那句 本机复核
随后的维护者提交
CI 结论只看 |
第四批收尾。八篇功能文档全部就位,`doc/` 下每一篇都能从 README 的正文里点到。
**只加/改 `.md`,一行代码没动。**
## 两篇新页
- **`doc/expand-guide.md`**(内存池 / 页堆 / span):这一层是全仓文档覆盖最差的地方。
写出来的是"默认关着的理由",以及几处**只有读实现才知道**的行为 —— SUPER 档
(>256 KiB)的块在 `push()` 里被静默丢弃、`allocate_aligned()` 的 `align` 只用来把
size 凑整、`reset()` 与 `reset_stats()` 逐行相同、`max_total_memory` 全仓只在声明处
出现过一次、每销毁一个池至少睡 7 ms(7 个 mpsc 队列各 1 ms)、
`memory_usage_ratio()` 在无符号下溢后给出垃圾读数。另记一条实测:
`uvcpp_memory_pool.h:1082` 用了 C++14 的 init-capture,而 `CMAKE_CXX_STANDARD` 是 11
(g++ 只给 warning,`-pedantic-errors` 才报错)。
- **`doc/webapp-support-guide.md`**(框架底下那七个类型):按类型清单界定范围,开头就
写明"框架行为以 `webapp-guide.md` 为准"。最有价值的是 **§10 默认值与头注释对不上**
—— 头注释错、实现对的地方逐条列了出来,包括 `uvcpp_console_log_options{false, true}`
那段"把自己说反了"的注释(实测确实编不过)、`color` 的默认值并不是"自动判断"、
默认输出比头里的样例多一列 `(tid:N)`。安全相关的坑都带 `文件:行号`:
`web_build_cookie()` 的 `path`/`same_site` 原样拼接(可注入)、
`web_url_encode()` 的 `keep` 优先于 `plus_for_space`、`set_boundary()` 失败会把
解析器打进终态且**救不回来**。
## README / CONTRIBUTING
- 两版 README 各加一张 **`### 模块指南` / `### Module guides`** 正文表,把八篇按模块
列出来;`doc/` 结构树也补齐(原来漏了 5 篇)。
- 顺手修掉两版 README 类表里一处**陈旧散文**:`uvcpp_tcp_server` 没有
`on_connection()`,连接回调是 `listen()` 的第一个参数(`src/net/uvcpp_tcp_server.h:177`)。
这与上一批修掉的那 4 个烂例子同类,只是它藏在表格里,编译门禁够不着。
对照:`uvcpp_ws_server::on_connection()` **是**真的(`src/web/uvcpp_ws_server.h:111`),
所以 WS 那一处不动。
- `CONTRIBUTING.md`:在 "Where new documentation goes" 里列出这九篇,并写明
**不要建 `doc/<模块>/` 子目录** —— 两个门禁都是非递归扫描,放进去等于对所有判据隐形。
## 验证
- `check_doc_snippets.py --pkg <六模块全开包> --cxx g++`:**rc=0**,
候选 131 条:编过 61 条(37811 B),片段 70 条,跳过 0 条。
新页 `webapp-support-guide.md` 编过 8/9 条 —— 那 1 条片段是 §10 故意编不过的反例。
- `check_docs.py`:rc=0,3 条判据全过,503 处引用可解析,`doc/` 下 15 篇**无孤儿**。
本批之前 `expand-guide.md` 与 `webapp-support-guide.md` 两篇都是红的,
正是被上面那张表清掉的。
- **判据 2 的对照组**:把一个锚点改成 `#13-没做的如实列` ⇒ rc=1,且点名
「doc/webapp-support-guide.md 第 47 行的锚点 … 没有对应标题」;从仓库外备份还原后
逐字节相同、复跑回绿。新页的 13 个锚点全部用 `check_doc_snippets.py --toc` 核过。
- `check_doc_versions.py`:rc=0,命中恰好 4,全部等于 `1.2.0`。
- `verify_tree.py --tree build-h2`:rc=0,构建 errors=0 / DLL md5 / exe mtime /
ctest 93 个用例 0 失败,四条全 PASS(未传 `--stability`,故第 5 条未跑)。
- 版本号不动:本批只改 `.md`,不升档;两版 README 的版本串一个字未碰。
Co-Authored-By: Claude Code <noreply@anthropic.com>
这个 PR 修 #5(连接频繁建立/断开导致内存不收敛)。里面有两处需要你拍板,写在最后一节:(1) 新加的那个诊断计数要不要留在公开 API 上;(2) "句柄还活着"这一支要不要简化成无条件
delete——我按保守做法留着了,但变异验证显示它不是非如此不可,证据都在下面。症状
服务跑着,连接不停地来、不停地断,RSS 线性涨、不收敛。两个独立复现:
uvcpp_tcp_server,客户端反复连、对端 RST)tests/14_churn.js,对端干净 FIN)371 字节/连接和 issue 里估的 350–400 B 对得上,而且 20000 次连接里斜率一点没收敛 —— 不是缓存没热、不是分配器碎片。
根因
~uvcpp_tcp_client里那条兜底:这一支的动机是对的(在回调帧里当场
delete loop_、delete read_arg_确实是悬垂,见下),但判据挑错了:loop_->is_running()区分不了「我正踩在自己的回调里」和「循环只是恰好跑着」。而服务运行期间后者恒成立 —— 于是这个分支成了默认分支,tcp_那个包装对象一次也没被拆过。用探针把两条复现路径分开
在析构点打了一组探针(
leak / 句柄是否已摘 / 读回调帧 / 关闭回调帧 / 删除点),两条复现形状完全不同:uvcppchurn(2000 次,rst)release_client(关闭完成回调里)webdemo-zfw(1000 次,fin)release_client(读回调里)两边
is_closing()都是 0;read_arg_ != nullptr只在 webapp 侧成立。也就是说这两条路不是同一个"来不及删":一条是"句柄早摘了、根本没人压着我",另一条是"句柄还活着、外层那一帧确实还压着"。同一句"所以一个都不拆"把两种都盖住了。改动
判据从「循环还在不在跑」换成「句柄还在不在」:
tcp_->get_handle() == nullptrdelete tcp_(连read_arg_那块一起)!tcp_->is_closing()read_stop(),然后把包装对象交给它自己的关闭完成回调去删tcp_->is_closing()handle_close_cb只有一个),插不进去,只能留下第三条在实测的两条 churn 路径上都是 0 次触发(探针数出来的)。
同一个析构里还有一处同形的,一并改了:上游刚合入的握手超时定时器(
d12188c)在「析构发生在定时器回调里」这一支里只关句柄、把包装对象留在原地,理由写的是"同上"—— 而上面那句现在不再"留在原地"了,那个理由已经不成立。支路划分和三支路一模一样,计数也进同一组。这一处是推理出来的,不是实测的:触发它要 TLS 握手超时,net-only 的 churn 装置碰不到,非 TLS 路径上它恒为 0。不想要的话就是一行的事(改回
[](uvcpp_handle*) {}),见末节 (3)。"句柄还在"这一支为什么可以不必漏
靠的是
uvcpp_handle::callback_close已经有的两条顺序保证:handle_close_cb拷到局部、把成员清空、wrapper->_handle = nullptr、释放掉uv_handle_t的内存,然后才调close_cb(wrapper);回调返回之后不再碰 wrapper。uv_close的完成回调是在本轮uv_run的收尾阶段(uv__run_closing_handles)跑的 —— 那时当前这一帧早就解开了。两条合起来:在自己的关闭完成回调里
delete wrapper是安全的。所以这一支不是"现在不能删",而是"不能现在删,但可以现在交给那个回调"。read_arg_那块一并交出去:它不由任何 libuv 请求持有(trampoline_read明确不删它 —— 读回调是长期注册、会反复进来;写/连接那几块由各自的完成回调delete),而读注册在上面刚read_stop掉,所以"这一帧解开之后再删"同样安全。漏掉它就是每次连接漏一个std::function块 —— 实测那是 webapp 侧剩下的大头。顺序仍是有意的:
is_closing()那一问不能省(覆写正在执行的那个闭包就是悬垂),get_handle()要先于is_closing()问(后者把_handle直接交给uv_is_closing,它在句柄已摘时解引用空指针)。效果
net 层(
uvcppchurn.exe+ 同一个 blaster harness,每档 4000 连接;RSS 单位 MB)。两列是同一次测量里前后脚跑的:master 那一列是临时把上游的uvcpp_tcp_client.cpp换进构建树重建出来的,跑完按字节还原(md5 复核过)。master 斜率 +7.07 MB / 20000 连接 = 371 字节/连接;本分支 0(只在第一次灌连接时涨了 0.05 MB 的预热,之后 5 个采样点全部落在 7.78)。
webapp(
tests/14_churn.js,90 s,3128 连接,18 个采样点):0.74–1.17 MB/min → 0.02 / 0.07 MB/min(RSS 8.7 → 8.8,判定"基本平坦")。回归判据
全量套件:rebase 到
a02ebf7之后全量重建 +ctest -C Release→ 75/75 全绿(比 rebase 前多一条 —— 上游1401d46带进来的test_web_http_client_selfdestroy_func)。这套结果有两个边界必须说清,免得它看起来比实际更有力:
UVCPP_ENABLE_OPENSSL=OFF的 —— 所以一个 TLS 用例都没跑(11 个web_ssl_*一条不在里面)。主体那段改动(tcp_那三条支路)在这个配置下是编到、也跑到了的;但第二个提交那处tls_hs_timer_在#if UVCPP_OPENSSL_ENABLE里,这个配置下连编译都没编译过,75/75 对它一个字都说不上。已经在建一个OPENSSL=ON的配置来把它编过并跑上 TLS 那批用例,结果补在这里。*不得覆盖 Accept-Encoding 里显式给出的 q=0 #10 那几条。它们都不碰uvcpp_tcp_client的析构路径,所以这套结果对本分支成立,但严格说它跑的不是"只有这一个提交的干净树"。哪些数是 rebase 之后重测的:上面那张 net 层 RSS 对照表和整轮变异验证都是新基线上重跑的;探针区分表、webapp 的 0.74–1.17 MB/min 是 rebase 前那轮量的 —— 它们测的是 master 的行为,而 master 这个析构里处理
tcp_的代码在新旧基线上逐字相同(git show比对过),所以照用。新用例
test_reclaim_paths(tests/functional/tcp_client_func.cpp)两条腿各钉一种"回调返回之后还剩什么",用外部循环(这样析构不会连循环一起漏掉,收尾后还能问
uv_loop_close()):set_close_manager,服务端用的就是这条)删客户端deferredclose(),完成回调里删released判据有两条,互相独立:
uvcpp_tcp_client::reclaim_stats()里released + deferred必须随断开次数涨 1、skipped恒 0。计的是释放真的做成了,不是"打算这么做"(released记在delete之后、deferred记在那个延迟的delete里面),所以把释放动作删掉会让计数停在原地。uv_loop_close()必须返回 0 —— 退回到"一个都不拆"时,漏掉的那个uv_handle_t还挂在 handle_queue 上(既不活跃也没在关,uv_run连 while 体都不进),这里只能UV_EBUSY。这一条不需要新计数就能抓住老写法。用
reclaim_stats()而不是 RSS 斜率做回归判据,是因为后者是间接量:慢、噪声大,涨到能测出来要几千次连接,CI 上没法当断言。变异验证(
tests/tools/run_tcp_client_dtor_mutation.py,7 个变异)(rebase 之后在新基线上整轮重跑过,7 条逐条复现;收尾的源码按字节还原、grep 无残留、PageHeap 已关、还原后重建 rc=0 也都过了。)
0xC0000005)is_running守卫reclaim_paths两条腿红(不开页堆)deleteM6 抓到的三条判据(逐条打印出来的):
M7 为什么没抓住 —— 是等价变异,不是覆盖缺口(写清楚免得它以后被当成功效证据)。把"交给关闭完成回调去删"换成"当场
delete"之后,6 条腿在整页堆下全绿。读一遍就明白:uvcpp_stream::callback_read、uvcpp_handle::callback_alloc、uvcpp_connect::callback_connect)都是先把闭包拷到局部再调(源码里写明了"回调里delete self是合法用法"),回调返回之后一个字节都不再碰 wrapper;delete tcp_落到uvcpp_handle::free_handle(),它按句柄当时的状态分派:正在关 → 塞个 detached 哨兵就返回(底层内存留给已经排队的callback_close)、活跃或"init 过但没 start" → 自己补一次uv_close、从没 init 过 → 才直接还内存。三条路都不会把"还挂在loop->handle_queue上"的内存还掉。所以这一条不区分"当场删"和"交给关闭回调删",留着它是出于保守,不是因为它被证明了必要 —— 见末节 (2)。
两个工具上的连带改动
gflags.exe在本机不存在(Windows Kits\10\Debuggers\x64下只有dbghelp/dbgcore/srcsrv三个可再发行件),原来的set_pageheap()直接以"PageHeap 设置失败"退出,等于这个门禁在本机跑不起来。补了一条不依赖 gflags 的路:直接写同一个 IFEO 键,写完照样回查注册表。两个值缺一不可 ——GlobalFlag = 0x02000000加PageHeapFlags = 0x1(full)。少了后者只是标准页堆:释放过的块填0xFEEEFEEE但页还在,于是"释放后读"读得到、不崩,而这个门禁要抓的恰恰就是读。机制先单独验过:一个malloc(64) → free → 读的探针,裸跑 rc=0,这样设上之后直接SEGV(rc=139),删掉键又回到 rc=0。(\S+ FAIL.*)匹配不上用例名带空格的(reclaim_paths leg1 FAIL …、既有的delete_in_read_cb leg1 FAIL …一样匹配不上),报出来是空的。判据本来就只看退出码,这一列只是明细 —— 但静默为空会让人以为"没红",改成(.*FAIL.*)。(1) 新增的那个诊断计数要不要留在公开 API 上
我加了
它是这个 PR 里唯一新增的公开面。动机写在上面:析构走哪条支路从外面完全看不见,不给个可观测量就只能拿 RSS 斜率当判据。仓库里已有
close_observer_count()/rejected_count()这类诊断计数,所以我按同一个路子做了,但它确实是为测试加的生产 API。不想要的话有两条退路:
uv_loop_close()那条判据。代价是腿 2(released支路)就没有判据了 —— 那条支路的泄漏在 handle_queue 上是干净的(uv_handle_t早被callback_close释放了)。它只占 net 层那 371 B 里的一部分,我用 RSS 斜率量过,但那之后就只能靠慢测。#ifdef只在测试构建里编出来。多一个编译开关,我不太推荐。我倾向留着 —— 这条路径的 bug 全是"悄悄少还一次内存",有个能直接断言的数比什么都强。
(2) "句柄还活着"这一支要不要简化成无条件
deleteM7 的证据反过来指着这个方向:既然"当场
delete tcp_"在整页堆下 6 条腿全绿,而free_handle()本身就把"活跃 / 正在关 / 没 start"三种状态收尾得当,那这一支或许根本不用分三种情况,一句就够了 —— 而且它在"句柄正在关"这个第三种情况上不漏(现在是留着的,只是实测 0 次触发)。代价是:这条写法依赖
free_handle()的状态机在"回调帧里"也成立,而那是个比"把删除推迟到关闭回调"更细的依赖 —— 后者是本仓库到处在用的形状(uvcpp_tcp_server的stream->close([peer](uvcpp_handle*){ delete peer; })就是它)。我没敢替你选:探针能构造出来的形状里两者等价,构造不出来的那些帧(比如写完成里析构、TLS 半途析构)我没法证明等价。你想简化的话,改完之后 M7 这个变异就不存在了(它就是那段代码),回归判据也只剩
released一列 —— 一并说一声我来改。(3) 那处握手超时定时器的改动要不要留
它动的是你昨天刚合入的那行(
d12188c里的close([](uvcpp_handle*) {})),不是这个 issue 原来圈定的范围。我改它的理由是那条注释里的"同上"已经指不通了 —— 但我拿不出实测,只能给推理,这正是它和上面 (1)(2) 的区别。要拆掉的话它跟reclaim_stats()里那两行计数是绑在一起的,说一声我拆成干净的(那边的计数也可以一并从reclaim_stat的文档里去掉)。