Repository navigation
Description
On a fresh zero-permit semaphore, registering a pending request and handing it one permit took 232 ms versus 64 ms for Tokio over three million handoffs. The benchmark verifies that the waiter becomes ready and forgets the permit to return the semaphore to zero.
Apple M4 Max, macOS arm64, rustc 1.101.0-nightly. Each cell gives rank, elapsed time relative to Asyncband and the whole-batch median over 10 interleaved runs. Lower is faster. Supplemental measurements
| Scenario | Fixed rounds | Asyncband | Tokio | async-lock |
|---|---|---|---|---|
| Hand one permit to a waiting caller | 3,000,000 | #2, 1.00× (232 ms) | #1, 0.28× (64 ms) | — |
| Try and return one available permit | 30,000,000 | #3, 1.00× (271 ms) | #1, 0.67× (181 ms) | #2, 0.84× (228 ms) |
Object lifetime explains much of the first row on this host. With the same three million handoffs, a fresh semaphore each round took 233 ms for Asyncband and 65 ms for Tokio. Reusing one across rounds gave 86 and 63 ms. Allocation counting found two allocations per fresh Asyncband handoff and zero for Tokio. After warm-up both reused instances had zero allocations per handoff. Asyncband's first waiter goes through the queue lock and arena. Tokio stores its waiter node inside the acquisition future. Tokio's pinned implementation
First contribution
Reproduce both lifetimes with the same zero-permit protocol, then isolate the queue's first allocation. A change should improve first registration and keep the reused case competitive. Check multi-permit ordering, cancellation after a partial or full grant, and absence of missed wakeups. PR #394 addresses acquisition-future size and is related work. PR #394
To run the supplemental peer_checks command below, save and run peer-checks.rb from the linked comment in a disposable checkout of 6102089. The benchmark and library sources match 36ba6f2. Generator and instructions
cargo x bench --bench primitives -- semaphore::acquire::handoff_permit --repeat 10 --raw
cargo x bench --bench peer_checks -- semaphore/ --repeat 10 --raw
Activity
rokadepiyush49-rgb commentedon Nov 6, 2026
Could you assign this to me? I'd run
semaphore::acquire::handoff_permiton fresh and reused semaphores on an Apple M4 (10 cores, macOS 27.0.1), then repeat on Linux, counting allocations for each.My guess is that the two allocations are the first lock of
waiterson macOS and the firstArenaslot. In a small standalone probe, a freshMutexallocated 64 bytes on first lock. That one should disappear on Linux, which would show how much of the gap is the queue.If the queue does matter, an inline first slot would keep
constconstruction but make every semaphore larger, while a node in the future would undo some of #394. I'd want your view on that before writing anything. I haven't run the benchmarks yet. I used Claude to help me read the code and write the probe.tisonkun commentedon Nov 6, 2026
Thanks for your interest! We don't assign issues to individual contributors. Anyone is welcome to submit a PR directly for review.
nanami-0713 commentedon Nov 9, 2026
Reproduced both lifetimes on Apple M5 (10 cores), macOS 27.0.1, rustc 1.99.1 stable, in a disposable checkout of
6102089(PR #383 head, content-identical to the issue's36ba6f2), with the issue'speer-checks.rbgenerator running thesemaphorescenarios with--repeat 10.semaphore/pending-handoff— a fresh semaphore per roundsemaphore/try-one-and-return— one semaphore per repetitionsemaphore/reused-handoff— the handoff round below, one semaphore per repetitionMedians over 10 interleaved runs; ranges are min..max. The first two rows are the
semaphore/scenarios of the generatedpeer_checksbench. The third row is a local adapter on top of the repository's ecosystem bench (patch below): the samepending-handoffround —acquire(1)polled to pending,release(1), polled to ready,forget()— with the semaphore constructed once per repetition instead of once per round, through the same interleaved runner against the same library sources, executed back-to-back with the peer bench in one session.Allocation counting (a
GlobalAlloccounter, 1,000,000 rounds; per-round counts are exact in every cell): a fresh Asyncband handoff performs two allocations per round against one for Tokio, and after warm-up both reused semaphores allocate nothing per handoff. Backtraces from the probe identify both sites (each recurs on every round):waitersmutex heap-initializes the OS mutex: on this pthread-backed platformstd::sync::Mutexboxes the OS mutex lazily (std::sys::sync::once_box::OnceBoxin the trace; asyncband'sinternal::mutex::Mutexwrapsstd::sync::Mutexdirectly). Tokio's batch semaphore locks astd-backed mutex on the same path and pays the same boxing — this is its one fresh-round allocation.Arena::insertgrows the waiter slot vector from empty on the first registration, 4 × 48-byte slots (Arena<Node<WaitNode>>::insertreachingRawVec::grow_one, both underacquired_or_enqueue). This one is Asyncband-specific.So the fresh-lifetime overhead is the queue's first-registration cost paid every round: the mutex's lazy boxing, shared with Tokio on this platform, plus the arena's first growth, which is Asyncband's extra allocation. Both disappear on a reused semaphore — the same handoff runs at 2.4x Tokio's time on a fresh semaphore and 1.12x on a reused one (the try/return row, which never queues a waiter, is 1.26x on a reused semaphore).
Adapter patch —
benchmarks/benches/ecosystem/semaphore.rsplus themod/register lines inmain.rs