mpsc 通道的隐藏成本
摘要
本文分析了 Rust 中 Tokio 的 mpsc 通道中意想不到的内存分配开销,揭示了由于内部块大小导致的每个通道的固定开销。文章展示了这一开销如何影响诸如 Agent Gateway 这样的大规模应用程序,并建议采用 futures-channel 等替代方案以提高内存效率。
<p><a href="https://lobste.rs/s/jihmlg/hidden_cost_mpsc_channels">评论</a></p>
查看缓存全文
缓存时间: 2026/05/13 00:23
# mpsc 通道的隐藏成本
来源:https://blog.howardjohn.info/posts/mpsc-cost/
最近,我花了很多时间分析并优化我们的 Rust 反向代理 AgentGateway(https://agentgateway.dev/)的内存使用情况。反复出现的一个问题是,看似无害的 Tokio `mpsc` 通道竟然分配了惊人的大量内存。
按照我天真的理解,我原本认为分配模式如下:
```rust
struct BigStruct {
data: [u8; 1024],
}
fn main() {
// 分配约 1024 字节
let _ = tokio::sync::mpsc::channel::<BigStruct>(1);
// 分配约 1024*1024 字节
let _ = tokio::sync::mpsc::channel::<BigStruct>(1024);
}
```
然而,在实践中这两者都是错误的:它们各自分配了 32KB!在我们的应用程序中,有两个特定区域因此产生了相当显著的性能影响。
## 发生了什么
为了更深入地探究这一问题,我搭建了一个小型 Playground(https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=1a0da74fc4e775c30fb461f2b5090047)来分析来自 `mpsc` 的内存分配。结果相当有趣:
| msg_size | capacity | heap_after_create | heap_after_fill |
|----------|----------|-------------------|-----------------|
| 81 | 800 B | 800 B | 816 |
| 800 B | 800 B | 1024 | 9728 B |
| 128 | 14640 B | 4640 B | 128 |
| 164640 B | 4640 B | 128 | 1024 |
| 4640 B | 132608 B | 1024 | 133312 B |
| 33312 B | 1024 | 16 | 33312 B |
| 33312 B | 1024 | 1024 | 33312 B |
| 1050112 B| | | |
这里我们有:
- `msg_size`:结构体 `T` 的大小。
- `capacity`:有界 `mpsc` 通道的容量。
- `heap_after_create`:创建后立即分配的内存。
- `heap_after_fill`:我们在通道上发送 `capacity` 个消息将其填满后立即分配的内存。
这里有几点引人注目:
- 通道的容量对初始分配大小没有影响。
- 即使开始发送消息,在达到某个特定点之前(剧透:是在 32 条消息之后),内存不会开始增长。
- 通过简单的计算可以看出,初始成本为 `(msg_size * 32) + 544`。因此存在一个固定成本,以及一个基于消息大小的倍数。
### 实现细节
查看 Tokio 源代码可以很容易地找到这个 `32` 的倍数。该通道由 `Block`(块)链表构成(https://github.com/tokio-rs/tokio/blob/bdcea6b2cd716b0a378626796bf5dc049608663d/tokio/src/sync/mpsc/block.rs#L31)。每个 `Block` 存储 `BLOCK_CAP` 个 `T`(https://github.com/tokio-rs/tokio/blob/bdcea6b2cd716b0a378626796bf5dc049608663d/tokio/src/sync/mpsc/block.rs#L47),其中 `BLOCK_CAP` 被硬编码为 `32`(https://github.com/tokio-rs/tokio/blob/bdcea6b2cd716b0a378626796bf5dc049608663d/tokio/src/sync/mpsc/mod.rs#L140)。因此,分配 `mpsc` 基本上就是分配一个 `[T; 32]`。剩余的 544 字节固定开销来自通道的其他部分,我没有进行深入分析。
## 实际影响
### 大量微小通道
对我们而言,第一个实际影响在于我们为集群中的每个 Kubernetes `Service` 对象创建了一个通道,用于发送有关服务健康状态的事件。在许多环境中,这些对象的数量高达数千。我们在每个通道上发送的消息很小(仅 24 字节),并且对这些通道的吞吐量和延迟要求非常低。然而,正如我们上面所学到的,每个通道占用了 `1312` 字节!我们将此迁移到了另一个通道库 `futures-channel`,它每次只分配一个 `T`(代价是吞吐量下降,但这与我们无关)。最终结果是在代表性测试中将我们的整体内存使用量减半:
Agentgateway 优化前后的内存
### Hyper 连接
当使用 Hyper(https://hyper.rs/)时,我们看到每个连接会产生 16KB 的内存分配。当服务于数千个连接时,这累积起来相当可观。其中一部分显而易见:每个连接都有一个硬编码的缓冲區 `INIT_BUFFER_SIZE: usize = 8192`。然而,另外 8KB 正是源于同一个 `mpsc` 问题!每个 `SendRequest`(用于发送请求的 API)都利用了一个分发 `http::Request` 的通道。每个请求通常约为 `250` 字节,乘以 32 就得到了剩余的 8KB。与我们之前的用例不同,此代码路径对延迟/吞吐量非常敏感。前端分配对端到端基准测试有显著影响(https://github.com/hyperium/hyper/issues/4057#issuecomment-4346493767),但我们实际上一次只需要通道中有 1-2 个项目——32 的块大小几乎完全是开销。此问题在 Hyper issue #4057(https://github.com/hyperium/hyper/issues/4057)中被跟踪。
相似文章
你的Rust服务没有内存泄漏——可能是分配器的问题
本文描述了一次调试过程:一个Rust服务在负载下内存持续居高不下,但并没有真正泄漏,原因是glibc的ptmalloc分配器没有将释放的内存归还给操作系统。文章解释了分配器的行为,并为Rust开发者提供了见解。
安全 Rust 的边界
TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。
面向 Morello 的 Rust:始终在线内存安全,即使在非安全代码中
本文介绍了一种针对 Morello 能力硬件架构修改的 Rust 编译器,通过利用硬件能力,实现即使在非安全代码中也始终在线内存安全。
在Rust中的缓存感知数据布局:字段分区、伪共享与128字节规则
这篇博客文章解释了Rust中针对多线程结构的缓存感知数据布局,涵盖了字段分区和避免伪共享的128字节规则,并以一个SPSC环形缓冲区为例。
用Rust构建了一个极简编码代理,优化内存占用
介绍zerostack,一个用Rust构建的极简编码代理,专注于低内存占用(约16MB RAM)和空闲时无CPU占用,旨在实现与Pi或Mistral's Vibe等现有代理功能等效。