Skip to content

feat(mpsc): add fair capacity reservations to bounded channels - #291

Merged
tisonkun merged 34 commits into
apache:mainfrom
tisonkun:codex/bounded-reserve-publish
Sep 9, 2026
Merged

feat(mpsc): add fair capacity reservations to bounded channels#291
tisonkun merged 34 commits into
apache:mainfrom
tisonkun:codex/bounded-reserve-publish

Conversation

@tisonkun

@tisonkun tisonkun commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

Add BoundedSender::reserve() and try_reserve(), returning a borrowed Permit so producers can wait for capacity before constructing a message. Pending sends and reservations receive capacity in wait-queue order, and cancelling an unused reservation returns its capacity.

let permit = sender.reserve().await?;
let message = build_message();
permit.send(message)?;

Design Notes

  • Keep queued values, capacity, sender waiters, receiver registration, and disconnection under one mutex with preallocated VecDeque<T> storage. The bounded implementation no longer needs per-slot state, custom atomics, or unsafe storage.
  • A permit reserves capacity without claiming message order. Other messages can flow while it is held, and receiver destruction does not wait for held or forgotten permits. Sending after disconnection returns the unsent value.
  • Waker callbacks and message destructors run outside the lock. The try_* methods do not wait for messages or capacity, but may contend briefly for the mutex.
  • Accept positive capacities subject to the standard collection's allocation limits, without a separate limit for packed atomic flags or a channel-specific zero-sized-message path.

Validation: cargo x test (501 tests), cargo x lint, and the MPSC suite under Miri with four scheduling seeds (36 passed and three ignored per seed).

Reserve capacity independently from the FIFO ticket. Claim a ticket only
inside synchronous send, then initialize and publish the slot without
holding the capacity wait queue lock.

Keep publication and close ownership in a per-slot state. Cover cursor
wrap, paused publishers, held permits, and payload cleanup with native
and Miri tests. Restore the unmodified AtomicWaker with its attribution.
Run producer and receiver tasks on the same executor by default, and
measure a receiver on the block_on caller thread as a separate workload.
Use the same reusable-task harness and start protocol for usize and
inline 1 KiB messages, including capacity-one and reservation cases.
Signed-off-by: tison <wander4096@gmail.com>
Remove the Kanal adapter and dependency because its pending-operation cancellation semantics differ from the bounded MPSC contract.
Rounded-up slot storage overflows a power of two and the shared permit
counter packs channel state into two flag bits, so capacity is now
bounded at usize::MAX >> 2 with an explicit panic message, replacing
opaque arithmetic or allocation failures. Zero-sized messages allocate
no slots and remain limited only by the permit counter.
The permit counter, closed flag, and a may-have-waiters flag now share a
single atomic state. Acquire and release are each one lock-free
operation when no sender waits; releases only take the wait-queue lock
to hand capacity directly to the oldest waiter, preserving registration
order. A registration sets the waiting flag before its final capacity
recheck so a racing release switches to the locked path and no permit is
stranded without a wake.
Publishing a message previously ran the receiver waker's two read-modify-write
operations even when no receiver was parked. A publishable parked flag now
gates the wake: both flag accesses are SeqCst, so a skipped wake implies the
receiver's post-store recheck observes the published message and no wake is
lost.
The zero-sized path packs its message count and a closed flag into one
atomic word, replacing the last mutex inside the bounded buffer. Close
and publication stay atomic: a publication ahead of the flag is counted
in the drain, and every later one observes the flag and fails.
The parked flag is unsound in the C++ memory model: slot publication is
only a Release operation, so the SeqCst total order on the flag does not
bind it. Miri finds a store-buffering interleaving where the producer
observes the flag unset (skipping the wake) while the receiver's recheck
still observes the pre-publication slot, stranding a parked receiver
(concurrency::publication_racing_with_receiver_registration_cannot_lose_wakeup,
concurrency::last_sender_drop_racing_with_receiver_registration_cannot_lose_wakeup).
Closing the hole needs a SeqCst fence per publish, which costs as much as
the wake it replaces. Keep the unconditional wake; Tokio does the same.
Divan's auto-tuning starts at one iteration per sample and stops once a
sample exceeds 100x timer precision. A cold first call (lazy
initialization, cache misses) can cross that threshold immediately,
ending tuning at sample_size = 1. Every sample then quantizes to one
timer tick (41 ns on this machine): try_round_trip reported 41.74 ns
medians for Tokio and AsyncChannel, and cancel_reserved_capacity
reported 40.75 ns for Asyncband, with means dominated by cold outliers.
Which bench hits this varies from run to run, so pin sample_size on all
nanosecond-scale benches, in line with the sizes auto-tuning converges
to on healthy runs. Medians now agree with independent measurements
(e.g. cancel_reserved_capacity: Asyncband 5.11 ns, Tokio 7.10 ns).
The pre-flight check duplicates what allocation already reports:
collecting the slot array panics with "capacity overflow" when the
layout exceeds the allocation limit, and larger requests abort like any
other Rust allocation failure. Keep only the `usize::MAX >> 2` capacity
bound, which protects the packed permit counter and the power-of-two
rounding.
After a wake claims the WAKING bit, the state can only be WAKING:
registration enters from WAITING and concurrent wakes keep the bit set,
so the restoring swap always reads the value it replaces. A Release
store publishes the emptied slot the same way and drops one
read-modify-write from every message publication.
The packed queue length moves out of the slot ring into a ZeroSized
storage value whose three operations own its invariants: publication
fails once the closed flag is set, consumption relies on the count
being a lower bound, and close transfers the remaining count to the
drain. Dispatch on size_of::<T>() == 0 is const-folded, so generated
code is unchanged.
WAITING and CLOSED become the two largest usize values instead of flag
bits below a shifted permit counter. The usable range is no longer
narrowed by the encoding, so the capacity ceiling rises from
usize::MAX >> 2 to usize::MAX >> 1, now bound by the power-of-two slot
rounding and the zero-sized queue's packed closed bit. Installing
WAITING is a compare exchange over the exhausted counter: a permit
that arrives first wins and is picked up by the registering sender's
recheck, keeping the no-stranded-permit invariant. Hot-path operation
counts are unchanged.
The receiver waker is written at most once per park cycle, while the
permit counter and slot ticket are read-modify-written by every
producer on every message. Padding the quiet word only spreads the
shared allocation over more cache lines.

Interleaved runs of the 8-producer sustained-capacity bench show
padding nothing loses ~12% throughput, while padding only the two
contended words is at parity with padding all three (slightly ahead
in every round).
Split the 650-line bounded module along the channel's own structure:
mod.rs wires up the channel, sender.rs and receiver.rs hold the two
public endpoints, and the two internal mechanisms get one file each —
semaphore.rs for capacity, buffer.rs for storage.

The sender's wait machinery moves into the semaphore as Acquire, the
in-flight counterpart of acquire(), so all sentinel and wait-queue
interlocking lives in one place. Verb-named operations replace
noun-shaped internals: Reservation becomes Acquire, and the receiver's
single-attempt core try_pop becomes pull.
Give the slotted and zero-sized queues separate variants of one
storage enum instead of sharing a flat struct with size checks. The
zero-sized channel no longer carries a dead ticket, close flag, or
slot pointer; the slotted variant is boxed so the enum does not
reserve its room either way.

Review experiment: the dispatch tag is nearly free, but the extra
dependent load behind the box costs the single-threaded round trip.
Numbers accompany the pull request discussion; drop this commit if
the readability does not pay for them.
The file boundary bought nothing over a test module, and the split
hid how little code the unsafe core actually has.
Signed-off-by: tison <wander4096@gmail.com>
Signed-off-by: tison <wander4096@gmail.com>
@tisonkun tisonkun changed the title feat(mpsc): prototype bounded permits with sequenced publication feat(mpsc): add fair capacity reservations to bounded channels Sep 9, 2026
Reuse try_send for ready sends and clone wakers before locking channel state. Remove redundant ZST lifecycle coverage while keeping the maximum-capacity boundary test.
@tisonkun
tisonkun marked this pull request as ready for review September 9, 2026 10:36
@tisonkun
tisonkun enabled auto-merge (squash) September 9, 2026 10:39
@tisonkun
tisonkun merged commit f269b33 into apache:main Sep 9, 2026
10 checks passed
@tisonkun
tisonkun deleted the codex/bounded-reserve-publish branch September 9, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant