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.
ThreadPool — no owned poolra-common-cs onlyRa.Common.Envelope directly (sealed, no alias)DynamicRoutingSlip — LIFOSemaphoreSlim per channel, matching Java's Semaphorerecord + with-based fluent builderHow 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.
| Config | Sustained eps | 0.5× | 1.0× | 1.5× |
|---|---|---|---|---|
cap1 | 239,952 | 0.80 | 0.69 | 0.59 |
cap8 | 340,295 | 0.71 | 0.60 | 0.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.
| Config | eps | vs. seq |
|---|---|---|
seq (1 producer) | 280,944 | — |
par (8 producers, 1 channel) | 421,688 | 1.50× |
chan (8 producers, 8 channels) | 497,239 | 1.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.