weighted-mpsc
A bounded tokio mpsc channel that bounds the queue by the total weight of the messages in it, rather than by the number of messages.
What it does
You implement Weigh for your message type (usually returning its size in bytes)
and give the channel a weight budget. A send waits until the messages already in
the channel leave enough room for the new one, then goes through.
Use it when messages vary in size and you want to cap the total size in flight - for example, to bound the memory a producer/consumer pipeline holds at once, regardless of how many or how few messages that turns out to be. Weight does not have to be bytes; any additive measure works (rows, estimated cost, etc.).
(This is the technique from How musl (and a lucky accident) cut my Rust service's memory, extracted into a small, reusable crate.)
How it works
A tokio::sync::Semaphore holds the budget: one permit per weight unit. A message
takes permits equal to its weight while it is in the channel and while the receiver
still holds the Lease that recv returns. Dropping the Lease returns the
permits to the budget and frees room for more sends. Because the budget is held
until the Lease is dropped - not just until the message is received - the bound
covers the message while the consumer is still using it, not only while it sits in
the queue. Call Lease::into_inner to take the value out and free the budget
immediately if you want looser, plain-channel semantics.
Usage
use ;
// Tell the channel how to weigh a message (here: its heap bytes).
async
Weigh is implemented for Vec<u8>, String, and Box<[u8]> (weight = byte
length) behind the default weigh-std feature. Enable the weigh-bytes feature
for bytes::Bytes and BytesMut, or turn off default features to drop the
built-in impls and weigh every type yourself.
Messages larger than the whole budget
A message that weighs more than the entire budget can never wait for room, so you
pick what happens to it via Builder:
use ;
let = new
.oversized // Allow (default) | Reject | Drop
.min_weight // minimum weight counted per message
.on_oversized
.;
Allow(default): send it anyway. It reserves the whole budget until it is received and itsLeasedropped, so it runs on its own. The message is delivered whole - nothing is dropped or truncated.Reject: returnSendError::TooLarge(which hands the message back); the budget is left untouched.Drop: discard it;sendreturnsOk(()). Useon_oversizedto count or log.
Under Allow, an oversized message is fully in memory while it is in flight, so
peak memory can briefly exceed the budget by about that message's size (the budget
bounds what runs alongside a message, not the size of a single one). Size the
budget at or above your largest message, or use Reject/Drop, if you need the
budget to be a hard ceiling.
Benchmark
Sending and receiving 10,000 messages of 1 KiB each, against a raw count-bounded
tokio::mpsc as the baseline (criterion, on an Apple-silicon laptop):
| Channel | Time | Throughput |
|---|---|---|
tokio::mpsc (count-bounded) |
1.18 ms | 8.5 Melem/s |
weighted-mpsc |
1.54 ms | 6.5 Melem/s |
The weight accounting adds one semaphore acquire and release per message - about
36 ns each, or roughly 30% of throughput on this all-in-memory micro-benchmark.
In a pipeline whose cost is dominated by real work or I/O, that is negligible.
Reproduce with cargo bench; measure on your own hardware and workload before
drawing conclusions. Full results across x86-64 and arm64 at 1 to 20 cores are in
BENCHMARKS.md.
Install
Status / scope
Early, single-maintainer software. The surface is intentionally small: a weighted
sender/receiver, the Lease guard, and the oversized policy. Not here yet, and
possibly worth adding: a try_send and a futures::Stream receiver.
Contributions welcome.
License
Apache-2.0. One dependency: tokio.