Skip to content

fix(expand): 大块分配漏记 span->in_use,每次分配都整段泄漏 - #7

Merged
Antruly merged 1 commit into
Antruly:masterfrom
sercebr:fix/page-heap-large-object-leak
Sep 19, 2026
Merged

Antruly merged 1 commit into
Antruly:masterfrom
sercebr:fix/page-heap-large-object-leak

Conversation

@sercebr

@sercebr sercebr commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

问题

allocate_large_object()(> 256 KiB 那条路)建好 span 之后只加了全局计数 g_in_use,没有给 span->in_use 记账。而释放侧判断"这是不是最后一个使用者、可以整段归还"用的正是它:

// free_mem() / return_large_object()
if (span->in_use.fetch_sub(1, std::memory_order_relaxed) == 1)
    release_span_to_system(span);

span->in_use 在 allocate_span_from_system() 里初始化为 0(span->in_use.store(0, ...)),从 0 减一返回的还是 0,== 1 永远不成立 ⇒ release_span_to_system() 一次都不会被调用。每一次大块分配都留下一整段虚拟内存,永不归还。

复现

一个起了 HTTPS 演示服务的进程(UVCPP_ENABLE_MEMORY_POOL=1),反复下载同一个 256 MiB 文件:

下载次数 服务端 RSS
0 9.1 MB
1 270.0 MB
2 529.8 MB
3 790.7 MB

每次 +260 MB,且从不回落。

对响应体大小做二分,泄漏有一个精确的断崖,位置正好是 k_large_size_threshold = 262144:

响应体 n 100 次请求的 RSS 增量
60000 / 120000 / 200000 / 250000 +0.2 ~ +0.9 MB(噪声)
260000 −0.6 MB
262000 +25.4 MB
262144 +51.3 MB
270000 +51.7 MB
300000 +57.9 MB

对照实验

同一份源码、同一个靶场、同一台机器,只改 UVCPP_BUILD_EXPAND(即 UVCPP_ENABLE_MEMORY_POOL)这一个构建开关:

构建 4 次 256 MiB 下载之后
内存池 OFF 8.62 → 8.90 MB(零增长)
内存池 ON 9.11 → 1050 MB(每次 +260 MB)

这就把范围锁死在内存池/页堆上,而不是"某个接口坏了"。

修复

span->in_use.fetch_add(1, std::memory_order_relaxed);

一行。span->in_use 的语义本来就是"这一段当前有几个使用者在",分配时记上、释放时减掉,两边对齐即可。

回归用例

tests/functional/expand_memory_pool_func.cpp 新增 large_object_returned:分配并释放 8 轮 300 KiB,用 get_stats() 的 total_allocated(向系统要的总字节数,release_span_to_system() 里会减回去)断言没有净增长。

这个用例经过区分性验证 —— 同一份测试二进制,只换 DLL:

未修复的 DLL:
  [expand_memory_pool] large_object_returned
      大块释放后没有归还:2490368 字节仍挂在本进程上(8 轮 × 307200 字节)
    -> FAIL

已修复的 DLL:
  [expand_memory_pool] large_object_returned
    -> PASS

2490368 = 8 × 311296,正是 8 段按页对齐后的整块。

为什么既有用例没抓到:test_many_large_blocks() 的尺寸数组最大是 262144,而大块路径的门槛是 size > k_large_size_threshold(也是 262144)—— 差一个字节。再加一档就能撞上,已在新增用例的注释里写明。

影响面

比"某个接口坏了"大得多:任何单次分配超过 256 KiB 的地方都会整段泄漏 —— 响应体、上传/下发的分片缓冲、用户自己调 uvcpp_alloc_bytes 的大块都算在内。

而官方预编译包正是 UVCPP_ENABLE_MEMORY_POOL=1(见包里的 lib/pkgconfig/uvcpp.pc 与 RELEASE.md:97),所以用预编译包的服务会直接吃到这个缺陷;反过来,如果谁的内存池是关的(默认关闭),就一直看不见它。

验证情况

这次是在本机编译并实测过的:

  • CMake + MSVC(VS 18 / v145)构建,-DUVCPP_BUILD_WEB=ON -DUVCPP_BUILD_WEBAPP=ON -DUVCPP_BUILD_EXPAND=ON;
  • 修复前后各跑一轮上面的复现与回归用例,结论如各节表格;
  • 回归用例在修复/未修复两版 DLL 上都跑过,确认能区分。

(另:这个缺陷最初是在 1.1.0 预编译包上以"send_file() 下发大文件时内存按已传字节数增长"的形态观察到的 —— issue #3。那个描述把成因归给了 send_file/chunked 组帧,是不准确的:真正的原因是任何 > 256 KiB 的分配都会泄漏,而那条路径的分片缓冲恰好是 262144 + 约 10 字节,刚跨过阈值。issue #3 我会同步更正。)

问题
----

`allocate_large_object()`(> 256 KiB 那条路)建好 span 之后只加了全局计数
`g_in_use`,**没有给 `span->in_use` 记账**。而释放侧判断"这是不是最后一个
使用者、可以整段归还"用的正是它:

    if (span->in_use.fetch_sub(1, std::memory_order_relaxed) == 1)
        release_span_to_system(span);

`span->in_use` 在 `allocate_span_from_system()` 里初始化为 **0**,从 0 减一
返回的还是 0,`== 1` 永远不成立 ⇒ **`release_span_to_system()` 一次都不会被
调用**。每一次大块分配都留下整段虚拟内存,永不归还。
`return_large_object()` 走的是同一个判据,同样中招。

现象与复现
----------

在一台起了 HTTPS 演示服务(`UVCPP_ENABLE_MEMORY_POOL=1`)的进程上,反复请求
一个 256 MiB 的文件:

    第 1 次下载后 RSS:  270.0 MB
    第 2 次下载后 RSS:  529.8 MB
    第 3 次下载后 RSS:  790.7 MB        ← 每次 +260 MB,且从不回落

用响应体大小二分,泄漏有一个**精确的断崖**,位置正好是
`k_large_size_threshold = 262144`:

    n=60000 / 120000 / 200000 / 250000   →  100 次请求 +0.2 ~ +0.9 MB(噪声)
    n=262000                             →  +25.4 MB
    n=262144                             →  +51.3 MB
    n=300000                             →  +57.9 MB

对照实验把范围锁死在"内存池"上:同一份源码、同一个靶场、同一台机器,
只改构建开关 ——

    内存池 OFF:4 次 256 MiB 下载,RSS 8.62 → 8.90 MB   (零增长)
    内存池 ON :4 次 256 MiB 下载,RSS 9.11 → 1050 MB   (每次 +260 MB)

修复
----

    span->in_use.fetch_add(1, std::memory_order_relaxed);

一行。`span->in_use` 的语义本来就是"这一段当前有几个使用者在",分配时记上、
释放时减掉,两边对齐即可。

回归用例
--------

`tests/functional/expand_memory_pool_func.cpp` 新增 `large_object_returned`:
分配并释放 8 轮 300 KiB,用 `get_stats()` 的 `total_allocated`(向系统要的总
字节数,`release_span_to_system()` 里会减回去)断言没有净增长。

**这个用例经过区分性验证** —— 同一份测试二进制,只换 DLL:

    未修复的 DLL:FAIL  大块释放后没有归还:2490368 字节仍挂在本进程上
                        (8 轮 × 307200 字节 = 8 段整块)
    已修复的 DLL:PASS

为什么既有用例没抓到:`test_many_large_blocks()` 的尺寸数组最大是
**262144**,而大块路径的门槛是 `size > 262144` —— 差一个字节。再加一档就能
撞上,已在注释里写明。

影响面
------

比"某个接口坏了"大得多:**任何单次分配超过 256 KiB 的地方都会整段泄漏**。
响应体、上传/下发的分片缓冲、用户自己 `uvcpp_alloc_bytes` 的大块都算。
而官方预编译包正是 `UVCPP_ENABLE_MEMORY_POOL=1`(见包里的
`lib/pkgconfig/uvcpp.pc`),所以用预编译包的服务会直接吃到这个缺陷。

本改动已在本机编译并实测:CMake + MSVC 构建,修复前后各跑一轮上面的复现与
回归用例,结论如上一节。
@Antruly
Antruly merged commit d7297d9 into Antruly:master Sep 19, 2026
16 checks passed
Antruly added a commit that referenced this pull request Sep 19, 2026
合入外部贡献的 `fix(expand): 大块分配漏记 span->in_use` 之后,按"每推一批
功能升一档 patch"的节奏前进一档;`UVCPP_VERSION_IS_RELEASE` 保持 0,
HTTP/2 全部完成前不出 release。

判据取在 `uvcppConfig.cmake` 的版本串上(版本头只在 configure 期进构建,
`cmake --build` 不会因此重编任何 obj):重新 configure 后为 `1.1.12`。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@Antruly

Antruly commented Sep 19, 2026

Copy link
Copy Markdown
Owner

合了,谢谢 🙏

我本地独立跑了一遍区分性验证,没直接信 CI 的绿:只去掉 span->in_use.fetch_add 那一行、只重编 uvcpp_page_heap.cpp(测试二进制不动),新的 large_object_returned 立刻报

大块释放后没有归还:2490368 字节仍挂在本进程上(8 轮 × 307200 字节)

而既有的 many_large_blocks 依然是 PASS —— 跟你说的"尺寸数组最大恰好 262144、跟大块门槛差一个字节"完全对上,用例确实有区分性,不是空转。补丁打上之后 76/76 全绿。

squash 进 master 了:d7297d9。

另外提一句你可能已经想到、但值得写下来的:发布产物是带池的(uvcpp.pc 里钉着 -DUVCPP_ENABLE_MEMORY_POOL=1),而 CMake 默认是 OFF —— 所以自己编的人怎么都看不见,用预编译包的人一上来就中。这个"哪一类用户会踩到"的划界,比缺陷本身还值钱。

Antruly added a commit that referenced this pull request Sep 20, 2026
sercebr 在 #22 指出的形状:`tests/functional/expand_memory_pool_func.cpp` 里那两条
池语义用例包在 `#if UVCPP_ENABLE_MEMORY_POOL` 里,池关掉时**从 stdout 里静默少掉两
行**,而 ctest 照旧报这个文件 PASS —— 比"打印一行 SKIP"更安静。这正好撞在
`tests/functional/CMakeLists.txt:38-40` 自己定的口径上("关掉某模块就把它的用例从
目标列表里摘掉,让「没测」表现为用例不存在,而不是表现为通过"),而那条口径当初正是
为 TLS 写的。

改动:

* `large_object_returned` / `stats_in_use_follows_alloc` 两条搬进
  `tests/expand/memory_pool_test.cpp`(那个目录由 UVCPP_BUILD_EXPAND 门控,池关 ⇒
  整个目录不建)。并进**既有**目标而不是另开文件,所以 ctest 条数一条不变
  (池关 80 / 池开 81)。
* `expand_memory_pool_func.cpp` 里删掉这两条、那条 `[skip]`、以及池头文件的 `#if`
  包 include。它现在**一条 `#if` 都不剩**,文件头那句「开/关都跑」从声明变成实话。

**没搬 `write_second_slot_func.cpp` 的那三条**:它们测的是 `uvcpp_buf` 的写语义
(`append_uv_buf_view` 的所有权),池的 `total_allocated` 只是**量具**不是被测物 ——
搬进 `tests/expand/` 等于把 buf 的覆盖记到 expand 名下。而它们也确实没法在池关的档里
跑(那时 `uvcpp_buf` 走 malloc/realloc,没有"向系统要了多少字节"这个读数)。摘掉整个
文件会连文件里池无关的那几条一起丢,那才是真丢覆盖;所以那处 `[skip]` 留着,它**点名**
了哪三条没跑。

判据搬了家必须**重新证明有牙**(新家里前面那十几条用例会把 thread cache 热成另一副
样子,只有真红一次才知道它还看得见)。两条各变异一次,都在新家实测:

* 摘掉 `alloc()` 出口那次 `g_in_use.fetch_add`(= 回到修前形状)⇒
  `test_enterprise_stats_in_use_follows_alloc` 红:`持有 32 个 64 字节块,在用块数
  只涨了 0(期望 32)`。
* 摘掉 `allocate_large_object()` 里 `span->in_use` 那次记账(= 回到 #7 修前形状)⇒
  `test_enterprise_large_object_returned` 红:`大块释放后没有归还:2490368 字节仍挂在
  本进程上(8 轮 × 307200 字节)`。

两次都从仓库外的 `.good` 逐字节还原(`cmp` 过)、`utime` 顶新 mtime 再重编。

**条数不变是同一棵树前后比出来的**:`build-webapp` 改动前门禁报 81 个用例、改动后
`ctest` 仍 81/81。注册面没动(没增删文件,两个 `CMakeLists.txt` 一个字节都没改),
所以池关那档的 80 同理不变。

纯测试改动,不动库、不动公开头里的声明 ⇒ 照 `d337222` 的先例**不升版本档**。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants