Skip to content

连接频繁建立/断开时内存线性增长且不收敛(约 410 字节/连接) #5

Closed

Description

@sercebr

现象

连接频繁建立/断开会让服务端内存线性增长,且不收敛 —— 单独跑 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

  1. sercebr commented on Sep 19, 2026

    @sercebr
    CollaboratorAuthor

    定位完成:成因是 ~uvcpp_tcp_client() 里那条有意的泄漏分支,而它在正常运行中 100% 命中

    把仓库拉下来编译之后做了几组对照,结论如下。

    1. 与内存池无关(与 #7 不是一回事)

    同一份源码编两个构建,只差 UVCPP_BUILD_EXPAND:

    构建 前半段斜率 后半段斜率
    内存池 OFF 0.87 MB/min 0.85 MB/min
    内存池 ON 0.92 MB/min 1.13 MB/min

    两条曲线一样,都约 0.9 MB/min ≈ 350–400 字节/连接,且都不收敛。

    2. 与 HTTP 无关,只在 TCP accept/close 上

    客户端行为 前半段 后半段
    bare:只建连即断,一个 HTTP 字节都不发 0.74 0.90 MB/min
    fin:正常请求 + FIN 四次挥手 1.22 0.92 MB/min
    rst:正常请求 + RST 立刻断 0.87 0.85 MB/min

    不发任何请求也涨同样的量 ⇒ 请求处理、响应、会话状态、webapp 那一层全都无关。

    3. 不是 libuv 的问题

    写了个最小的纯 libuv 服务器(uv_accept 之后立刻 uv_close),同样的客户端打同样的次数:

    服务器 斜率
    纯 libuv(最小程序) −0.14 / −0.02 MB/min(平坦)
    libuvcpp 只用 net 层(uvcpp_tcp_server,接上即关) 0.55 / 0.71 MB/min
    libuvcpp 完整 webapp 0.74 / 0.90 MB/min

    libuv 平坦、libuvcpp 的 net 层就涨 ⇒ 是 libuvcpp 自己的事,与 libuv 与 OS 无关。

    4. 定位到具体分支:每次关闭都走那条"有意泄漏"

    uvcpp_tcp_server 是这样释放客户端的(close_client_on_callback_error):

    tcp->close([this, client](uvcpp_handle*) {
      clients_.remove(client);
      delete client;                 // ← 在关闭回调里 delete
    });

    此刻 loop_->is_running() 为真,于是 ~uvcpp_tcp_client() 走这一支:

    if (loop_ != nullptr && loop_->is_running()) {
      ...
      loop_ = nullptr;
      tcp_ = nullptr;                // 包装对象不 delete
      read_arg_ = nullptr;           // 这几个堆上的 std::function 块也不 delete
      connect_arg_ = nullptr;
      write_arg_ = nullptr;
      close_arg_ = nullptr;
      close_mgr_arg_ = nullptr;
      return;                        // ← 注释原文:"这是**有意的泄漏**"
    }

    给两条分支各加了计数器实测(2000 次 bare 连接):

    [dtor] leak=500
    [dtor] leak=1000
    [dtor] leak=1500
    [dtor] leak=2000
    

    走泄漏分支 2000 次,正常分支 0 次。

    5. 为什么这是个真问题,而不只是"文档化的取舍"

    那条注释给的取舍理由是有道理的:析构可能发生在读回调里,那时 tcp_、各个 *_arg_ 块、loop_ 都压在栈上,拆了必然 use-after-free。

    但判据是 loop_->is_running() —— 它区分不了"我在自己的回调里"和"循环只是恰好跑着"。而服务运行期间后者恒成立,于是:

    • 每一次正常关闭都走泄漏分支,正常运行 100% 命中,不是边角情况;
    • 于是 tcp_(uvcpp_tcp 包装对象)和五个 *_arg_ 堆块按连接数线性累积,直到进程退出;
    • 按 ~350–400 字节/连接、35 连接/秒算,就是上面那 0.9 MB/min;短连接为主的服务每天约 1 GB。

    注意这里泄漏的不是 uvcpp_tcp_client 对象本身(那个确实被 delete 了),而是它持有的包装对象与回调块。

    6. 可能的修法(供讨论,我没有提交补丁)

    这条路的修复要小心 UAF,所以先不动手,列几个方向:

    1. 把判据从"循环在跑"换成"我正在自己的回调里" —— 比如在框架调用每个用户回调期间维护一个 in_own_callback_ 标志(并记录是哪一个)。有了它,析构就能只保留"正压栈上的那一个",其余照常释放。改动集中在 uvcpp_tcp_client。
    2. 把 delete client 推迟到没有回调在栈上的时机 —— 服务端维护一张待释放表,在一个 uv_check/uv_idle 回调里统一释放。但注意:光把 delete 挪到另一个回调里没用,判据仍然是 is_running(),照样走泄漏分支;要配合第 1 条或改判据。
    3. 退一步:把 *_arg_ 那几个块在泄漏分支里也释放掉(它们在关闭回调路径上并不在栈上)—— 能省掉一部分,但治标,且同样需要区分"哪个回调在栈上"。

    我倾向第 1 条:判据精确了,泄漏分支才真的只在需要时生效。

    复现材料

    本机已有的东西(D:\build\):两份构建(池开/关)、uvcppchurn.exe(只用 net 层的接上即关服务器)、uvchurn.exe(纯 libuv 的对照组)、tests/14_churn.js(斜率测量,含 bare/fin/rst/keepalive 四种模式)。要的话我可以把这些整理成一个最小复现贴上来。

  2. sercebr commented on Sep 19, 2026

    @sercebr
    CollaboratorAuthor

    补了一版实现:#13

    思路是把那条兜底的判据从「循环还在不在跑」换成「句柄还在不在」。两条 churn 复现的形状其实完全不同 —— net 层(对端 RST)那句柄早就摘了、根本没人压着它,webapp(对端 FIN)那句柄还活着、外层那一帧确实还压着 —— 而原来那句「所以一个都不拆」把两种都盖住了,于是它成了默认分支。

    同一次测量里的对照:master 这侧 371 字节/连接(20000 连接 +7.07 MB,不收敛),这版 0(6 个采样点全平)。

    里面有三处我没替你定,写在 PR 正文最后一节。

  3. Antruly commented on Sep 19, 2026

    @Antruly
    Owner

    修好了,合入 ce27803(#13,作者 @sercebr)。本地把原症状也量了一遍,A/B 如下。

    本机 churn 实测(A/B)

    形状:裸 uvcpp_tcp_server(接上就由框架登记、起读、断开时回收)+ 裸 socket 连上即关
    (SO_LINGER 0 ⇒ RST)。每 2000 次连接采样一次常驻内存,共 6 点,采样前 500 ms 让收尾跑完。

    改前那一支(把析构里那句判据退回成"循环还在跑 ⇒ 一个都不拆",即 #13 之前的行为):

    sample 1  accepted=6144   rss=18.38 MB
    sample 2  accepted=8192   rss=21.21 MB
    sample 3  accepted=10240  rss=23.83 MB
    sample 4  accepted=12288  rss=26.44 MB
    sample 5  accepted=14336  rss=29.05 MB
    sample 6  accepted=16384  rss=31.94 MB
    slope  1422.1 字节/连接(13.56 MB / 10000 次连接)
    reclaim released=0 deferred=0 skipped=0
    

    改后(master ce27803):

    sample 1  accepted=6144   rss=10.26 MB
    sample 2  accepted=8192   rss=10.29 MB
    sample 3  accepted=10240  rss=10.29 MB
    sample 4  accepted=12288  rss=10.29 MB
    sample 5  accepted=14336  rss=10.29 MB
    sample 6  accepted=16384  rss=10.29 MB
    slope  3.3 字节/连接(0.03 MB / 10000 次连接)
    reclaim released=0 deferred=16384 skipped=0
    

    两点如实说明:

    • 绝对值和你量到的 371 字节/连接不能直接比 —— 装置不同(你那份 churn 客户端是独立进程,
      这份是同一进程内,服务端还多背了一个 uvcpp_tcp_server 的登记开销)。可比的是 A/B:
      同一装置下线性 vs 平。
    • 16384 次连接全部走 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 Release 76/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() 走的就是它)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions