SEDA bus · C++ · 0.1.0

Rust's model, translated almost mechanically — with one real surprise.

seda-bus-cpp is header-only C++20, following seda-bus-rust's concurrency model closely: real OS threads, no garbage collector, so the shape translates directly. It's also the one implementation in this comparison whose 8-producer throughput came in lower than its 1-producer number — a real finding, tracked down and fixed, not a fluke.

Worker pool
Hand-rolled — a guarded std::deque job queue, standing in for Rust's mpsc
True stage parallelism
Yes — OS threads
Dependencies
ra-common-cpp only
Envelope
ra::common::Envelope, aliased as ra::seda_bus::Envelope
Routing slip
DynamicRoutingSlip — LIFO
Concurrency permits
Atomic-CAS loop, same mechanism as Rust
Guaranteed delivery
No — in-memory only
Batch size
16 envelopes per drain
Distribution
Header-only — no separate compilation step
Race/sanitizer verified
Yes — ThreadSanitizer, clean, in Docker

How it implements the design

No GC, so Rust's shape ports almost line for line.

Bus wraps a std::shared_ptr<detail::Impl>, mirroring Rust's Arc<Inner> — copies are cheap and share state. Consumer is std::function<bool(Envelope&)>, the direct analogue of Rust's blanket Fn(&mut Envelope) -> bool implementation, so lambdas subscribe directly with no adapter class. A thrown exception from a consumer is caught and treated as a nack, exactly like Rust's catch_unwind. std::shared_mutex guards the channel registry and each stage's consumer list (read far more than written); a plain std::mutex guards the dead-letter and callback maps — matching Rust's RwLock/Mutex split exactly.

The one real design bug this port had, and the fix. Originally, Channel::Offer (push) and Poll (pop) shared a single std::mutex guarding the whole queue — so every producer and every consumer on a stage contended on the same lock, regardless of which end they touched. This was this comparison's worst throughput collapse under 8-way concurrency. A first fix attempt used a textbook two-lock (Michael & Scott) queue over a linked list: it fixed the contention but broke the previously-clean single-producer case, introducing a bimodal cold-stall pattern from 200,000 individual heap allocations where std::deque had amortized growth. The working fix backs the same two-lock algorithm with a pre-allocated circular buffer instead, removing the allocator from the hot path entirely — verified clean under ThreadSanitizer in Docker, 13/13 tests, 75/75 assertions.

(Three unrelated construction-time bugs in ra-common-cpp — re-opened /dev/urandom handles, a JSON serialize-then-reparse route clone, and a process-wide mutex around random byte generation — were found and fixed in an earlier pass of this benchmark. They mattered to anyone constructing envelopes directly, but don't show up in the dispatch-only numbers below, which exclude envelope construction from the timed window.)

Results

The only port where 8 producers beat 1 producer — backwards.

Capacity curve — sustained throughput under real, bounded back-pressure
ConfigSustained eps0.5×1.0×1.5×
cap1179,1190.920.890.86
cap890,9800.860.830.69

C++ is the only implementation in this comparison whose cap8 sustained throughput is lower than its cap1 (90,980 vs 179,119 — every other port's cap8 beats its own cap1). This is real, not a measurement artifact: under a genuinely bounded, contended queue with 8 producers and 8 concurrent consumers, head/tail-lock contention has a real cost that unthrottled firehose conditions don't fully expose. It's also this port's mildest 1.5× overload drop of any config in the whole comparison (0.86) — its single-producer dispatch path is fast enough that 150% of its own ceiling barely moves the needle.

Firehose throughput (envelopes/sec, unthrottled — diagnostic, not capacity planning)
Configepsvs. seq
seq (1 producer)333,154
par (8 producers, 1 channel)264,5530.79×
chan (8 producers, 8 channels)396,9871.19×

Before the two-lock ring-buffer fix, this benchmark's worst par collapse held at 0.34–0.47× across three earlier passes, traced each time to this exact mutex under Docker's virtualization. The fix took it from 68,998 to 253,233 eps in one Docker-verified run (~3.7×) — the 0.79× ratio above is a normal, much smaller residual: 8 producers still funnel into one stage, it just no longer pays a mutex tax on top.

Reading the numbers

Latency shape confirms the fix worked, not just the throughput.

seq stays this comparison's cleanest case (p50/elapsed 0.01/0.06); par moved from a rare severe cliff to a “mild backlog” classification (0.01/0.27) as a direct consequence of sustaining far more real throughput — by Little's law, doing more real work per second naturally carries a bigger average queue depth, which is what a higher p50/elapsed ratio means here. chan still shows a real, sustained backlog (0.15/0.74), the same class of finding this comparison found across most compiled and interpreted ports once producers weren't artificially throttled by construction cost.

The full cross-language results, including the two-lock queue investigation

Source

Read the code, or the rest of the family.

seda-bus-cpp on GitHub — source, its own DESIGN.md, and the test suite.