你的Rust服务没有内存泄漏——可能是分配器的问题

Lobsters Hottest 工具

摘要

本文描述了一次调试过程:一个Rust服务在负载下内存持续居高不下,但并没有真正泄漏,原因是glibc的ptmalloc分配器没有将释放的内存归还给操作系统。文章解释了分配器的行为,并为Rust开发者提供了见解。

<p><a href="https://lobste.rs/s/srmkur/your_rust_service_isn_t_leaking_it_could_be">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/07 20:20

# 你的 Rust 服务并没有泄漏 —— 问题可能出在分配器上 来源:https://pranitha.dev/posts/rust-and-memory-allocators/ 在工作负载测试我们一个 Rust 服务时,我们遇到了一个花了很长时间才想明白的问题:内存会在负载下飙升,然后一直居高不下。我们的服务是事件驱动的: 1. 从消息队列(Kafka/Redis Streams/NATS)读取事件 2. 为每个事件生成一个 Tokio 任务来处理它 3. 使用 `Semaphore` 限制并发任务数量以实现背压 按照这种设计,我们期望在所有事件处理完成后内存会降下来。但实际上它一直卡在容器限制的上限附近。 ## 默认内存分配器 ## 我们的工作负载 我们的工作负载是突发且稀疏的,频率为每小时 3-4 次。每次突发约有 10 万个事件。我们的服务以 Kubernetes Pod 的形式运行在 Ubuntu 云虚拟机上。下面是我们处理的工作负载模式的一个简化版本。从高层次来看,下面的代码使用一个 `Semaphore` 始终保持只有 100 个事件处于活动状态。对于每个事件,它会生成许多短命的 Tokio 任务,每个任务都在等待 I/O,然后在其响应被收集后丢弃。 ```rust struct Event { payload: Bytes, // ~4KB user_tokens: Vec<String>, // 最多 1000 个 token // 其他字段 } // ... 在 main 中 let semaphore = Arc::new(Semaphore::new(100)); loop { let event: Event = fetch_next_event().await; let permit = semaphore.acquire_owned().await.unwrap(); tokio::spawn(async move { let _permit = permit; let data = event.payload.clone(); let mut tasks = JoinSet::new(); for token in &event.user_tokens { let token = token.clone(); let data = data.clone(); tasks.spawn(async move { process(token, data).await; }); } let mut responses = Vec::with_capacity(event.user_tokens.len()); while let Some(res) = tasks.join_next().await { responses.push(res); } generate_response_event(event, responses); }); } ``` 上面的代码写得很容易理解。我们尝试了一些代码优化,减少了峰值内存使用,但内存模式保持不变。 ## 第一次检查:是不是内存泄漏? 由于内存一直居高不下,我们首先想到的是:代码中是否有泄漏?Rust 使意外写出内存泄漏变得困难,但并非不可能。由于任务被频繁地大量生成,我们很容易怀疑其中一些 Tokio 任务存在的时间比应该的要长。 我们使用了 `dhat` (https://docs.rs/dhat/latest/dhat/) 来检查是否存在内存泄漏。 ``` At t-gmax: 1,455,866,178 bytes (100%) in 1,321,561 blocks (100%), avg size 1,101.63 bytes At t-end: 10,798 bytes (100%) in 26 blocks (100%), avg size 415.31 bytes ``` `t-gmax` 表示整个程序运行期间堆内存的峰值消耗,`t-end` 表示程序执行结束时堆内存的状态。堆内存从 1.4GB 峰值下降到 10KB,这证实了 Rust 程序几乎释放了它分配的所有内存。但 Kubernetes 仍然显示很高的 RSS。 RSS(驻留集大小)是操作系统当前计为进程使用的物理内存量。`t-end` 与 K8s 报告的 RSS 之间的差距来自于 glibc 的分配器管理已释放内存的方式。 ## glibc 的分配器 glibc 的 `ptmalloc` 通过 arena 管理内存。每个 arena 从一个或多个连续的堆区域分配内存。对于线程 arena,这些区域是 mmap 支撑的子堆。以下是分配器工作原理的简化模型。 ### 分配 当 Tokio 任务并发执行时,任务的内存块会按照请求顺序依次排列在这些 arena 中。借助 Tokio 的工作窃取机制,任务的内存可能分散在不同的 arena 中。以下是分配如何在 arena 内部排列的简化视图。 ``` Arena 1 Arena 2 │ │ │ │ │ 虚拟扩展... │ 虚拟扩展... │ │ +----------------------+ +----------------------+ │ TOP CHUNK │ │ TOP CHUNK │ ← (顶部指针) +----------------------+ +----------------------+ │ Task C: Box │ │ Task D: Vec │ +----------------------+ +----------------------+ │ Task A: Buffer │ │ Task C: Integer │ +----------------------+ +----------------------+ │ Task B: Vec │ │ Task A: String │ +----------------------+ +----------------------+ │ Task A: String │ │ Task B: Vec │ +----------------------+ +----------------------+ HEAP START HEAP START ``` 任务 A 的内存和任务 B 的内存之间没有边界。它们是交错排列的。glibc 只有在分配的大小低于 `mmap` 阈值时才会在 arena 中分配内存。默认的 `mmap` 阈值是 128KB,但 glibc 会动态调整,在 64 位机器上最大可达 32MB。由于我们工作负载中大多数单个分配都低于 mmap 阈值,因此它们是在 glibc arena 内部处理的,而不是获得自己的 mmap 区域。 ### 释放 glibc 通过从顶部修剪来缩小这个连续的堆区域。只有当空闲空间位于堆的末尾时,操作系统才能回收内存。高于 mmap 阈值的分配会获得它们自己的 `mmap` 区域,并在释放时通过 `munmap` 干净地归还。 在上面的例子中,如果任务 B 和任务 C 提前完成并释放了它们的内存,操作系统无法回收这些内存。因为任务 A 仍然存活并持有 Buffer,它就像一个锁,卡住了当前页面,并将它下面的所有内存页面也困住了。 ``` Arena 1 │ │ 虚拟扩展... │ +----------------------+ │ TOP CHUNK │ ← (顶部指针) +----------------------+ │ free │ +----------------------+ │ Task A: Buffer │ ← 仍然存活 +----------------------+ │ free │ +----------------------+ │ Task A: String │ +----------------------+ HEAP START ``` glibc 会将任务 B 和 C 释放的块放入跨 arena 的线程本地 `tcache` 或相应 arena 的 `bins` 中,以便将来任务重用,而不是将其返回给操作系统。这在某些情况下会导致堆碎片,从而产生逐渐增加或阶梯状的内存曲线。 我们内存曲线中的平坦部分并不完全是由于某个存活的分配阻碍了堆修剪。在我们的服务中,没有任何分配会长时间存活,我们期望每次突发后内存能返回给操作系统,但 RSS 却保持平坦。 尽管我们没有长时间存活的分配阻碍堆修剪,但分配器仍然持有内存。这可能发生在最后释放的块被分配器缓存,并且没有合并到 arena 的可回收空闲空间时。由于没有分配活动来触发合并,这些块可能会像障碍一样,导致内存滞留在分配器内部。 线程 arena 使这个问题更严重,因为它们使用 mmap 支撑的子堆增长。如果当前子堆满了,arena 可以附加一个新的子堆,并将顶部指针移过去。自动堆收缩从 arena 的顶部开始。如果顶部的子堆仍然有未合并的块,那么它下面的较旧子堆也可能保持映射状态,即使它们是空闲的。 ``` 线程 Arena ├── 子堆 1: 空闲但保留 ├── 子堆 2: 空闲但保留 └── 子堆 3: 分配器仍有空闲缓存块未合并到顶部 ``` 由于我们的服务受 `Semaphore` 限制,RSS 没有超过之前的峰值,使得所有后续突发重复使用上一次突发留下的空闲块/子堆。这导致内存始终保持在高位,并且曲线平坦。 ### 修剪内存 我们可以通过调用 `malloc_trim(0)` 来要求 glibc 将未使用的页面返回给操作系统。 ``` gdb -p 1 -batch -ex "call malloc_trim(0)" ``` 当我们在突发后调用 `malloc_trim(0)` 时,内存立即下降到基线。我们仅将此作为调试实验,而不是作为生产环境中的回收策略。我们的服务是事件驱动的,因此在正常请求路径中没有明确确定的点可以安全地调用 `malloc_trim`。 ## 切换到 jemalloc 随后我们切换到了 jemalloc 分配器,并观察到突发后内存稳定下降。 ```rust #[global_allocator] static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; ``` ### 为什么 jemalloc 有效? Jemalloc 创建基于 mmap、按大小类隔离的每线程 arena。 ``` jemalloc arena ├── slab: 256B → 只包含 256B 的块 ├── slab: 512B → 只包含 512B 的块 ├── slab: 8KB → 只包含 8KB 的块 └── ... ``` 当 256B slab 中的所有槽都被释放时,该页面就完全空了。jemalloc 将其标记为 dirty,并由后台线程调用 madvise 告知操作系统回收内存。这与 8KB bin 正在做什么无关。glibc 中一个存活块靠近堆顶部导致其下方所有内存被锁定的核心问题,在 jemalloc 中不会发生。每个大小类 slab 独立存亡。 ``` glibc: [256B freed][8KB live][256B freed][256B freed] → 全部被锁定 jemalloc: 256B slab: [free][free][free] → madvise,归还 8KB slab: [live] → 保留 ``` 通过将大小类隔离到独立的 slab 中,减少了跨大小类的碎片。在一个 slab 内部,只有当所有槽都被释放后,页面才会被归还,因此部分占用的页面仍可能持有内存而不释放。 ### jemalloc 配置 在我们的构建中,通过 tikv-jemallocator 的 `background_threads` 特性启用了 jemalloc 的后台线程。我们环境中一些相关的分配器/运行时设置如下: ``` Page size: 4096 thp: madvise background_thread: true dirty_decay_ms: 10000 muzzy_decay_ms: 0 ``` Jemalloc 将未使用的页面视为 dirty,并根据 `dirty_decay_ms` 进行清理。启用了 `background_thread` 后,这种清理可以异步进行,而无需等待未来的应用分配活动。根据平台支持,清理可能会通过 `MADV_FREE` 等机制将 dirty 页面转换为 muzzy 页面,从而允许操作系统在内存压力下回收这些页面。`muzzy_decay_ms` 设置控制未使用的 muzzy 页面被进一步清理的速度。 ## 为什么不用 MiMalloc? 我们也尝试了通常被推荐用于高吞吐量服务的 MiMalloc。但使用 MiMalloc 时,我们观察到事件处理完后内存仍然卡在高位,类似于 glibc。 ```rust #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; ``` 这种行为与 mimalloc 与 Tokio 的工作窃取调度器交互的方式一致: 1. 在**线程 A**上分配的任务可能被窃取并在**线程 B**上执行/丢弃。 2. 在 mimalloc v3 中,跨线程释放会被推送到所属页面的原子 `xthread_free` 列表中,而不是立即放入正常的本地空闲列表。 3. 当所属页面稍后再次被分配器活动访问时,该 `xthread_free` 列表才会被整合。 由于我们的工作负载是稀疏的,每次突发后 Tokio 工作线程会进入空闲状态。任何跨线程释放的块因此可能更长时间地停留在 `xthread_free` 列表上,从而延迟页面收集/清理/回收,导致 RSS 保持平坦。 我们的生产环境中确实有一些来自健康检查和指标的小规模分配活动。当所有 Tokio 工作线程都挂起时,Tokio 不会随机唤醒它们或对它们进行轮询。它只会从睡眠列表中弹出一个睡眠中的工作线程。因此,这些微小的后台任务可能只唤醒一小部分工作线程,而不是在所有工作线程之间产生分配活动。 此外,整合并不意味着 RSS 立即下降。即使 `xthread_free` 列表被整合到 mimalloc 的正常页本地空闲列表中,这些块也只能由 mimalloc 重用。只有当足够多的内存在页面/arena 级别变得可回收,并且 mimalloc 清理或回收时,RSS 才会下降。 ### 配置 mimalloc 我们也尝试了 `MIMALLOC_PURGE_DELAY=0` 和 `MIMALLOC_PURGE_DECOMMITS=1`,以使 mimalloc 更积极地归还内存。但这些设置只控制 mimalloc 已经识别为空闲的内存之后的处理行为。它们不会强制待处理的跨线程释放被整合。 使用上述配置,RSS 在下一次突发开始时下降了一点,很可能是由于新的分配活动访问了一些页面并收集了待处理的释放。但紧接着同样的突发又会分配更多内存,因此 RSS 迅速回升。 ## 结论 尽管 glibc 是默认分配器并且对大多数应用程序运行良好,但对于突发性工作负载,由于 arena 重用、碎片和缓存的空闲块,它可能会保留大量属于分配器的内存。而 mimalloc 是为低延迟、高吞吐量的分配模式设计的,但其回收依赖于分配活动。这使得它不适合我们的工作负载——工作线程在突发之间经常处于空闲状态。Jemalloc 的后台清理可以基于衰减定时器归还未使用的页面,而不是仅仅依赖未来的应用分配活动,这使其成为我们工作负载的正确选择。 感谢 Abhirag (https://abhirag.com/) 与我一起攻克这个难题,调试分配器行为不是一个人的运动 🙌

相似文章

安全 Rust 的边界

Lobsters Hottest

TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。

mpsc 通道的隐藏成本

Lobsters Hottest

本文分析了 Rust 中 Tokio 的 mpsc 通道中意想不到的内存分配开销,揭示了由于内部块大小导致的每个通道的固定开销。文章展示了这一开销如何影响诸如 Agent Gateway 这样的大规模应用程序,并建议采用 futures-channel 等替代方案以提高内存效率。

Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的

Hacker News Top

# Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的 来源:[https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html](https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html) *你可以在[这里](https://github.com/redixhumayun/simpledb/)找到该项目的源代码* 零拷贝是一种旨在消除内核与用户空间缓冲区之间 CPU 数据复制的技术,尤其在数据处理等高吞吐量应用中极具价值。