Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ All notable changes to this project will be documented in this file.
* Finish releasing buffered bounded MPSC messages even if one message destructor panics.
* Improve unbounded MPSC throughput with batched receiving and incremental storage reclamation; empty-buffer retention is bounded independently of previous peak occupancy.
* Make completed and abandoned `Completion` waits lock-free while preserving cancellable pending registration.
* Scale broadcast backlog reclamation with the number of messages released rather than the number of subscriptions, removing the per-receive cursor scan and the lingering cost of receivers dropped after a peak.
* Avoid heap allocation when waking up to 32 waiters in Barrier, broadcast, condvar, event, MPSC, phaser, and watch notifications; larger waiter sets spill to a single heap allocation.

## v0.7.2 (2026-09-11)

Expand Down
6 changes: 4 additions & 2 deletions asyncband/src/barrier/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ use std::task::Poll;

use crate::internal::mutex::Mutex;
use crate::internal::wake_all;
use crate::internal::waker_batch::WakerBatch;
use crate::internal::wakerset::WakerSet;
use crate::internal::wakerset::WakerToken;

Expand Down Expand Up @@ -188,9 +189,10 @@ impl Barrier {
if state.arrived == self.n {
state.arrived = 0;
state.generation += 1;
let wakers = state.waiters.drain();
let mut wakers = WakerBatch::new();
state.waiters.drain_into(&mut wakers);
drop(state);
wake_all(wakers);
wake_all(&mut wakers);
return BarrierWaitResult(true);
}

Expand Down
12 changes: 7 additions & 5 deletions asyncband/src/broadcast/mpmc/bounded/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ use crate::internal::mutex::Mutex;
use crate::internal::semaphore::Acquire;
use crate::internal::semaphore::Semaphore;
use crate::internal::wake_all;
use crate::internal::waker_batch::WakerBatch;
use crate::internal::wakerset::WakerToken;

#[cfg(test)]
Expand Down Expand Up @@ -195,7 +196,7 @@ impl<T> Shared<T> {
/// Hands `freed` released slots back to producers parked in `send`.
///
/// Capacity is `retained()`, which is `buffer.len()`. The buffer grows only in
/// `Backlog::publish_retained` and shrinks only in `Backlog::reclaim_consumed`, which is
/// `Backlog::publish_retained` and shrinks only in `Backlog::reclaim_vacated`, which is
/// reachable from exactly two places: a receive that vacates the last cursor at the backlog
/// head, and removing a subscription. Those are the only callers of this method, so no path
/// can free capacity without waking a producer. Subscribing cannot: a new cursor starts at the
Expand Down Expand Up @@ -438,7 +439,8 @@ impl<T> BoundedSender<T> {
/// observe an empty buffer and park after this message became visible.
fn publish<P>(&self, payload: P, into_msg: impl FnOnce(P) -> Arc<T>) -> Result<(), P> {
let mut discarded = None;
let wakers = {
let mut wakers = WakerBatch::new();
{
let mut inner = self.shared.inner.lock();

if !inner.log.has_receivers() {
Expand All @@ -453,10 +455,10 @@ impl<T> BoundedSender<T> {
inner.log.publish_retained(into_msg(payload));
}

inner.waiters.drain()
};
inner.waiters.drain_into(&mut wakers);
}

wake_all(wakers);
wake_all(&mut wakers);
drop(discarded);
Ok(())
}
Expand Down
154 changes: 84 additions & 70 deletions asyncband/src/broadcast/mpmc/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ enum Retention {
Fixed,
}

/// A retained message together with the number of receiver cursors positioned at its version.
///
/// While a cursor sits on a version the message stays readable, so once the count reaches zero the
/// head can advance past the slot. Tracking the count per slot is what lets reclaim release the
/// invisible prefix directly instead of scanning every subscription for the slowest cursor.
struct Slot<T> {
msg: Arc<T>,
/// The number of receivers whose next read is this message.
cursors: usize,
}

/// The committed backlog: every message whose version falls in `[head, tail)`, plus one cursor for
/// each active subscription.
///
Expand All @@ -102,17 +113,20 @@ enum Retention {
/// is placed in `buffer` in the same critical section, so a later publication can never become
/// visible ahead of an earlier one.
pub struct Backlog<T> {
/// Messages whose versions are in the range `[head, tail)`.
/// Messages whose versions are in the range `[head, tail)`, each with its cursor count.
///
/// Each message is held behind an `Arc` so the receive path can move the payload out of the
/// critical section. Cloning the `Arc` under the lock keeps `T::clone` — and, for reclaimed
/// messages, `T::drop` — outside it, which matters because both are arbitrary user code that
/// may call back into this channel.
buffer: VecDeque<Arc<T>>,
buffer: VecDeque<Slot<T>>,
/// The version of the first message in `buffer`.
head: u64,
/// The number of active receivers whose cursor equals `head`.
head_receivers: usize,
/// The number of receivers whose cursor equals `tail`.
///
/// Caught-up cursors have no buffered slot to count against, so they are tallied here. Every
/// cursor is counted exactly once: either in one slot's `cursors` or in `at_tail`.
at_tail: usize,
/// The next message version to assign.
tail: u64,
/// Cursor for each active receiver.
Expand All @@ -131,11 +145,11 @@ impl<T> Backlog<T> {
Self::new(VecDeque::with_capacity(capacity), Retention::Fixed)
}

fn new(buffer: VecDeque<Arc<T>>, retention: Retention) -> Self {
fn new(buffer: VecDeque<Slot<T>>, retention: Retention) -> Self {
Self {
buffer,
head: 0,
head_receivers: 0,
at_tail: 0,
tail: 0,
receivers: Arena::new(),
retention,
Expand Down Expand Up @@ -187,7 +201,7 @@ impl<T> Backlog<T> {
pub fn publish_discarded(&mut self) {
debug_assert!(!self.has_receivers());
debug_assert!(self.buffer.is_empty());
debug_assert_eq!(self.head_receivers, 0);
debug_assert_eq!(self.at_tail, 0);
self.advance_tail();
self.head = self.tail;
}
Expand Down Expand Up @@ -222,73 +236,74 @@ impl<T> Backlog<T> {
pub fn publish_retained(&mut self, msg: Arc<T>) {
debug_assert!(self.has_receivers());
self.advance_tail();
self.buffer.push_back(msg);
// Every cursor that was caught up now has this message as its next read.
let cursors = mem::take(&mut self.at_tail);
self.buffer.push_back(Slot { msg, cursors });
if let Retention::Elastic { peak_len } = &mut self.retention {
*peak_len = (*peak_len).max(self.buffer.len());
}
}

fn insert_receiver(&mut self, head: u64) -> SlotId {
if head == self.head {
self.head_receivers += 1;
}

self.receivers.insert(head)
}

/// Registers a new subscription at the committed tail.
///
/// A new cursor never lowers `retained()`, so this can never release capacity.
pub fn subscribe(&mut self) -> SlotId {
let head = self.tail;
self.insert_receiver(head)
self.at_tail += 1;
self.receivers.insert(self.tail)
}

pub fn remove_receiver(&mut self, key: SlotId) -> Reclaimed<T> {
let head = self.receivers.remove(key);

if head == self.head {
self.release_head_receiver()
} else {
Reclaimed::empty()
let cursor = self.receivers.remove(key);
if cursor == self.tail {
self.at_tail -= 1;
return Reclaimed::empty();
}
}

fn release_head_receiver(&mut self) -> Reclaimed<T> {
self.head_receivers -= 1;

if self.head_receivers == 0 {
self.reclaim_consumed()
let offset = (cursor - self.head) as usize;
let slot = &mut self.buffer[offset];
slot.cursors -= 1;
if offset == 0 && slot.cursors == 0 {
self.reclaim_vacated()
} else {
Reclaimed::empty()
}
}

pub fn receive(&mut self, key: SlotId) -> Option<Received<T>> {
let head = {
let version = {
let cursor = self
.receivers
.get_mut(key)
.expect("active broadcast receiver must be registered");
if *cursor >= self.tail {
return None;
}
let head = *cursor;
let version = *cursor;
*cursor += 1;
head
version
};

debug_assert!(head >= self.head);
let offset = (head - self.head) as usize;
let msg = self.buffer[offset].clone();
let reclaimed = if head == self.head {
self.release_head_receiver()
debug_assert!(version >= self.head);
let offset = (version - self.head) as usize;
// Count the cursor at its next version before it leaves this one, so a reclaim triggered
// by leaving `head` stops at the message this receiver reads next.
if version + 1 == self.tail {
self.at_tail += 1;
} else {
self.buffer[offset + 1].cursors += 1;
}

let slot = &mut self.buffer[offset];
let msg = slot.msg.clone();
slot.cursors -= 1;
let reclaimed = if offset == 0 && slot.cursors == 0 {
self.reclaim_vacated()
} else {
Reclaimed::empty()
};
// A reclaim triggered by this receive always begins with this receiver's own message: the
// reclaim path runs only for a cursor sitting at `head`, so the first slot drained is
// `msg`. `take_msg` relies on this to recognize that it owns the payload.
// reclaim path runs only for a cursor leaving `head`, so the first slot drained is `msg`.
// `take_msg` relies on this to recognize that it owns the payload.
debug_assert!(
reclaimed
.first()
Expand All @@ -297,47 +312,46 @@ impl<T> Backlog<T> {
Some((msg, reclaimed))
}

/// Advances `head` to the slowest active cursor and hands the released prefix to the caller.
/// Releases the prefix no cursor can still read and hands it to the caller.
///
/// The head slot's cursor count has just reached zero, so every message up to the next counted
/// slot is invisible: each cursor is counted in exactly one place, and none of those places is
/// a slot being popped. Popping the zero-count prefix replaces the old scan over every
/// subscription for the slowest cursor, so advancing the head costs the messages released
/// instead of the receivers subscribed.
///
/// A receive releases exactly one message: its cursor is counted at the next version before
/// it leaves `head`, so the zero-count prefix ends there. Only removing a lagging subscription
/// can release more.
///
/// `buffer` shrinks here and grows only in [`Backlog::publish`], so this is the one place
/// `retained()` can fall. A bounded channel therefore accounts for released capacity at
/// exactly the two call sites that reach this: [`Backlog::receive`] and
/// [`Backlog::remove_receiver`].
fn reclaim_consumed(&mut self) -> Reclaimed<T> {
let mut next_head = self.tail;
let mut head_receivers = 0;

for head in self.receivers.values() {
if *head < next_head {
next_head = *head;
head_receivers = 1;
} else if *head == next_head {
head_receivers += 1;
}
}
fn reclaim_vacated(&mut self) -> Reclaimed<T> {
debug_assert!(self.buffer.front().is_some_and(|slot| slot.cursors == 0));

debug_assert!(next_head >= self.head);
let consumed = usize::try_from(next_head - self.head)
.expect("retained broadcast message count exceeds usize");
// Move reclaimed messages out so their Drop impls run after the channel is unlocked. Keep
// the first one separate so the usual one-message reclaim does not allocate another buffer.
let first = if consumed == 0 {
None
} else {
self.buffer.pop_front()
};
// Reclaiming exactly one message is the overwhelmingly common case — a cursor advances by
// one at a time — so skip building a `Drain` that would yield nothing.
let rest = if consumed > 1 {
self.buffer.drain(..consumed - 1).collect()
// the first one separate so the usual one-message reclaim does not allocate, and skip
// building a `Drain` that would yield nothing: even an empty one costs a few nanoseconds
// on every receive. A bulk reclaim counts the zero-count prefix up front so it moves out
// in one drain instead of growing a vector geometrically, which measured 15% slower for a
// 32-message backlog.
let first = self.buffer.pop_front().map(|slot| slot.msg);
let extra = self
.buffer
.iter()
.take_while(|slot| slot.cursors == 0)
.count();
let rest = if extra == 0 {
Vec::new()
} else {
vec![]
self.buffer.drain(..extra).map(|slot| slot.msg).collect()
};
let reclaimed = Reclaimed { first, rest };
debug_assert_eq!(reclaimed.len(), consumed);

self.head = next_head;
self.head_receivers = head_receivers;
self.head += reclaimed.len() as u64;
debug_assert!(self.head <= self.tail);
self.shrink_buffer();
reclaimed
}
Expand Down
10 changes: 6 additions & 4 deletions asyncband/src/broadcast/mpmc/unbounded/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ use super::error::TryRecvError;
use crate::internal::arena::SlotId;
use crate::internal::mutex::Mutex;
use crate::internal::wake_all;
use crate::internal::waker_batch::WakerBatch;
use crate::internal::wakerset::WakerToken;

#[cfg(test)]
Expand Down Expand Up @@ -176,16 +177,17 @@ impl<T> UnboundedSender<T> {

// Publishing and draining the wait set share one critical section, so a receiver can never
// observe an empty buffer and park after this message became visible.
let (unretained, wakers) = {
let mut wakers = WakerBatch::new();
let unretained = {
let mut inner = self.shared.inner.lock();
let unretained = inner.log.publish(msg);
let wakers = inner.waiters.drain();
(unretained, wakers)
inner.waiters.drain_into(&mut wakers);
unretained
};

// Notify all waiting receivers. An unsent message is dropped here too, once the lock is
// released.
wake_all(wakers);
wake_all(&mut wakers);
drop(unretained);
}

Expand Down
10 changes: 4 additions & 6 deletions asyncband/src/condvar/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,9 @@ impl Condvar {
/// If no task is currently waiting, this call has no effect. Notifications are not buffered for
/// future calls to [`wait`](Self::wait) or [`wait_owned`](Self::wait_owned).
pub fn notify_all(&self) {
let wakers = {
let mut wakers = WakerBatch::new();
{
let mut waiters = self.waiters.lock();
let mut wakers = WakerBatch::new();

while waiters
.unlink_first_waiter(|node| {
Expand All @@ -173,11 +173,9 @@ impl Condvar {
})
.is_some()
{}
}

wakers
};

wake_all(wakers.into_iter());
wake_all(&mut wakers);
}

/// Waits for a notification, atomically releasing and then reacquiring the mutex.
Expand Down
Loading