EventBus¶
EventBus is a Disruptor-style ring buffer for broadcasting typed events to multiple consumers. Uses lock-free sequencing with busy-spin waiting.
template <typename Event,
size_t CapacityPow2 = config::DEFAULT_EVENTBUS_CAPACITY,
size_t MaxConsumers = config::DEFAULT_EVENTBUS_MAX_CONSUMERS,
typename PublishSeam = NoPublishSeam>
class EventBus : public ISubsystem;
Purpose¶
- Deliver high-frequency events (market data, orders, etc.) to multiple subscribers with minimal latency and zero allocations on the hot path.
- Support CPU affinity and real-time thread priority for latency-critical components.
- Provide backpressure handling via timeout-based publishing.
Key Methods¶
| Method | Description |
|---|---|
subscribe(listener, required, wait) |
Registers a consumer. required=true (default) gates publishing. wait is ConsumerWaitMode::ACTIVE (default, spins) or PARKED (blocks, publisher wakes it). Returns bool. |
publish(event) |
Publishes event to ring buffer, returns sequence number (-1 if stopped). |
publishBatch(evs, count) |
Publishes a contiguous batch, returns the last sequence. -1 if stopped, if count == 0, or if count > CapacityPow2 / 2. |
tryPublish(event, timeout) |
Publishes with timeout. Returns {PublishResult, seq}. |
start() / stop() |
Starts or stops all consumer threads. stop() returns only once no publisher is inside the ring. |
waitConsumed(seq) |
Blocks until all required consumers have processed up to seq. |
flush() |
Waits until all published events are consumed by required consumers. |
consumerCount() |
Returns number of registered consumers. |
enableDrainOnStop() |
Ensures remaining events are dispatched before shutdown. |
setOwnConsumerThreads(bool) |
Before start(). false spawns no consumer threads: you step them. |
pollConsumer(i) |
One step over consumer i; false means nothing was there. One stepper per consumer. |
drainConsumer(i) |
Hands consumer i everything left in the ring, uncapped. |
consumerFailed(i) |
The listener threw; the slot is out of service and steps on it do nothing. |
consumerHealth(i) |
The last observed health of one consumer. Readable at any time, from any thread. |
healthSnapshot() |
Counts of stalled and dead consumers from the last sweep, without re-sweeping. |
consumerHealthReport(i) |
State, lastSeen and lastChange for one consumer, from a single sweep. |
monitorPeriod(threshold) |
Static. The period the built-in monitor thread sleeps between sweeps. |
PublishResult¶
enum class PublishResult
{
SUCCESS, // Event published successfully
TIMEOUT, // Buffer full, timeout expired
STOPPED // Bus not running
};
Backpressure Handling¶
When the ring buffer is full (consumers too slow), publish() blocks until space is available. Use tryPublish() with a timeout to handle backpressure:
auto [result, seq] = bus.tryPublish(event, std::chrono::microseconds{1000});
if (result == Bus::PublishResult::TIMEOUT) {
// Handle backpressure: drop event, log warning, etc.
}
TIMEOUT means the event never entered the ring: no slot was written and no
sequence number was spent, so the next accepted publish takes the sequence this
one would have had. stats().dropped counts the refusals. See
The Disruptor Pattern for why the two
publish paths claim their sequence at different points.
Batch Publishing¶
count must be between 1 and CapacityPow2 / 2; anything else is refused with
-1, in every build, and nothing is published. The upper bound is the ring's, not
a style rule: a range wider than the ring reserves slots it wraps back onto, so
the wrap gate would wait on sequences inside the same batch that only this
publisher can stamp. The refusal is a return, never a block.
Stopping¶
stop() takes the bus down in this order: publishing is closed, the publishers
already inside the ring are waited out, consumer threads are joined (draining
first if enableDrainOnStop() was set), and only then are the remaining events
destroyed and the gating lines reset.
The contract that follows from it:
- A publish is either refused --
publish()returns -1,publishBatch()returns -1,tryPublish()returnsSTOPPED-- or its event is in the ring. An accepted event is never thrown away by the shutdown before the consumers that were going to get it have had their chance (withenableDrainOnStop(), that means delivered; without it, the undrained tail is dropped as documented below). - When
stop()returns, no publisher is writing into a slot any more, so the bus can be destroyed or restarted. stop()waits for a publisher that is inside the ring, including one parked in the event's own copy constructor. A publisher blocked at the wrap gate or the reclaim fence gives up instead of waiting, so a stalled consumer does not keepstop()from returning.- Sequence numbers restart from zero on the next
start(), so they are unique within a run and not across runs.
Consumer Health¶
consumerHealth(i), healthSnapshot() and consumerHealthReport(i) are
level-triggered queries: they read what the last sweep recorded, from any
thread, while the sweep is running and after the bus has stopped.
const auto report = bus.consumerHealthReport(0);
// report.state -- HEALTHY, STALLED or DEAD
// report.lastSeen -- the sequence this consumer had reached
// report.lastChange -- when that sequence last moved
The three fields come from one sweep: lastChange moves only together with
lastSeen, so the pair says whether the consumer is progressing and, if not,
since when. A state on its own does not.
With enableMonitorThread, the sweep runs on a thread of the bus's own.
monitorPeriod(stallThreshold) is the period it sleeps between sweeps: half the
threshold, floored at 1 ms. The floor matters because the arithmetic is integer
milliseconds -- without it a threshold under 2 ms halves to zero and the monitor
holds a core. An idle bus costs no measurable CPU at any threshold.
The publish seam¶
One window in the publish path cannot be reached from outside the bus: the
instant after publish() has read "is the bus running" and before it claims a
sequence. It is the window the stop contract above is built around -- a
publisher preempted there comes back with an arbitrarily old answer -- so a
test needs a way into it.
Pass a type of the same shape as the fourth template argument and its
beforeClaim() runs at that instant, on publish(), tryPublish() and
publishBatch() alike. The default does nothing and compiles to nothing, so a
production bus carries no branch, no member and no call for it; a bus with a
seam is a distinct type from the bus the engine builds.
CPU Affinity (when LRVX_CPU_AFFINITY_ENABLED)¶
enum class ComponentType
{
MARKET_DATA,
EXECUTION,
STRATEGY,
RISK,
GENERAL
};
struct AffinityConfig
{
ComponentType componentType = ComponentType::GENERAL;
bool enableRealTimePriority = true;
int realTimePriority = config::DEFAULT_REALTIME_PRIORITY;
bool enableNumaAwareness = true;
bool preferIsolatedCores = true;
};
| Method | Description |
|---|---|
setAffinityConfig(cfg) |
Configure CPU affinity and RT priority. |
setCoreAssignment(assign) |
Manually set core assignment. |
setupOptimalConfiguration() |
Auto-configure for component type. |
verifyIsolatedCoreConfiguration() |
Verify isolated core setup. |
Consumer threads are distributed across available cores using round-robin assignment.
Design Highlights¶
- Disruptor Pattern: Single producer, multiple consumers with sequence-based coordination.
- Ring Buffer: Fixed-size power-of-2 capacity with wrap-around.
- Busy-Spin Waiting: Uses
BusyBackofffor low-latency polling. - Gating Sequence: Publishers wait for slowest required consumer before overwriting.
- Per-Consumer Threads: Each consumer runs in dedicated
std::jthread. - Zero Allocations: Events stored directly in pre-allocated ring buffer slots.
- Tick Sequencing:
tickSequencefield is automatically set if present on event. - In-Place Construction: Events constructed via placement new, destructed on reclaim.
- Thread-Safe Subscribe:
subscribe()returns false if called afterstart(). - Overflow Protection: Sequence counter overflow is detected and handled.
Statistics¶
struct Stats
{
uint64_t published{0};
uint64_t dropped{0};
uint64_t consumed{0};
};
Stats stats() const;
stats() snapshots three relaxed-load counters. The counts are not a consistent triple — each is
read independently — so treat them as monotonic indicators, not as an invariant
(published == consumed + dropped may not hold at the instant of the call).
Internal Types¶
| Name | Description |
|---|---|
ConsumerSlot |
Per-consumer state: listener, sequence, thread, required, coreIndex. |
Listener |
Inferred from Event::Listener via ListenerType trait. |
PublishResult |
Enum for publish outcome (SUCCESS, TIMEOUT, STOPPED). |
Template Parameters¶
| Parameter | Default | Description |
|---|---|---|
Event |
- | Event type to broadcast. |
CapacityPow2 |
config::DEFAULT_EVENTBUS_CAPACITY (4096) |
Ring buffer size (power of 2). |
MaxConsumers |
config::DEFAULT_EVENTBUS_MAX_CONSUMERS (128) |
Maximum consumer count. |
PublishSeam |
NoPublishSeam |
Test seam on the publish path. The default's hook is an empty static function: it inlines away and the publish path is unchanged. Leave it alone outside tests. |
Example Usage¶
using BookBus = EventBus<pool::Handle<BookUpdateEvent>>;
BookBus bus;
// subscribe() returns bool - check for success
if (!bus.subscribe(&bookHandler)) {
// Handle error: null listener, bus running, or at capacity
}
#if LRVX_CPU_AFFINITY_ENABLED
bus.setupOptimalConfiguration(BookBus::ComponentType::MARKET_DATA);
#endif
bus.start();
// Standard publish (blocks on backpressure)
auto seq = bus.publish(std::move(bookUpdateHandle));
// Publish with timeout (non-blocking backpressure handling)
auto [result, seq2] = bus.tryPublish(event, std::chrono::microseconds{500});
if (result == BookBus::PublishResult::TIMEOUT) {
LOG_WARN("Backpressure detected, event dropped");
}
bus.flush();
bus.stop();
Required vs Optional Consumers¶
Consumers can be registered as required (default) or optional:
bus.subscribe(&criticalHandler, true); // required (default)
bus.subscribe(&loggingHandler, false); // optional
Behavior differences¶
| Aspect | Required Consumer | Optional Consumer |
|---|---|---|
| Gating | Blocks waitConsumed() and flush() |
Does not block these methods |
| Backpressure | Can cause publisher to wait | Never causes backpressure |
| Event delivery | Always receives all events | Always receives all events |
| Reclaim | Events reclaimed after processing | Events reclaimed after all consumers process |
Key guarantee¶
All consumers (required and optional) are guaranteed to receive every event, even during ring buffer wrap-around. The bus ensures events are not destroyed until all consumers have processed them.
Use cases¶
- Required: Strategy handlers, risk managers, order routers - anything that must process every event
- Optional: Logging, metrics, debugging tools - where occasional delays shouldn't block the main flow
Notes¶
- Capacity must be a power of 2 for efficient masking.
- Optional consumers don't block
waitConsumed()orflush(), but still receive all events. subscribe()must be called beforestart()- returns false otherwise.enableDrainOnStop()should be called beforestart()if drain behavior is needed.- CPU affinity features require
LRVX_CPU_AFFINITY_ENABLEDcompile flag. publish()returns -1 if the bus is not running.publishBatch()returns -1 for an empty batch and for one larger than half the ring.
Benchmarking¶
Run event_bus_benchmark to measure performance on your hardware:
Example results on Intel i5-1135G7 @ 2.40GHz (4 cores / 8 threads):
| Benchmark | Time | Throughput |
|---|---|---|
| PublishLatency | 50 ns | 20 M/s |
| SingleConsumerThroughput | 61 µs/1000 | 16 M/s |
| MultiConsumer (4) | 195 µs/1000 | 5 M/s |
| TryPublishLatency | 110 ns | 9 M/s |
| EndToEndLatency | 200 ns | 5 M/s |