Skip to content

perf + fix: zero-alloc fan-out, send-on-closed race, logger cleanup#4

Merged
F2077 merged 7 commits into
mainfrom
perf/optimization-audit
Jun 21, 2026
Merged

perf + fix: zero-alloc fan-out, send-on-closed race, logger cleanup#4
F2077 merged 7 commits into
mainfrom
perf/optimization-audit

Conversation

@F2077

@F2077 F2077 commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Three logically separated commits hardening and speeding up the fan-out hot path. Split so the bug fix can be cherry-picked / reverted independently of the perf work.

1. fix(deliver): send-on-closed-channel race (815c24a) — pre-existing bug

deliver's fan-out read each subscriber's per-topic channel via channels.Load then select-sent. A concurrent unsubscribe could close(ch) in that window, panicking the publisher goroutine and crashing the whole process. The new safety-net test TestConcurrentPublishWithDynamicSubscribe reproduces it deterministically under -race.

Fix: Subscriber.channels moves from sync.Map to a subscriber.mutex-guarded map[string]chan T, and deliver holds subscriber.mutex while reading the channel + sending. This serializes send against unsubscribe's close (an absent channels[topic] means already unsubscribed → skip). resetTimer runs outside the lock to avoid re-entrant deadlock. Lock order is unchanged (no new deadlock surface — deliver releases subscription.rwMutex before taking subscriber.mutex).

2. perf(deliver): zero-allocation snapshot via sync.Pool (44d9403)

deliver allocated a fresh snapshot slice on every Publish (~80KB at 10k subscribers, GC-thrashing under concurrent publishers). Pooled per-subscription with *[]*Subscriber[T] elements — a pointer (not the slice) so only 8 bytes box into the Get/Put interface{}; the backing array grows naturally to steady-state subscriber count.

3. chore: fold duplicated logger.Enabled calls (3feb554)

Topics, tryRemoveSubscription, isEmpty, addSubscriber, removeSubscriber, Subscribe, unsubscribe each called logger.Enabled(LevelDebug) twice (acquire + release). Folded into one debug := local, matching the pattern already in createOrLoadSubscription/deliver. Pure consistency; no behavior change.

Benchmarks (count=5 median, Intel Core Ultra 5 125H)

Scenario Before After Δ
UltraLarge (10k subs) 2.67ms, 1 alloc 2.03ms, 0 alloc −24%
HighLoadParallel (10k, 180 pub) 113µs, 1 alloc 84µs, 0 alloc −26%
MultiPub × 50 subs 18.7µs, 17 alloc 16.1µs, 12 alloc −14%
MultipleSubscribers (100) 6.4µs, 1 alloc 6.7µs, 0 alloc +5% (mutex)
PublishSingleSubscriber 104ns 109ns +5ns (mutex)

Multi-subscriber fan-out — the core fire-and-forget use case — wins big; single-subscriber pays ~5ns for the mutex. mutex (≈15ns) beats sync.Map.Load (≈30ns) + type assertion once N grows.

Test plan

  • go test -race -count=3 ./pubsub/... green — each commit independently
  • TestConcurrentPublishWithDynamicSubscribe (4 publishers + 8 dynamic subscribe/close goroutines) race-clean — the regression test for the race fix
  • TestDeliverZeroAlloc (//go:build !race — the race detector skews alloc counts) guards the 0-alloc invariant
  • go vet clean

🤖 Generated with Claude Code

F2077 added 7 commits June 21, 2026 17:01
deliver's fan-out read each subscriber's per-topic channel via
channels.Load then select-sent; a concurrent unsubscribe could
close(ch) in that window, panicking the publisher goroutine and
crashing the whole process.

Switch Subscriber.channels from sync.Map to a mutex-guarded
map[string]chan T, and have deliver hold subscriber.mutex while
reading the channel and sending. This serializes send against
unsubscribe's close: channels[topic] absent means already
unsubscribed so deliver skips; otherwise the send completes
before close can run. resetTimer runs outside the lock to avoid
re-entrant deadlock with its own subscriber.mutex take.

Lock order is unchanged: deliver releases subscription.rwMutex
before taking subscriber.mutex, so no subscription->subscriber
ordering is introduced.

Verified with go test -race -count=3 ./pubsub/... plus the new
TestConcurrentPublishWithDynamicSubscribe stress test (4
publishers + 8 dynamic subscribe/close goroutines).

Co-Authored-By: glm-5.2
deliver snapshotted the subscriber set on every Publish with a
fresh make([]*Subscriber[T], 0, len(subscribers)) — at N=10000
subscribers that is ~80KB allocated per Publish, hammering the
GC under concurrent publishers.

Pool the snapshot slice per subscription. Pool element is
*[]*Subscriber[T] (pointer, not slice) so only an 8-byte pointer
boxes into the Get/Put interface{} — storing the slice directly
would escape the whole slice header. After fan-out, Reset to [:0]
and Put; the backing array's capacity naturally grows to the
subscription's steady-state subscriber count. sync.Pool is
concurrency-safe and per-P cached, so concurrent delivers get
independent slice instances.

Measured (count=5 median): UltraLarge 10k subs 2.67ms->2.03ms
(-24%), HighLoadParallel 113us->84us (-26%), both 1 alloc/op ->
0. Single-subscriber +5ns (mutex) is acceptable for a
fire-and-forget fan-out library.

TestDeliverZeroAlloc (//go:build !race; the race detector skews
alloc counts) guards the invariant.

Co-Authored-By: glm-5.2
Topics, tryRemoveSubscription, isEmpty, addSubscriber,
removeSubscriber (broker.go) and Subscribe, unsubscribe
(subscriber.go) each called logger.Enabled(LevelDebug) twice —
once on acquire, once inside the release defer. Fold both into a
single `debug :=` local, matching the pattern already used in
createOrLoadSubscription and deliver.

Pure consistency cleanup; no behavior change (the Enabled gate
is inlined to a fast false when debug is disabled).
TestDebugLevelLoggerExercisesHotPath still exercises the gated
bodies.

Co-Authored-By: glm-5.2
README: update the benchmark table to median-of-3 numbers post
PR #4 (fan-out: UltraLarge -24%, HighLoadParallel -26%, all hot
paths zero-allocation). Add a note that the non-zero B/op on the
big fan-out benchmarks comes from the harness's drain goroutines,
not library code.

CLAUDE.md:
- Architecture: Subscriber.channels is now a subscriber.mutex-
  guarded map[string]chan T (was sync.Map); the per-subscription
  errCh lives on each *Subscription. deliver snapshots the
  subscriber set via a sync.Pool-recycled slice and sends under
  subscriber.mutex.
- Locking pattern: document the new deliver<->subscriber.mutex
  contract (send vs unsubscribe close are mutually exclusive) and
  why resetTimer runs outside the lock.
- Gotchas: add the send-on-closed-channel race history and its
  regression test TestConcurrentPublishWithDynamicSubscribe.
- Code layout: list concurrent_test.go and perf_test.go.

Co-Authored-By: glm-5.2
CI runs a gofmt gate (gofmt -l must be empty). The channels map
field comment and the NewSubscriber struct-literal keys were not
aligned to the column gofmt expects after channels: was added.
Verified: gofmt -l pubsub/ cmd/ is now empty.

Co-Authored-By: glm-5.2
CLAUDE.md claimed "There is no Makefile, linter config, or CI file"
and that go test/vet was the whole story — all three were wrong:
Makefile, .golangci.yml, and .github/workflows/test.yml all exist.
This inaccuracy is what led to committing un-gofmt'd code and
turning CI red on this very PR. Replace with an accurate list of
the gates CI enforces (go mod tidy, gofmt, vet, race test,
golangci-lint v2.12.2 with only-new-issues), and note that
infertypeargs is excluded in .golangci.yml so it won't fail CI.

Co-Authored-By: glm-5.2
Address code-review feedback:

- broker.go: nil out the *Subscriber pointers in the pooled snapshot
  slice before Put. sync.Pool is cleared by the runtime on GC, and
  the next Get's [:0] only resets length (not elements), so without
  this the Pool's backing array keeps every snapshotted Subscriber
  reachable until the next GC — delaying reclamation of unsubscribed
  subscribers and inflating memory peaks for large fan-out.
- perf_test.go: debug.SetGCPercent(-1) around the measurement so a GC
  clearing the Pool mid-run can't make the zero-alloc assertion flaky.
- concurrent_test.go: count Subscribe errors instead of silently
  returning; a systemic Subscribe failure would otherwise let the
  stress test pass vacuously.
- subscriber.go: check channels[topic] before make-ing the chan so
  re-Subscribe doesn't allocate a channel it immediately discards.

Verified: go test -race -count=3 green; TestDeliverZeroAlloc green.

Co-Authored-By: glm-5.2
@F2077
F2077 merged commit 4c0a7b7 into main Jun 21, 2026
1 check passed
@F2077
F2077 deleted the perf/optimization-audit branch June 21, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant