Skip to content

feat(mpsc): prototype bounded permits with sequenced publication - #291

Draft
tisonkun wants to merge 3 commits into
apache:mainfrom
tisonkun:codex/bounded-reserve-publish
Draft

feat(mpsc): prototype bounded permits with sequenced publication#291
tisonkun wants to merge 3 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 that reserves capacity before a message is constructed. Rework bounded storage around separate claim and publication steps and an exclusive consumer cursor. This draft demonstrates the API and its performance tradeoffs against merged main 64d7a94 and ecosystem channels.

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

Design Notes

  • A public permit reserves capacity without claiming FIFO order. It may be held across an await; other messages can still flow. Dropping it returns capacity, and dropping the receiver does not wait for held or forgotten permits. Permit::send returns the unsent value if the receiver has disconnected. Capacity waiters retain the existing retry-based notification policy, including barging.
  • Publication claims a slot with one fetch_add, writes the payload, and publishes a generation stamp with Release ordering. The single consumer acquires that stamp and advances its own cursor without resetting each slot's stamp. Physical storage rounds up to a power of two; the requested logical capacity remains exact.
  • Capacity uses a producer reservation sequence, separately published consumer progress, and cancellation credits. Producers cache returned capacity until their current window is exhausted. A successful ordinary send still updates two producer sequences: the reservation CAS and publication fetch_add. Pending sends keep the payload outside the capacity retry loop.
  • The claim increment uses AcqRel to carry consumption observed by earlier reservations. An old permit can cross many laps before publishing, so its original capacity acquire alone cannot prove that its eventual slot is safe to reuse. Close freezes the drain boundary before any rejected late claims; panic guards finish releasing queued values.
  • Remove the unused general-purpose AtomicWaker and its obsolete provenance entry in a separate cleanup commit. Bounded MPSC keeps its existing local receiver wake protocol.

The reservation/publication split follows the Disruptor two-phase publication model, with capacity permits kept separate from short-lived physical claims to support async cancellation and receiver teardown.

Validation

  • cargo x test: 330 unit/integration tests and 167 doctests passed.
  • cargo x check: the complete feature matrix passed.
  • cargo x lint: formatting, TOML, spelling, source headers, Clippy, and rustdoc passed.
  • MIRIFLAGS=-Zmiri-seed=0 cargo x miri: the complete workflow passed; additional bounded internals and reservation tests passed seeds 1, 2, and 3.
  • Tests cover held permits, exact capacity, cancellation wake handoff, receiver close with outstanding permits, Send-only payloads, old permits observing slot reuse, concurrent cancellation, publication order, counter wraparound, and panic-safe capacity ownership. Existing callback and wake-race suites remain exercised.

Benchmarks

Measured on Apple M4 Max, macOS 26.6.2, rustc 1.99.0-nightly (3d6c19bb9 2026-08-11). Compare main 64d7a94ddb5ab9f2e130f8f1cc8f392301e73ebe with demo 82d6992028b2b25fd42b50ead4e41e08b7440491. Both were rebuilt at the same temporary build path with the same common benchmark source; the reservation-only suite is excluded from main because its API does not exist there. Flume uses its async feature with default features disabled.

Five shuffled rounds, 70 invocations, 685 per-case measurements. Each cell is the median of five run medians. Bulk cases use 50 samples per run and 16,384 messages per sample with warmed, reused channels and tasks/threads. Microbenchmarks use 100 samples of 1,000 iterations. All use Divan's OS timer. Task measurements include scheduling and wakeups, with normal Tokio cooperation enabled; P counts producer tasks, which share four workers except the explicitly marked current-thread cases.

The prototype improves the inline 1 KiB cases, while scalar workloads show the cost of separate capacity reservation. At capacity 4096 with eight producer tasks, scalar elapsed time rises from 0.3387 ms to 0.7394 ms, although it remains below Tokio, async-channel, and Flume in that case. Capacity-one cross-worker handoff remains much slower than Flume, as it also is on main. These results support review as a feature/performance tradeoff.

Bounded scalar messages

All times are in ms; lower is better. Delta is demo time / main time − 1.

Case Main 64d7a94 Demo 82d6992 Delta Tokio 1.53.1 async-channel 2.5.0 Flume 0.12.0
OS threads, C=64, P=1 1.191 1.296 +8.8% 1.292 1.574 1.359
OS threads, C=64, P=2 1.314 1.515 +15.3% 1.84 1.887 1.898
OS threads, C=64, P=4 1.12 1.733 +54.7% 3.028 3.537 3.857
OS threads, C=64, P=8 3.844 4.67 +21.5% 31.66 24.78 23.03
Current-thread, C=1, P=1 1.978 2.168 +9.6% 2.058 3.748 1.782
Current-thread, C=1, P=4 2.061 2.155 +4.6% 2.077 4.578 1.767
4 workers, C=1, P=1 125.7 127.1 +1.1% 126.1 128.4 63.85
4 workers, C=1, P=4 127.4 127.3 -0.1% 125.3 127.7 29.71
4 workers, C=1, P=8 127.7 125.8 -1.5% 126.2 127.1 17
Current-thread, C=64, P=1 0.1871 0.2161 +15.5% 0.4003 0.5766 0.378
Current-thread, C=64, P=4 0.2071 0.23 +11.1% 0.4049 0.6156 0.4094
4 workers, C=64, P=1 2.173 2.179 +0.3% 2.165 2.112 2.133
4 workers, C=64, P=4 2.151 2.127 -1.1% 2.255 2.759 2.38
4 workers, C=64, P=8 2.099 2.089 -0.5% 2.366 3.159 3
Current-thread, C=4096, P=1 0.1488 0.1794 +20.6% 0.3875 0.5498 0.3219
Current-thread, C=4096, P=4 0.1542 0.1784 +15.7% 0.394 0.537 0.3225
4 workers, C=4096, P=1 0.2854 0.1822 -36.2% 1.184 0.6217 0.7854
4 workers, C=4096, P=4 0.3043 0.7457 +145.1% 2.782 1.554 1.216
4 workers, C=4096, P=8 0.3387 0.7394 +118.3% 2.823 1.404 1.289

Bounded inline 1 KiB messages

Four workers; every sample starts with an empty receive registration. Producer identity, per-producer sequence, and payload data are checked on every sample.

All times are in ms; lower is better. Delta is demo time / main time − 1.

Case Main 64d7a94 Demo 82d6992 Delta Tokio 1.53.1 async-channel 2.5.0 Flume 0.12.0
C=64, P=1 4.091 3.72 -9.1% 3.686 4.052 4.626
C=64, P=8 2.199 1.467 -33.3% 3.047 4.794 14.81
C=4096, P=1 3.667 2.003 -45.4% 3.35 3.498 3.686
C=4096, P=8 2.092 1.417 -32.3% 2.78 5.095 11.07

Bounded ready paths

All times are in ns; lower is better. Delta is demo time / main time − 1.

Case Main 64d7a94 Demo 82d6992 Delta Tokio 1.53.1 async-channel 2.5.0 Flume 0.12.0
try_send + try_recv 4.33 5.412 +25.0% 10.7 28.88 10.94
ready send + recv 6.162 8.121 +31.8% 19.91 30.25 16.87

Reservation API

Main has no reservation API. Compare the borrowed-permit paths with Tokio using the same reservation benchmark harness.

Case Unit Demo Tokio
Reserve, publish, receive ns 7.625 18.17
Reserve and cancel ns 3.625 5.817
Current-thread, C=64, P=1 ms 0.1906 0.3719
4 workers, C=64, P=8 ms 2.123 2.358
Current-thread, C=4096, P=1 ms 0.1551 0.3667
4 workers, C=4096, P=8 ms 0.6151 2.85

Reproduce the new cases with the repository benchmark workflow, narrowed to the ecosystem harness:

cargo bench --workspace --all-features --bench ecosystem -- mpsc::bounded::scheduled_inline --timer os
cargo bench --workspace --all-features --bench ecosystem -- mpsc::reservation --timer os

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