SEDA bus · C# · 0.1.0

The CLR's own thread pool, not a hand-rolled one.

seda-bus-cs follows Java's concurrency model rather than Rust's: .NET, like the JVM, ships a real built-in thread pool and semaphore, so hand-rolling either would fight the platform instead of matching it. The one real design fix this port needed was tried once, made things worse, was reverted, and was later fixed with a completely different mechanism.

Worker pool
The shared, process-wide .NET ThreadPool — no owned pool
True stage parallelism
Yes — OS threads via the CLR's pool
Dependencies
ra-common-cs only
Envelope
Ra.Common.Envelope directly (sealed, no alias)
Routing slip
DynamicRoutingSlip — LIFO
Concurrency permits
SemaphoreSlim per channel, matching Java's Semaphore
Guaranteed delivery
No — in-memory only
Batch size
16 envelopes per drain
Config
record + with-based fluent builder
Race/sanitizer verified
Not run

How it implements the design

Idiomatic .NET, with one structural trade-off the shared pool forces.

No owned worker pool: Bus's constructor raises ThreadPool.SetMinThreads to an advisory floor so a burst of work isn't stalled behind the pool's default gradual thread-injection rate, then drains every stage on the shared, process-wide pool. That's this port's one real structural divergence from the shared design's “one shared worker pool” — here it's shared with the whole process, not scoped to one bus. Consequence: Shutdown can't Join() a pool it doesn't own, so an explicit Interlocked-counted in-flight tracker, not queue depth alone, is what shutdown actually waits on.

ConcurrentDictionary covers the channel registry, dead-letter map, callback map, and per-channel attempts map with no manual locking — a genuine simplification over the C++/Rust ports' guarded maps. Bus itself is a plain class with no Arc/shared_ptr-equivalent wrapper; the CLR's GC already keeps it alive for as long as a scheduled work item's closure references it.

The one fix attempted, reverted, then solved differently. The original Channel had a single lock guarding both push and pop on a LinkedList — the same pattern C++ had before its fix. Porting C++'s exact two-lock, pre-allocated design made things worse, not better (bimodal 95–390ms latency) — the working theory is that any blocking wait inside a work item scheduled on the shared ThreadPool gets punished by its gradual thread-injection throttling. Reverted rather than shipped as an uncertain win. A later pass fixed the real bottleneck with a different mechanism instead: two lock-free ConcurrentQueue lanes (fresh admissions and retries drained separately, retries always ahead), whose only wait (Block back-pressure) runs on the producer's own thread, never inside a ThreadPool-scheduled drain.

Results

The lowest capacity-curve ratios in the comparison — and the best firehose parallelism gain.

Capacity curve — sustained throughput under real, bounded back-pressure
ConfigSustained eps0.5×1.0×1.5×
cap1239,9520.800.690.59
cap8340,2950.710.600.52

C#'s ratio is the lowest across the board, including well below saturation (0.80 at cap1's 0.5× load, 0.71 at cap8's). Every C# trial in the raw data — both configs, every load fraction — shows a max latency between roughly 85,000 and 147,000µs regardless of load level, which points at a fixed, roughly-once-per-trial cost rather than ordinary contention scaling with concurrency — each trial constructs a fresh bus, and .NET's ThreadPool is known to inject new threads gradually under demand (roughly one per half-second to a second), a plausible source of exactly this shape of delay. Not confirmed by further investigation this pass.

Firehose throughput (envelopes/sec, unthrottled — diagnostic, not capacity planning)
Configepsvs. seq
seq (1 producer)280,944
par (8 producers, 1 channel)421,6881.50×
chan (8 producers, 8 channels)497,2391.77×

1.50× is the best par-vs-seq ratio anywhere in this comparison — direct evidence the two-lock-free-queue fix worked (272,370 → 421,688 eps, +55%, Docker-verified), and that avoiding a blocking wait inside a shared-pool work item was the right diagnosis.

Reading the numbers

Two different findings, not one contradiction.

The capacity-curve ratios (a real bounded queue under swept load) and the firehose par-vs-seq ratio (unthrottled, no back-pressure ever engaged) measure different things and aren't in tension: the fixed per-trial cost that drags the capacity-curve numbers down shows up regardless of load level, while the lock-free queue fix specifically targeted contention under sustained concurrent publishing, which the firehose par config isolates directly.

The full cross-language results, including the reverted fix attempt

Source

Read the code, or the rest of the family.

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