Repository navigation
Conversation
- Reuse dispatch and worker goroutines while preserving callback independence - Shrink ring buffers at quarter capacity to prevent resize oscillation - Add lifecycle regressions and reproducible performance comparisons
This was referenced Oct 31, 2026
Member
Author
|
Superseded by two independently reviewable PRs: #179 (Ring resize hysteresis) and #180 (Queue dispatcher/worker reuse). Both target master directly, contain disjoint code/test files, and can be merged in either order. Each description includes fresh standalone benchmark comparisons and raw samples; plan/report artifacts remain local. The original branch is retained for historical reference. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A backlog alternating between 1,024 and 1,025 tasks previously resized the Ring on every enqueue/dequeue pair. Shrink only at 25% utilization to avoid that oscillation. Reuse a single dispatcher and worker goroutines instead of spawning Request and work goroutines for every task.
Keep handler timeout/retry/panic logic intact, make Start idempotent, and run optional afterFn callbacks independently while retaining Release tracking. Public interfaces are unchanged.
Performance results
Measured on 2026-10-31, comparing baseline
9721be66093a0e2889129e4751154d8388698b98, the Ring-only change, and the combined implementation ina67a8e5(the current head only removes local report artifacts; Go source is unchanged).All summary numbers below are medians of six samples. For Queue benchmarks, ns/task is amortized end-to-end work cost, not an individual task's queuing delay or p99. Time reduction =
1 - after/before; derived throughput gain =before/after - 1.End-to-end results
General tasks use 17 → 11 allocations/task (35.3% fewer), and approximately 1,000–1,002 → 854 B/task (14.6–14.7% lower). With afterFn, the results are 17 → 13 allocations/task (23.5% fewer) and 1,001 → 894 B/task (10.7% lower).
The 4/12-worker timing ranges overlap; median differences must not be treated as proof of statistical significance. Allocation-count reductions were consistent across all recorded samples. Empty-task results do not imply the same gain for real CPU-heavy or I/O-heavy handlers.
Ring capacity-boundary results
One operation is a successful enqueue/dequeue pair while holding the backlog near the stated capacity boundary. Previously, a backlog alternating between 1,024 and 1,025 caused capacity to alternate between 1,024 and 2,048, allocating and copying twice while holding the Ring mutex. Growing at full and shrinking only at 25% utilization eliminates this steady-state oscillation; minimum capacity remains 2.
This targeted microbenchmark is not an overall Queue speedup multiplier. The first growth still allocates; amortization over many iterations rounds the measured steady-state B/op and allocs/op to zero. Later shrinking can retain a larger backing array in exchange for fewer allocations and copies.
Separate contributions and full sample ranges
Ring-only retains the original queue.go and applies only the new ring.go. Each row includes the complete median tuple and minimum/maximum time from the local measurement summary. For Ring, an operation is one enqueue/dequeue pair; for Queue, it is one completed task.
The Ring fix primarily benefits the capacity boundary. The batched end-to-end workload does not exhibit the same oscillation: Ring-only still uses 17 allocations/task, while adding the scheduler change brings general tasks down to 11. Small Ring-only timing differences are not claimed as gains.
Goroutine creation per task
These counts come from the code paths, not runtime-profile sampling, and exclude persistent goroutines created at initialization or worker growth. The handler goroutine remains necessary to preserve timeout behavior when a handler ignores cancellation. Callbacks remain independent of worker slots and tracked by Release. Idle queues now retain workers until retirement or release; workers block while waiting for tasks.
Environment, methodology, and limitations
GOMAXPROCS=12via-cpu=12.Raw benchmark data
All 108 original samples, execution timestamps, environment headers, and benchmark/implementation SHA256 hashes are embedded below. The report and raw-data files remain local and are not part of the PR file changes.
baseline: all six samples per case and source hashes
ring-only: all six samples per case and source hashes
optimized: all six samples per case and source hashes
Related issues
N/A — no GitHub or Jira issue was supplied.
Architecture / flow
flowchart TD Ring[ring.go: Ring.Request with quarter-full shrink] --> Dispatcher[queue.go: single start dispatcher] Dispatcher --> Workers[queue.go: reusable consume workers] Workers --> Handler[Existing handle timeout and retry logic] Handler --> Work[queue.go: work completion and metrics] Work --> Wake[queue.go: schedule notification] Wake --> Dispatcher Work --> Callback[queue.go: independent tracked afterFn] Resize[queue.go: UpdateWorkerCount] --> Wake style Ring fill:#dff0d8,stroke:#3c763d style Dispatcher fill:#dff0d8,stroke:#3c763d style Workers fill:#dff0d8,stroke:#3c763d style Work fill:#dff0d8,stroke:#3c763d style Wake fill:#dff0d8,stroke:#3c763d style Callback fill:#dff0d8,stroke:#3c763dAI authorship
queue.go,ring.go,options.go,performance_test.go,scheduling_test.go.Change classification and scope
Verification
Setup
Check out
perf/queue-dispatchatdc8131533355d3440557aad8a4c8ec26c1486ec1and run commands from the repository root. Prerequisites: Go 1.26.10, Git, and a working race-detector toolchain. Dependencies use go.mod/go.tools.mod; no external broker, accounts, secrets, services, or test data are required.go mod downloadprepares dependencies; the lint tool is pinned in go.tools.mod. RTK is optional for reviewers: commands below invoke Go directly.Automated checks
go tool -modfile=go.tools.mod golangci-lint fmtgo test -race -covermode=atomic -coverprofile=/tmp/queue-pr-coverage.out ./...go tool -modfile=go.tools.mod golangci-lint rungit diff --checkandgit diff --staged --checkbefore commitBehavioral scenarios
Starting state: each test creates its own in-memory Queue/backend; no persistent state. The following named tests all Passed in the full race suite. Run an individual scenario with
go test -race -run '^TEST_NAME$' -timeout=30s ., substituting the exact name in the first column.TestRingShrinkHysteresisAndFIFO: fill 16, drain to 8 then 4, refill and drainTestQueueResizePauseAndDrain: enqueue 16 gated jobs; resize 2 → 4 → 1 → 0 → 2TestReusableWorkerOutcomes: error, panic, retry success, noncooperative timed-out handler, then successTestAfterFnCanWaitForNextTask: one worker; callback enqueues and waits for a second taskTestReleaseWaitsForAfterFn: hold callback behind a channel, invoke Release, then unblockTestBlockingBackendAndRepeatedStart: call Start twice, process 64 jobs, shut down blocking backendCompletion is signaled by test channels; progress waits are bounded at 5 seconds, and the callback dependency test has a 1-second bound. The noncooperative handler is released during test cleanup. Existing shutdown, max-capacity, retry, and panic tests also passed.
Performance reproduction
Passed: run the command below in original, Ring-only, and final source directories using identical performance_test.go. Use baseline commit
9721be66093a0e2889129e4751154d8388698b98with the currentperformance_test.gocopied in. For Ring-only, also copy the currentring.go. Execute three rounds with two samples per round in this order: baseline → Ring-only → optimized; optimized → baseline → Ring-only; Ring-only → optimized → baseline:Expected: completed-task counts equal submissions; Ring boundary steady-state allocations disappear; general task allocations drop. Exact timing is machine-dependent. Observed medians, ranges, and all raw samples are in the Performance results section above. This benchmark uses one producer, bounded batches, and includes final Release; it does not measure real broker traffic or tail latency.
Not run: production/multi-producer soak or external-broker load tests; this scope has no production workload or external service fixture.
Cleanup: tests release their queues and require no service cleanup. Remove
/tmp/queue-pr-coverage.outif desired. Benchmarks do not persist application data. Formatting can update local files; inspectgit diffafterward.Security check
Risk and rollback
a67a8e5; there is no data/schema migration.Reviewer guide
Read
queue.goStart/work/consume/start andring.goRequest line by line, then the lifecycle tests. Inspect the benchmark methodology inperformance_test.go. This PR includes only the three existing Go files and two test files; the plan, report files, and raw-data files are retained locally; complete measurement data is embedded in this description.