
indexsub
indexsub.Notifier is a synchronization primitive that owns a monotonically increasing index. It allows goroutines to wait until progress reaches a target value without polling. A single subscribed index satisfies any number of goroutines waiting for the same target index.
Callers subscribing to a target index are handed a Sub, which becomes satisfied (its channel closes) once the notifier's index reaches or exceeds that target.
Features:
- Ordered notifications. Progress never regresses, once a subscription is satisfied it remains satisfied.
- Per-index notifications. Each subscribed index has its own signal, all waiters on the same index share it.
- High-water mark wakeups. Advancing the notifier to idx wakes every waiter for idx and all lower indexes.
- Cheap notification broadcast. Notifications are delivered by closing a shared channel.
- Context-aware and select-friendly. The channel-based API can be used directly in
select statements or combined with a context.Context. No polling required.
- Convenience method.
err := sub.Wait(ctx) handles common wait-with-context pattern and automatically unsubscribes when appropriate.
- Concurrency-safe. All operations on Notifier and Sub are safe for concurrent use, with one exception: a Sub must not be copied before its first use (see docs on the type).
- Minimal allocations. Only the first subscription to a fresh index allocates (shared channel).
- Mutex-free inlined fast paths.
- Safe, idempotent unsubscription.
- Basic metrics: active subscribers, pending indexes, and container capacity.
- Manual container compaction. Allows reclaiming memory after heavy churn.
Typical uses:
- Waiting for a replicated log or WAL offset.
- Waiting for a Raft commit/apply index.
- Waiting for a sequence number or generation.
- Waiting for a cache or snapshot version.
- Coordinating producer/consumer pipelines.
- Signaling progress without polling.
Install:
go get -u github.com/mnzbono/indexsub
Common usage example:
notifier := indexsub.NewNotifier()
// ...
sub := notifier.Subscribe(10)
if err := sub.Wait(ctx); err != nil {
return err
}
workAfterIndexReached()
Further details:
Usage
notifier := indexsub.NewNotifier()
// use channel in your selects
sub := notifier.Subscribe(10)
defer sub.Unsubscribe()
select {
// ...
case <-sub.Ch:
weGotToOurIndex()
// ...
}
// Wait(ctx) is a convenience method to wait on caller's context. Selecting on sub.Ch and ctx.Done()
// Wait also manages lifecycle of sub, unsubscribing and preventing Wait'ing on an unsubscribed sub.
// Provides richer context than selecting on Ch: Wait immeditely verifies the index to detect a notifier cancellation.
if err := sub.Wait(ctx); err != nil {
// wait err can only be:
// - ErrCanceledByNotifier: notifier used CancelAll.
// - ErrUnsubscribed: the sub is already unsubscribed and Wait won't proceed as it is unsafe (channel could've been abandoned).
// - Context.Canceled/Context.Timeout: the callers context cancel signal.
// - nil
return err
}
// Unsubscribe() function is safe and won't interfere with other subscribers at the same index (as long as sub wasn't copied BEFORE use)
sub := notifier.Subscribe(10)
sub2 := notifier.Subscribe(10)
sub.Unsubscribe()
sub.Unsubscribe()
sub.Unsubscribe() // sub2 still subscribed
// Stats() provide basic metrics
notifier.Stats()
Stats{
ActiveSubscribers int64
ActiveIndexes int64
Capacity int
}
// CancelAll() releases all subscribers without satisfying their index.
// If you plan on using it, consider using Wait method over simple Ch select, as it performs index verification for you.
// You can also use CurrentIndex() to verify it wasn't a notifier cancel.
sub := notifier.Subscribe(idx)
defer sub.Unsubscribe()
<-sub.Ch
if idx > notifier.CurrentIndex() {
panic("we got flushed!")
}
// Wait does the check automatically and safer, as the notifier is likely still holding lock of it's internal ops.
if err := sub.Wait(ctx); err == indexsub.ErrCanceledByNotifier {
panic("we got flushed!")
}
// Sub can query the notifier's current index, even after fulfillment or cancelation.
sub := notifier.Subscribe(10)
sub.Unsubscribe()
notifier.Update(99)
sub.NotifierIndex() // 99
// Done is a simple method that tells if the notifier index is equal or higher than sub's without blocking or touching its lifecycle.
notifier.Update(9)
sub := notifier.Subscribe(10)
sub.Done() // false
notifier.Update(99)
sub.Done() // true
Update() is idempotent, monotonic and gives feedback on idx advancement
| call |
return |
| notifier.Update(1) |
true |
| notifier.Update(1) |
false |
| notifier.Update(10) |
true |
| notifier.Update(2) |
false |
| notifier.CurrentIndex() |
10 |
if !notifier.Update(idx) {
panic("monotonic 4life")
}
Complexity
Where n is the number of distinct pending indexes.
| Operation |
Time |
Allocs/op |
Notes |
| Subscribe to a past index |
O(1) |
0 |
Returns a shared already-closed subscription. |
| Subscribe to an existing pending index |
O(1) |
0 |
Reuses the existing entry. |
| Subscribe to a new highest index |
O(1) amortized |
1* |
Appends to the ordered queue. |
| Subscribe to a new out-of-order index |
O(n) |
1* |
Binary search + slice insertion (copy). |
| Update with no pending indexes |
O(1) |
0 |
Fast path. |
| Update advancing k pending indexes |
O(k) |
0 |
Removes entries, closes channels (go scheduler wakes up subscribers). |
* Only the first subscription allocates (channel). Following subscriptions to that index reuse the entry.
** Worst-case total insertion cost: inserting m distinct indexes in strictly descending order requires Θ(m²) time overall, because each insertion shifts all previously inserted elements.
Benchmark
$ go test -bench=. -count=20
goos: linux
goarch: amd64
pkg: github.com/mnzbono/indexsub
cpu: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
Lower ns/op is better. SubscribeWorst intentionally exercises the pathological insertion order.
| benchmark |
ns/op |
B/op |
allocs/op |
| UnsubscribeClosed-8 |
4.800n |
0 |
0 |
| SubscribeUnsubscribeChurn-8 |
102.8n |
112 |
1 |
| SubscribeExisting-8 |
21.79n |
0 |
0 |
| SubscribeClosed-8 |
1.919n |
0 |
0 |
| SubscribeNewNoGrow-8 |
112.0n |
111 |
0 |
| SubscribeNewGrowing-8 |
223.3n |
236 |
1 |
| SubscribeScatter/10-8 |
74.93n |
67 |
0 |
| SubscribeScatter/100-8 |
96.08n |
71 |
0 |
| SubscribeScatter/1000-8 |
127.7n |
70 |
0 |
| SubscribeScatter/10000-8 |
203.8n |
70 |
0 |
| SubscribeScatter/100000-8 |
1.224µ |
70 |
0 |
| SubscribeWorst/10-8 |
101.7n |
112 |
1 |
| SubscribeWorst/100-8 |
127.5n |
112 |
1 |
| SubscribeWorst/1000-8 |
160.8n |
112 |
1 |
| SubscribeWorst/10000-8 |
595.4n |
112 |
1 |
| SubscribeWorst/100000-8 |
5.637µ |
119 |
1 |
| Update/0-8 |
16.51n |
0 |
0 |
| Update/1/10-8 |
67.94n |
na |
na |
| Update/1/100-8 |
69.93n |
na |
na |
| Update/1/1000-8 |
74.54n |
na |
na |
| Update/1/10000-8 |
80.24n |
na |
na |
| Update/1/100000-8 |
108.3n |
na |
na |
| Update/10/10-8 |
431.0n |
na |
na |
| Update/10/100-8 |
446.2n |
na |
na |
| Update/10/1000-8 |
493.4n |
na |
na |
| Update/10/10000-8 |
540.4n |
na |
na |
| Update/10/100000-8 |
807.3n |
na |
na |
| geomean |
131.8n |
|
|
*SubscribeWorst inserts strictly descending indices, forcing every insertion to the front of the ordered queue. This is the implementation's pathological O(n²) case and is included to characterize worst-case behavior rather than expected usage.
Inlining
- Most paths inline:
- Update() to a lower index.
- Unsubscribe() CAS.
- Wait() on an unsubscribed Sub.
- Done(), Index(), IsUnsubscribed(), CurrentIndex(), NewIndexNotifier()
- Subscribe() could be made to inline by replacing atomic with atomic methods, but hardly worth it.
./indexsub.go:17:6: can inline init.0 with cost 6 as: func() { closedChan = make(chan struct {}); close(closedChan) }
./indexsub.go:47:6: can inline NewNotifier with cost 56 as: func(...Options) *Notifier { opts = ; switch statement; size := opts.InitialSize; n := &Notifier{...}; (*atomic.Uint64).Store(n.idx, opts.InitialIndex); return n }
./indexsub.go:148:6: can inline (*Notifier).CurrentIndex with cost 9 as: method(*Notifier) func() uint64 { return (*atomic.Uint64).Load(n.idx) }
./indexsub.go:281:6: can inline (*Notifier).newLateSub with cost 10 as: method(*Notifier) func(uint64) Sub { return Sub{...} }
./indexsub.go:276:6: can inline (*Notifier).newSubscription with cost 14 as: method(*Notifier) func(uint64, <-chan struct {}) Sub { n.subscribersTotal++; return Sub{...} }
./indexsub.go:258:6: can inline newEntry with cost 7 as: func() entry { return entry{...} }
./indexsub.go:82:6: cannot inline (*Notifier).subscribe: unhandled op DEFER
./indexsub.go:84:2: can inline (*Notifier).subscribe.deferwrap1 with cost 76 as: func() { (*sync.Mutex).Unlock(.autotmp_3) }
./indexsub.go:74:6: cannot inline (*Notifier).Subscribe: function too complex: cost 89 exceeds budget 80
./indexsub.go:120:6: cannot inline (*Notifier).update: function too complex: cost 318 exceeds budget 80
./indexsub.go:112:6: can inline (*Notifier).Update with cost 77 as: method(*Notifier) func(uint64) bool { if idx <= (*Notifier).CurrentIndex(n) { return false }; return (*Notifier).update(n, idx) }
./indexsub.go:150:6: cannot inline (*Notifier).unsubscribeOut: unhandled op DEFER
./indexsub.go:152:2: can inline (*Notifier).unsubscribeOut.deferwrap1 with cost 76 as: func() { (*sync.Mutex).Unlock(.autotmp_2) }
./indexsub.go:190:6: can inline (*Notifier).cancelAll with cost 31 as: method(*Notifier) func() { for loop; n.q = n.q[:0]; n.indexesTotal = int64(0); n.subscribersTotal = int64(0) }
./indexsub.go:207:6: can inline (*Notifier).compact with cost 58 as: method(*Notifier) func() { target := max(int(n.initSize), len(n.q), len(n.active)); if cap(n.q) <= target { return }; newMap := make(map[uint64]entry, target); for loop; n.active = newMap; newSlice := make([]uint64, 0, target); n.q = append(newSlice, n.q...) }
./indexsub.go:174:6: cannot inline (*Notifier).Flush: function too complex: cost 239 exceeds budget 80
./indexsub.go:184:6: cannot inline (*Notifier).CancelAll: function too complex: cost 179 exceeds budget 80
./indexsub.go:201:6: cannot inline (*Notifier).Compact: function too complex: cost 206 exceeds budget 80
./indexsub.go:227:6: cannot inline (*Notifier).Stats: function too complex: cost 163 exceeds budget 80
./indexsub.go:312:6: can inline (*Sub).Unsubscribe with cost 79 as: method(*Sub) func() { if atomic.SwapUint64(&s.unsubToken, uint64(0)) > (*atomic.Uint64).Load(s.notifier.idx) { (*Notifier).unsubscribeOut(s.notifier, s.idx) } }
./indexsub.go:330:6: can inline (*Sub).NotifierIndex with cost 13 as: method(*Sub) func() uint64 { return (*Notifier).CurrentIndex(s.notifier) }
./indexsub.go:297:6: cannot inline (*Sub).wait: unhandled op DEFER
./indexsub.go:298:2: can inline (*Sub).wait.deferwrap1 with cost 81 as: func() { (*Sub).Unsubscribe(.autotmp_3) }
./indexsub.go:289:6: can inline (*Sub).Wait with cost 71 as: method(*Sub) func(context.Context) error { if atomic.LoadUint64(&s.unsubToken) == uint64(0) { return ErrUnsubscribed }; return (*Sub).wait(s, ctx) }
./indexsub.go:321:6: can inline (*Sub).IsUnsubscribed with cost 8 as: method(*Sub) func() bool { return atomic.LoadUint64(&s.unsubToken) == uint64(0) }
./indexsub.go:324:6: can inline (*Sub).Done with cost 19 as: method(*Sub) func() bool { return s.idx <= (*Sub).NotifierIndex(s) }
./indexsub.go:327:6: can inline (*Sub).Index with cost 3 as: method(*Sub) func() uint64 { return s.idx }
Did you try a heap?
Yes.
The monotonic nature of this primitive is very well suited for a sorted slice. But the heap was tried nevertheless, and the code left in the heap branch for future reference.
The conclusion is, the heap is only better for a very specific use case: high volume of subscribers spraying the index but unsubscring before update reached them, something like time sensitive callbacks.
Even though the heap can handle better adversarial subscriptions at any volume, the dominant operation for this primitive is update, and heap performs clearly worse with big queues during update.
On top of that, if it was offered as opt-in it would impose type plumbing tax (interface was discarded due to worse results and boxed allocs).
|
(inlined slice) base sec/op |
sec/op vs base |
(heap inlined) Δ |
sec/op vs base |
(slice typed) Δ |
sec/op vs base |
(heap typed) Δ |
| UnsubscribeClosed-8 |
4.800n ± 35% |
4.799n ± 35% |
~ (p=0.825 n=20) |
5.002n ± 37% |
+4.21% (p=0.005 n=20) |
5.043n ± 30% |
+5.06% (p=0.015 n=20) |
| SubscribeUnsubscribeChurn-8 |
102.80n ± 0% |
87.78n ± 2% |
-14.61% (p=0.000 n=20) |
112.80n ± 1% |
+9.73% (p=0.000 n=20) |
88.42n ± 2% |
-13.98% (p=0.000 n=20) |
| SubscribeExisting-8 |
21.79n ± 3% |
22.01n ± 3% |
~ (p=0.920 n=20) |
23.18n ± 10% |
+6.38% (p=0.000 n=20) |
21.80n ± 2% |
~ (p=0.542 n=20) |
| SubscribeClosed-8 |
1.919n ± 2% |
1.937n ± 3% |
~ (p=0.482 n=20) |
2.006n ± 3% |
+4.53% (p=0.000 n=20) |
1.954n ± 4% |
~ (p=0.888 n=20) |
| SubscribeNewNoGrow-8 |
112.0n ± 1% |
113.4n ± 0% |
+1.25% (p=0.000 n=20) |
113.6n ± 1% |
+1.43% (p=0.000 n=20) |
109.8n ± 1% |
-2.01% (p=0.000 n=20) |
| SubscribeNewGrowing-8 |
223.3n ± 3% |
219.0n ± 3% |
~ (p=0.482 n=20) |
242.5n ± 3% |
+8.57% (p=0.000 n=20) |
223.0n ± 2% |
~ (p=0.794 n=20) |
| SubscribeScatter/10-8 |
74.93n ± 1% |
72.74n ± 1% |
-2.92% (p=0.000 n=20) |
97.65n ± 1% |
+30.31% (p=0.000 n=20) |
89.01n ± 1% |
+18.79% (p=0.000 n=20) |
| SubscribeScatter/100-8 |
96.08n ± 0% |
91.22n ± 1% |
-5.05% (p=0.000 n=20) |
97.12n ± 1% |
+1.09% (p=0.000 n=20) |
90.78n ± 0% |
-5.51% (p=0.000 n=20) |
| SubscribeScatter/1000-8 |
127.7n ± 1% |
102.2n ± 1% |
-19.93% (p=0.000 n=20) |
125.8n ± 1% |
-1.53% (p=0.000 n=20) |
100.5n ± 1% |
-21.30% (p=0.000 n=20) |
| SubscribeScatter/10000-8 |
203.8n ± 1% |
105.8n ± 1% |
-48.09% (p=0.000 n=20) |
209.7n ± 1% |
+2.87% (p=0.000 n=20) |
106.5n ± 1% |
-47.77% (p=0.000 n=20) |
| SubscribeScatter/100000-8 |
1224.0n ± 2% |
116.8n ± 1% |
-90.45% (p=0.000 n=20) |
1334.0n ± 1% |
+8.99% (p=0.000 n=20) |
126.7n ± 1% |
-89.65% (p=0.000 n=20) |
| SubscribeWorst/10-8 |
101.75n ± 1% |
99.04n ± 1% |
-2.66% (p=0.000 n=20) |
129.50n ± 1% |
+27.27% (p=0.000 n=20) |
121.05n ± 1% |
+18.97% (p=0.000 n=20) |
| SubscribeWorst/100-8 |
127.5n ± 1% |
118.4n ± 1% |
-7.17% (p=0.000 n=20) |
129.8n ± 1% |
+1.80% (p=0.000 n=20) |
114.0n ± 1% |
-10.58% (p=0.000 n=20) |
| SubscribeWorst/1000-8 |
160.8n ± 1% |
123.8n ± 0% |
-23.02% (p=0.000 n=20) |
162.8n ± 2% |
~ (p=0.085 n=20) |
119.9n ± 1% |
-25.41% (p=0.000 n=20) |
| SubscribeWorst/10000-8 |
595.4n ± 1% |
134.1n ± 0% |
-77.48% (p=0.000 n=20) |
614.7n ± 1% |
+3.25% (p=0.000 n=20) |
133.6n ± 2% |
-77.55% (p=0.000 n=20) |
| SubscribeWorst/100000-8 |
5637.0n ± 1% |
158.5n ± 1% |
-97.19% (p=0.000 n=20) |
5774.0n ± 2% |
+2.43% (p=0.000 n=20) |
157.7n ± 2% |
-97.20% (p=0.000 n=20) |
| Update/0-8 |
16.51n ± 0% |
15.82n ± 0% |
-4.18% (p=0.000 n=20) |
17.45n ± 0% |
+5.69% (p=0.000 n=20) |
17.59n ± 3% |
+6.54% (p=0.000 n=20) |
| Update/1/10-8 |
67.94n ± 1% |
78.33n ± 1% |
+15.28% (p=0.000 n=20) |
73.27n ± 1% |
+7.84% (p=0.000 n=20) |
87.70n ± 1% |
+29.08% (p=0.000 n=20) |
| Update/1/100-8 |
69.93n ± 1% |
96.83n ± 1% |
+38.46% (p=0.000 n=20) |
76.19n ± 1% |
+8.94% (p=0.000 n=20) |
107.60n ± 1% |
+53.86% (p=0.000 n=20) |
| Update/1/1000-8 |
74.54n ± 1% |
113.05n ± 2% |
+51.66% (p=0.000 n=20) |
80.49n ± 1% |
+7.98% (p=0.000 n=20) |
122.55n ± 1% |
+64.41% (p=0.000 n=20) |
| Update/1/10000-8 |
80.24n ± 1% |
128.80n ± 1% |
+60.52% (p=0.000 n=20) |
86.72n ± 1% |
+8.08% (p=0.000 n=20) |
138.80n ± 2% |
+72.98% (p=0.000 n=20) |
| Update/1/100000-8 |
108.3n ± 1% |
162.6n ± 1% |
+50.14% (p=0.000 n=20) |
128.4n ± 3% |
+18.56% (p=0.000 n=20) |
175.4n ± 1% |
+62.00% (p=0.000 n=20) |
| Update/10/10-8 |
431.0n ± 0% |
444.2n ± 1% |
+3.05% (p=0.000 n=20) |
462.4n ± 1% |
+7.28% (p=0.000 n=20) |
480.1n ± 1% |
+11.38% (p=0.000 n=20) |
| Update/10/100-8 |
446.2n ± 1% |
729.2n ± 1% |
+63.42% (p=0.000 n=20) |
488.2n ± 1% |
+9.40% (p=0.000 n=20) |
754.0n ± 1% |
+68.97% (p=0.000 n=20) |
| Update/10/1000-8 |
493.4n ± 1% |
907.7n ± 0% |
+83.97% (p=0.000 n=20) |
527.3n ± 1% |
+6.86% (p=0.000 n=20) |
935.5n ± 1% |
+89.60% (p=0.000 n=20) |
| Update/10/10000-8 |
540.4n ± 1% |
1128.0n ± 1% |
+108.75% (p=0.000 n=20) |
585.5n ± 1% |
+8.36% (p=0.000 n=20) |
1169.5n ± 1% |
+116.43% (p=0.000 n=20) |
| Update/10/100000-8 |
807.3n ± 1% |
1424.5n ± 1% |
+76.44% (p=0.000 n=20) |
949.0n ± 1% |
+17.55% (p=0.000 n=20) |
1530.0n ± 1% |
+89.51% (p=0.000 n=20) |
AI disclaimer: Design, implementation, tests, benchmarks, examples, comments and docs are hand-written. AI was used for code review feedback and to polish English phrasing in comments and docs (I'm not a native speaker).