Zero-dependency, type-safe, context-aware event bus for Go.
Relay provides a simple Bus pattern for one-to-many communication using generics and Go channels. Built on the standard library with no external dependencies.
- Zero external dependencies. Only the Go standard library.
- Fire-and-forget or wait.
Broadcast()returns aBroadcastResult; ignore it for async delivery or callWait()to block until all subscribers have been handled. - Configurable slow-subscriber policy. Block (default), drop, or apply a timeout per subscriber.
- Per-subscription backpressure. Control the size of each subscriber's event buffer.
- Context-aware. Subscriptions respect cancellation, and the bus can be shut down cleanly with
Close(). - Observability hooks. Attach a callback to monitor broadcast latency and drop rates.
- Lock-free broadcasts. Subscriber snapshots use
atomic.Pointerfor zero contention on the hot path. - Sharded subscriber storage. Subscribe/unsubscribe scale with
GOMAXPROCSshards. - Graceful shutdown.
CloseGracefullywaits for in-flight broadcasts.
go get -u github.com/binozo/go-relayRequires Go 1.22 or later.
package main
import (
"context"
"fmt"
"github.com/binozo/go-relay"
)
func main() {
bus := relay.NewChanBus[string]()
defer bus.Close()
sub, err := bus.Subscribe()
if err != nil {
panic(err)
}
defer sub.Unsubscribe()
go func() {
for {
msg, err := sub.Listen(context.TODO())
if err != nil {
return
}
fmt.Println("Received:", msg)
}
}()
result := bus.Broadcast("Hello, Relay!")
_ = result.Wait() // optional: wait until the event has been delivered
}bus := relay.NewChanBus[string](
relay.WithPoolSize(4), // limit concurrent broadcast goroutines; defaults to GOMAXPROCS
relay.WithShardCount(8), // subscriber list shards; defaults to GOMAXPROCS
relay.WithSubscriptionBackpressure(10), // each subscriber gets a buffered channel of size 10
relay.WithSlowSubscriberPolicy(relay.SlowSubscriberPolicyDrop), // drop events for slow readers
relay.WithSlowSubscriberTimeout(100*time.Millisecond), // only used with SlowSubscriberPolicyTimeout
relay.WithBroadcastCallback(func(d time.Duration, alive, dropped int) {
fmt.Printf("broadcast: %d alive, %d dropped, took %v\n", alive, dropped, d)
}),
)// Query live subscriber count
fmt.Println("subscribers:", bus.Len())
// Query aggregate stats across all subscribers
agg := bus.Stats()
fmt.Printf("received=%d dropped=%d subscribers=%d\n", agg.EventsReceived, agg.EventsDropped, agg.Subscribers)
// Per-subscriber stats
stats := sub.Stats()
fmt.Printf("received=%d dropped=%d\n", stats.EventsReceived, stats.EventsDropped)If WithPoolSize is configured, the semaphore caps concurrent broadcasts. Use TryBroadcast for non-blocking attempts:
result, ok := bus.TryBroadcast("urgent")
if !ok {
// bus is saturated -- drop or retry later
}For blocking semantics (wait until the semaphore has room), use Broadcast:
result := bus.Broadcast("normal")
if err := result.Wait(); errors.Is(err, relay.ErrBroadcastFull) {
// only possible if poolSize > 0 and the bus is saturated
}| Method | Error | Meaning |
|---|---|---|
Bus.Subscribe() |
relay.ErrBusClosed |
Bus has been shut down. |
Bus.SubscribeContext(ctx) |
context.Canceled / context.DeadlineExceeded |
Context was cancelled before subscription completed. |
Bus.Broadcast() |
- | Returns a BroadcastResult; actual error comes from Wait(). |
BroadcastResult.Wait() |
relay.ErrBusClosed |
Bus closed during broadcast. |
BroadcastResult.Wait() |
relay.ErrBroadcastFull |
Semaphore saturated (WithPoolSize only). |
Subscription.Listen(ctx) |
relay.ErrSubscriptionClosed |
Subscription unsubscribed. |
Subscription.Listen(ctx) |
ctx error | Listener context was cancelled. |
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sub, err := bus.SubscribeContext(ctx)
if err != nil {
// handle error
}
defer sub.Unsubscribe()
// If ctx expires, Listen will return the context error
msg, err := sub.Listen(context.Background())ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := bus.CloseGracefully(ctx); err != nil {
// timeout -- some broadcasts were still in flight
}| Type | Description |
|---|---|
ChanBus[T] |
Main event bus for type T. |
BroadcastResult |
Handle for tracking a broadcast; call Wait() to block. |
Subscription[T] |
Receives events of type T via Listen(ctx). |
Stats |
Aggregated subscriber metrics. |
SlowSubscriberPolicy |
Block / Drop / Timeout strategy. |
Full API docs: pkg.go.dev
Run go test -bench=. -benchmem . in the project root.
Environment: Go 1.22, AMD Ryzen 5 5600X, Linux
| Subscribers | ops/sec | ns/op | B/op | allocs/op |
|---|---|---|---|---|
| 1 | ~270k | 3,709 | 672 | 9 |
| 10 | ~82k | 14,278 | 4,128 | 54 |
| 100 | ~18k | 64,305 | 38,708 | 504 |
| 1000 | ~2.4k | 497,939 | 384,609 | 5,006 |
| Policy | ops/sec | ns/op | B/op | allocs/op |
|---|---|---|---|---|
| Block | ~39k | 31,228 | 288 | 4 |
| Drop | ~40k | 29,698 | 288 | 4 |
| Timeout (1ms) | ~25k | 48,065 | 536 | 7 |
| Shards | ops/sec | ns/op | B/op | allocs/op |
|---|---|---|---|---|
| 1 | ~521k | 2,031 | 707 | 16 |
| 4 | ~567k | 2,039 | 732 | 16 |
| 12 | ~465k | 2,150 | 795 | 16 |
| Operation | ops/sec | ns/op | B/op | allocs/op |
|---|---|---|---|---|
TryBroadcast |
~5.8M | 194 | 97 | 1 |
Stats (10 subs) |
~6.1M | 193 | 80 | 1 |
Stats (100 subs) |
~1.2M | 1,015 | 896 | 1 |
Stats (1000 subs) |
~125k | 9,828 | 8,194 | 1 |
- Broadcast cost scales linearly with subscriber count by design: one sequential goroutine iterates the snapshot. The broadcast hot path itself allocates 3-5 times regardless of subscriber count. Higher numbers in
BenchmarkBroadcastcome from the benchmark's background listener goroutines, which create acontext.WithTimeouton everyListen()call. This is not overhead from Relay. TryBroadcastis sub-microsecond.- Churn throughput is ~500k/sec regardless of shard count because the dominant cost is goroutine creation, not shard lookup.
Relay is intentionally simple:
- Snapshots, not locks. On every broadcast, the bus takes an atomic snapshot of the current subscriber list. Readers never block writers and vice versa.
- Sharded maps. Subscribers are distributed across
GOMAXPROCSshards to reduce contention during subscribe/unsubscribe. - Channels everywhere. Events flow through Go channels for familiarity and composability with standard patterns.