From 9139cd98da37f247d2801382518271a824e275bf Mon Sep 17 00:00:00 2001 From: sercebr <331266209+sercebr@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:00:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(expand):=20=E5=A4=A7=E5=9D=97=E5=88=86?= =?UTF-8?q?=E9=85=8D=E6=BC=8F=E8=AE=B0=20span->in=5Fuse=EF=BC=8C=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=E5=88=86=E9=85=8D=E9=83=BD=E6=95=B4=E6=AE=B5=E6=B3=84?= =?UTF-8?q?=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题 ---- `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 构建,修复前后各跑一轮上面的复现与 回归用例,结论如上一节。 --- src/expand/uvcpp_page_heap.cpp | 5 ++ tests/functional/expand_memory_pool_func.cpp | 54 ++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/expand/uvcpp_page_heap.cpp b/src/expand/uvcpp_page_heap.cpp index 564fdb33..6038dede 100644 --- a/src/expand/uvcpp_page_heap.cpp +++ b/src/expand/uvcpp_page_heap.cpp @@ -1044,6 +1044,11 @@ void* uvcpp_memory_pool_enterprise::allocate_large_object(size_t size) void* ptr = (char*)span->base_addr + k_span_header_size_actual + k_page_block_header_size_actual; span->free_list.store(ptr, std::memory_order_relaxed); + // 这一段的 in_use 必须记上:free_mem() / return_large_object() 靠 + // `span->in_use.fetch_sub(1) == 1` 判断"这是最后一个使用者,可整段归还"。 + // 少了这一句,fetch_sub 永远从 0 开始、返回值永远不是 1, + // release_span_to_system() 一次都不会被调用 —— 每次大块分配整段泄漏。 + span->in_use.fetch_add(1, std::memory_order_relaxed); g_in_use.fetch_add(1, std::memory_order_relaxed); return ptr; diff --git a/tests/functional/expand_memory_pool_func.cpp b/tests/functional/expand_memory_pool_func.cpp index 48a3e3e5..69fba2d4 100644 --- a/tests/functional/expand_memory_pool_func.cpp +++ b/tests/functional/expand_memory_pool_func.cpp @@ -22,6 +22,9 @@ #include #include #include +#if UVCPP_ENABLE_MEMORY_POOL +#include // get_stats:向系统要了多少字节 +#endif using namespace uvcpp; @@ -200,6 +203,54 @@ static bool test_buf_doubling_growth() { return true; } +#if UVCPP_ENABLE_MEMORY_POOL +// ========================================================================= +// Test: 大块(> 256 KiB)分配之后必须整段归还 +// +// 回归的是一个**只漏不崩**的缺陷,所以它比前面那些死循环/崩溃的活得更久: +// `allocate_large_object()` 建好 span 之后没有给 `span->in_use` 记账,而 +// `free_mem()` / `return_large_object()` 判断"这是不是最后一个使用者"用的正是 +// `span->in_use.fetch_sub(1) == 1` —— 从 0 开始减,返回值永远不是 1, +// `release_span_to_system()` 一次都不会被调用。于是**每一次大块分配都整段泄漏**, +// 且永不归还。 +// +// 为什么既有用例没抓到:上面 `test_many_large_blocks()` 的尺寸数组最大是 +// **262144**,而大块路径的门槛是 `size > k_large_size_threshold`(= 262144) +// —— 差一个字节。再加一档就能撞上。 +// +// 判据用 `get_stats()` 的 `total_allocated`(向系统要的总字节数, +// `release_span_to_system()` 里会减回去),它比 RSS 干净:不受分配器与 +// 页面回收策略影响。 +// ========================================================================= +static bool test_large_object_returned() { + uvcpp_memory_pool_enterprise& pool = uvcpp_memory_pool_enterprise::instance(); + + size_t before_total = 0, before_in_use = 0, before_free = 0; + pool.get_stats(before_total, before_in_use, before_free); + + const size_t kBig = 300 * 1024; // > 256 KiB ⇒ 走大块路径 + const int kRounds = 8; + for (int i = 0; i < kRounds; ++i) { + void* p = uvcpp_alloc_bytes(kBig); + if (p == nullptr) return false; + std::memset(p, 0xA5, kBig); + uvcpp_free_bytes(p); + } + + size_t after_total = 0, after_in_use = 0, after_free = 0; + pool.get_stats(after_total, after_in_use, after_free); + + const size_t growth = after_total > before_total ? after_total - before_total : 0; + // 留 1 MiB 余量给 span 管理与线程缓存;有缺陷时这里会是 kRounds * 300+ KiB。 + if (growth > 1024 * 1024) { + std::cout << " 大块释放后没有归还:" << growth << " 字节仍挂在本进程上(" + << kRounds << " 轮 × " << kBig << " 字节)" << std::endl; + return false; + } + return true; +} +#endif + int main() { bool ok = true; struct { const char* name; bool (*fn)(); } tests[] = { @@ -208,6 +259,9 @@ int main() { {"buf_growth", test_buf_growth}, {"buf_doubling_growth", test_buf_doubling_growth}, {"raw_alloc", test_raw_alloc}, +#if UVCPP_ENABLE_MEMORY_POOL + {"large_object_returned", test_large_object_returned}, +#endif }; for (const auto& t : tests) { std::cout << "[expand_memory_pool] " << t.name << std::endl;