maillon
A concurrent intrusive list for building synchronization primitives.
Maillon is the French word for a chain link.
Features
- 100% safe API
#![no_std], no allocation- Atomic emptiness check to avoid acquiring the mutex if the list is empty
- Lock-free insertion: multiple nodes can be inserted concurrently while another is being removed; removal requires locking
- Optional atomic state embedded in the list when empty (to carry a semaphore counter, a closed flag, etc.)
WaitList, a high-level asynchronous wait list with customizable synchronization built on top of the low-levelList
Usage
WaitList is a ready-to-use asynchronous wait list, built on top of List:
use ;
use WaitList;
List is the building block: Nodes are pinned, carry user data implementing NodeData, and are pushed to the back without locking, while every other operation goes through List::lock. Here is a minimal wait list, supporting only notify_one:
use ;
use ;
node_wrapper!
See examples for full implementations of tokio::sync::Notify and tokio::sync::Semaphore built with maillon, with fully identical API and behavior.
Cargo Features
| Feature | Description |
|---|---|
std (default) |
std::sync-based mutex and condvar parker |
lock_api |
lock_api::RawMutex trait implementation |
parking_lot |
parking_lot mutex and parker; implies lock_api |
portable-atomic |
Atomics via the portable-atomic crate, for targets without native atomic support |
pthread |
Raw pthread mutex and condvar, on Unix targets only |
Without any features enabled, the library falls back to spin-based mutex and parker.
Performance
Results of the tokio benchmarks, run with both tokio native primitives and their maillon counterparts from examples on an Intel i7-1065G7:
benchmarks prefixed by contention/uncontented[^1] measure Semaphore performance
| Benchmark | maillon | tokio | maillon speedup |
|---|---|---|---|
notify_one/10 |
200.35 µs | 247.28 µs | 1.23 |
notify_one/50 |
252.83 µs | 272.73 µs | 1.08 |
notify_one/100 |
245.82 µs | 276.25 µs | 1.12 |
notify_one/200 |
245.36 µs | 291.20 µs | 1.19 |
notify_one/500 |
245.54 µs | 281.86 µs | 1.15 |
notify_waiters/10 |
245.75 µs | 410.82 µs | 1.67 |
notify_waiters/50 |
215.92 µs | 281.20 µs | 1.30 |
notify_waiters/100 |
210.31 µs | 259.56 µs | 1.23 |
notify_waiters/200 |
212.58 µs | 247.82 µs | 1.17 |
notify_waiters/500 |
355.55 µs | 254.90 µs | 0.72 |
contention/concurrent_multi |
7.90 µs | 8.53 µs | 1.08 |
contention/concurrent_single |
500.41 ns | 679.58 ns | 1.36 |
uncontented/concurrent_multi |
9.02 µs | 9.09 µs | 1.01 |
uncontented/concurrent_single |
529.70 ns | 624.12 ns | 1.18 |
uncontented/multi |
287.34 ns | 400.76 ns | 1.39 |
maillon-based reimplementations seem to give a consistent speedup compared to tokio native ones. The only exception is notify_waiters/500, and it can be explained by several factors:
- The benchmark results are extremely noisy, ranging from 200 µs to 400 µs, so
mailloncan in fact perform better thantokioon some runs. - The scenario is not very realistic: all the threads are hammering the same cache line with CAS loops to requeue or notify in tight loops. The key point is that
maillondoesn't use backoff in CAS loops, so they run in full-contention mode, whiletokio's native implementation serializes all operations. Adding exponential backoff to thepush_backoperation improves the result down to 150 µs. - CPU hyperthreading typically handles this kind of ultra-contended scenario badly. Pinning the process to 4 cores only, or reducing the number of worker threads to 3 in order to avoid hyperthreading also greatly improves the result. Combined with exponential backoff, time drops below 100 µs.
See benches/README.md for a comparison of maillon with other crates, still reusing their benchmarks. maillon-based implementations give significantly better results in almost all benchmarks.
Safety and testing
Concurrent intrusive lists are one of the most unsafe[^2] concepts in Rust, so this crate uses unsafe code. It is tested with both miri and loom to ensure algorithm correctness and memory safety.
Reimplementations of tokio::sync::Notify and tokio::sync::Semaphore are also tested on the full tokio test suite (also with miri and loom).
List exposes a 100% safe API, so WaitList and tokio reimplementations don't use unsafe code[^3].
However, the default algorithm of List does not support strict provenance. This is a known issue of AtomicPtr based concurrent data structures.
Alternatives
The main goal of maillon was originally to provide a WaitList with notify_one as cheap as possible when there is no waiter, i.e. read-only, with a customizable synchronization strategy between the wake condition and the waker registration (SeqCst atomic operations vs. SeqCst fences vs. RMWs on the wake condition). Lock-free waiter insertion then came as an appreciable benefit on top of that.
Synchronization primitives
| Type | Notify with no waiter | Customizable synchronization | Insertion |
|---|---|---|---|
maillon::WaitList |
(SeqCst fence with Synchronized +) atomic load |
yes | lock-free |
async_event::Event |
SeqCst fence + atomic load |
no | locked, node boxed |
event_listener::Event |
locks | fence can be skipped with relaxed() |
locked |
tokio::sync::Notify and maitake_sync::WaitQueue store a wakeup when there is no waiter, and thus can't be used for the WaitList use case.
futures_intrusive::sync::ManualResetEvent and asyncband::event::ManualResetEvent only provide the equivalent of notify_all, and are fully locked anyway.
Intrusive lists
maillon::List is also exported as a general-purpose concurrent intrusive list: nodes are pinned, and a node dropped while linked removes itself from the list, taking the lock only if it is still linked. Node unlinking on drop is the reason why the mutex must be embedded in the list to be able to provide a safe API. Most alternatives are not synchronized and must be wrapped into a mutex.
pin-list: safe, but not synchronized, and aborts if a node is dropped without having been removed and taken out of the list.pinlist: safe, with the list embedding its mutex and nodes removing themselves on drop, likemaillon; but every insertion and every drop takes the lock, and nodes can only leave the list by being dropped.
There are other crates providing/implementing intrusive lists, but whose lists own their nodes, such as intrusive-collections or cordyceps, and stack-allocated nodes requiring unsafe code. maillon has the opposite design: nodes borrow the list, which doesn't own them.
Related design
saa's design shares similarities with maillon: its synchronization primitives push waiters lock-free into their state word, which also holds a lock bit and a few data bits. However, its wait queue is private, the lock bit is a spinlock, dequeuing the front and canceling a waiter both walk the queue from its tail, and the data bits limit its semaphore to 63 permits.
Acknowledgements
The maillon::list::Drain algorithm reuses the idea originally introduced to tokio by Tymoteusz Wiśniewski in tokio-rs/tokio#5458: make the draining atomic by moving the list nodes into a temporary circular list.
A small improvement, motivated by API ergonomics, has been made: the circular chaining is deferred until the list lock actually needs to be released mid-drain.
License
Licensed under either of
at your option.
[^1]: The uncontented typo comes from the original tokio benchmark.
[^2]: There is literally a hack in the compiler to support them.
[^3]: Except for the pin projection of wait_list::wait::WaitUntil, written directly to avoid depending on pin-project-lite.