The hidden cost of mpsc channels

Lobsters Hottest Tools

Summary

This article analyzes unexpected memory allocation costs in Tokio's mpsc channels in Rust, revealing a fixed overhead per channel due to internal block sizing. It demonstrates how this impacts large-scale applications like Agent Gateway and suggests alternatives like futures-channel for memory efficiency.

<p><a href="https://lobste.rs/s/jihmlg/hidden_cost_mpsc_channels">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 05/13/26, 12:23 AM

# The hidden cost of mpsc channels Source: [https://blog.howardjohn.info/posts/mpsc-cost/](https://blog.howardjohn.info/posts/mpsc-cost/) Recently I have been spending a lot of time analyzing and optimizing memory usage in our Rust reverse\-proxy,[agentgateway](https://agentgateway.dev/)\. One thing that repeatedly came up was a surprisingly large amount of memory allocated to innocent\-looking Tokio`mpsc`channels\. In my naive understanding, I would have assumed the following allocation pattern: ``` struct BigStruct { data: [u8; 1024], } fn main() { // Allocates ~1024 bytes let _ = tokio::sync::mpsc::channel::<BigStruct>(1); // Allocates ~1024*1024 bytes let _ = tokio::sync::mpsc::channel::<BigStruct>(1024); } ``` However, in practice both of these are wrong: they each allocate 32kb\! In our application we had two particular areas where this had pretty meaningful performance impact\. ## What's going on To dig into this a bit more I built out a small[playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=1a0da74fc4e775c30fb461f2b5090047)to analyze allocations from`mpsc`s\. The results were quite interesting: `msg\_size``capacity``heap\_after\_create``heap\_after\_fill`81800 B800 B816800 B800 B81024800 B9728 B12814640 B4640 B128164640 B4640 B12810244640 B132608 B1024133312 B33312 B10241633312 B33312 B1024102433312 B1050112 BHere we have: - `msg\_size`: the size of the struct`T`\. - `capacity`: capacity of the bounded`mpsc`channel - `heap\_after\_create`: memory allocated immediately after creation - `heap\_after\_fill`: memory allocated immediately after we send`capacity`items on the channel to fill it up\. A few things stand out here: - The capacity of the channel has no impact on the initial allocation size\. - Even when we start sending messages, we don't start to grow until a certain point \(spoiler: it's after 32 messages\)\. - With some quick math we can see the initial cost is`\(msg\_size \* 32\) \+ 544`\. So there is a fixed cost and a multiplier based on the message size\. ### Implementation details This`32`multiplier is easy to find looking at the Tokio source\. The channel is built up of a linked list of[`Block`s](https://github.com/tokio-rs/tokio/blob/bdcea6b2cd716b0a378626796bf5dc049608663d/tokio/src/sync/mpsc/block.rs#L31)\. Each`Block`[stores`BLOCK\_CAP``T`](https://github.com/tokio-rs/tokio/blob/bdcea6b2cd716b0a378626796bf5dc049608663d/tokio/src/sync/mpsc/block.rs#L47)values, where`BLOCK\_CAP`is[hardcoded to`32`](https://github.com/tokio-rs/tokio/blob/bdcea6b2cd716b0a378626796bf5dc049608663d/tokio/src/sync/mpsc/mod.rs#L140)\. Because of this, allocating the`mpsc`allocates a`\[T; 32\]`more or less\. The remaining fixed 544 bytes comes from the rest of the channel parts which I didn't analyze too closely\. ## Real world impact ### Many tiny channels The first real impact for us was a channel we created for each Kubernetes`Service`object in the cluster to send events about the health of the service on\. In many environments these number in the thousands\. The message we sent on each channel is small \(only 24 bytes\), and the throughput and latency requirements for these is very low\. As we learned above, however, each was taking`1312`bytes\! We moved this to another channel,`futures\-channel`, which only allocates one`T`at a time \(at the cost of a throughput degradation which was not relevant to us\)\. The end result cut our overall memory in half in a representative test: ![Agentgateway memory before and after optimization](https://blog.howardjohn.info/images/agw-memory-mpsc.png#center)Agentgateway memory before and after optimization### Hyper connections When using[Hyper](https://hyper.rs/), we see a 16kb allocation per connection\. When serving thousands of connections this can add up quite a bit\. Part of this is obvious: each connection has a hardcoded buffer`INIT\_BUFFER\_SIZE: usize = 8192`\. However, the other 8kb comes from the same`mpsc`issue\! Each`SendRequest`, the API that requests are sent on, utilizes a channel that dispatches`http::Request`s\. Each request is typically around`250`bytes, multiplying by 32 gives us our remaining 8kb\. Unlike our previous use case, this codepath is very latency/throughput sensitive\. The upfront allocation has a[meaningful impact on end to end benchmarks](https://github.com/hyperium/hyper/issues/4057#issuecomment-4346493767), but we only really ever need 1\-2 items in the channel at a time \- the 32 block size is almost pure overhead\. This is tracked in[Hyper issue \#4057](https://github.com/hyperium/hyper/issues/4057)

Similar Articles

Your Rust Service Isn't Leaking — It Could Be the Allocator

Lobsters Hottest

This article describes a debugging journey where a Rust service's memory remained high under load despite no leaks, due to glibc's ptmalloc allocator not releasing freed memory back to the OS. It explains the allocator behavior and provides insights for Rust developers.

The Edge of Safe Rust

Lobsters Hottest

A TokioConf 2026 talk/blog post explores pushing safe Rust to its limits by implementing tracing garbage collection for complex pointer structures, sharing techniques for circular references and raw-pointer GC design.