From a67a8e5eb0b3620ad5652d2436bd274d6b203b6f Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sat, 31 Oct 2026 10:49:38 +0800 Subject: [PATCH 1/2] perf(queue): reduce dispatch and resize overhead - 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 --- docs/performance.md | 145 ++++++++++++++++ docs/performance/baseline.txt | 67 ++++++++ docs/performance/optimized.txt | 67 ++++++++ docs/performance/ring-only.txt | 67 ++++++++ docs/performance/summary.json | 206 +++++++++++++++++++++++ options.go | 3 +- performance_test.go | 84 ++++++++++ plan.md | 64 +++++++ queue.go | 143 +++++++++------- ring.go | 8 +- scheduling_test.go | 294 +++++++++++++++++++++++++++++++++ 11 files changed, 1080 insertions(+), 68 deletions(-) create mode 100644 docs/performance.md create mode 100644 docs/performance/baseline.txt create mode 100644 docs/performance/optimized.txt create mode 100644 docs/performance/ring-only.txt create mode 100644 docs/performance/summary.json create mode 100644 performance_test.go create mode 100644 plan.md create mode 100644 scheduling_test.go diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..37d9ce4 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,145 @@ +# Queue 效能改善報告 + +日期:2026-10-31。基準 commit:`9721be66093a0e2889129e4751154d8388698b98`。 +比較對象是該 commit 與本次工作目錄的修改;沒有更動 public API 或新增依賴。 + +## 結果 + +兩項修改都已完成。容量邊界的 Ring 擴縮容抖動已消除;一般短任務的端到端 +每筆時間中位數降低約 **10–53%**,配置次數由 **17 降至 11 次/任務**。 +這是單機空任務 benchmark 的結果,不代表實際 I/O 或 CPU 密集工作也會有相同增幅。 + +下表皆取 **6 次樣本的中位數**。Queue 的 ns/op 是整批任務的平均處理成本, +不是個別任務的排隊延遲或 p99。吞吐量增幅由修改前時間除以修改後時間計算。 + +| 端到端情境 | 原版 ns/task | 修改後 ns/task | 每筆時間降低 | 換算吞吐量增幅 | allocs/task | +| --- | ---: | ---: | ---: | ---: | ---: | +| 1 worker | 2,537.5 | 1,196.0 | 52.9% | 112.2% | 17 → 11 | +| 4 workers | 1,610.5 | 1,438.5 | 10.7% | 12.0% | 17 → 11 | +| 12 workers | 2,065.0 | 1,859.0 | 10.0% | 11.1% | 17 → 11 | +| 4 workers,含 afterFn | 1,654.0 | 1,358.5 | 17.9% | 21.8% | 17 → 13 | + +一般任務的配置量約從 **1,000–1,002 B/task 降為 854 B/task**,下降約 14.6–14.7%; +有 afterFn 時從 **1,001 B/task 降為 894 B/task**,下降約 10.7%。 + +4/12 workers 的時間樣本範圍有重疊,不能把中位數差異當成已證明的統計顯著改善。 +配置次數的減少則在本次所有樣本中一致。 + +## 1. Ring 縮容門檻 + +[ring.go](../ring.go) 原本在使用量降至容量的 50% 時就把容量減半。 +當 backlog 在 1,024 與 1,025 之間反覆變動,每次 enqueue/dequeue 都會 +在 1,024 與 2,048 容量之間切換,重新配置並複製整個 backlog,期間持有 Ring mutex。 + +修改後:滿載仍擴大一倍,但使用量降至 **25% 或以下**才縮成一半。 +最大待處理數限制、FIFO 與最小 buffer 大小 2 均保留。 + +| 穩定 backlog;每次加入+取出 | 原版 ns/op | 修改後 ns/op | 原版 B/op | 修改後 B/op | allocs/op | +| --- | ---: | ---: | ---: | ---: | ---: | +| 64 筆 | 351.25 | 9.36 | 3,456 | 0 | 2 → 0 | +| 1,024 筆 | 3,453.50 | 9.14 | 51,200 | 0 | 2 → 0 | + +這是刻意涵蓋容量邊界的 microbenchmark,不能解讀成整個 Queue 快數百倍。 +穩態不再配置;首次擴容仍會配置,攤提到大量迭代後 benchmark 顯示 0 B/op、0 allocs/op。 +代價是縮容更晚,部分負載下會保留較大的 backing array,以換取較少的複製與配置。 + +## 2. 重用 dispatcher 與 worker + +[queue.go](../queue.go) 改為單一常駐 dispatcher 直接呼叫 `Request()`, +再將任務交給可重用的 worker goroutine。backend 的 Request 仍然序列執行。 +動態增加 worker 會建立新的 worker;減少時,退役的 worker 會先完成目前任務。 +`Start()` 改為冪等,避免重複呼叫造成多個 dispatcher。 + +| 每筆任務新增的 goroutine | 原版 | 修改後 | +| --- | ---: | ---: | +| 沒有 afterFn | 3:Request、work、handler | 1:handler | +| 有 afterFn | 3:callback 在 work 中執行 | 2:handler、callback | + +上述數字是程式碼執行路徑的計數,不是 runtime profile 的取樣結果; +不包含初始化與擴增 worker 時建立的常駐 goroutine。 + +`handle()` 的 timeout、retry 與 panic 處理維持原實作。 +保留獨立 handler goroutine,才能維持 handler 不理會 context 時,timeout 仍釋放 +Queue 執行名額的行為;這次沒有改成同步執行 handler。 + +原本 afterFn 不占用執行名額,可能等待後續任務完成。因此修改後以受 routineGroup +追蹤的獨立 goroutine 執行 callback,保留此行為,且 `Release()` 仍會等待 callback。 +[options.go](../options.go) 的 callback 說明也已修正為實際行為。 + +常駐 worker 的取捨:Queue 閒置時也會保留 worker goroutine,直到縮減或釋放; +它們阻塞等待任務,不忙迴圈輪詢。阻塞式 backend 的 Request 仍需由 backend 自行 +在 Shutdown 時解除阻塞,與既有介面契約一致。 + +## 分離兩項修改的貢獻 + +額外測量「只改 Ring、保持原始 queue.go」版本。下表單位均為 ns/op 中位數。 + +| 情境 | 原版 | 僅修 Ring | 兩項都修正 | +| --- | ---: | ---: | ---: | +| Ring backlog 64 | 351.25 | 9.32 | 9.36 | +| Ring backlog 1,024 | 3,453.50 | 9.15 | 9.14 | +| Queue,1 worker | 2,537.5 | 2,537.5 | 1,196.0 | +| Queue,4 workers | 1,610.5 | 1,669.0 | 1,438.5 | +| Queue,12 workers | 2,065.0 | 2,067.0 | 1,859.0 | +| Queue,4 workers+afterFn | 1,654.0 | 1,636.0 | 1,358.5 | + +Ring 修正的主要收益出現在容量邊界。本次批次端到端情境沒有重現同樣程度的 +擴縮容抖動;一般任務仍為 17 allocs/task,必須加上排程修改才降至 11。 +因此不能把 Ring 邊界測試的倍數套用到一般吞吐量,也不把這裡 Ring-only 的小幅波動視為改善。 + +## 測量方式與限制 + +- 環境:Apple M4 Pro、darwin/arm64、Go 1.26.10、`GOMAXPROCS=12`(`-cpu=12`)。 +- 三個版本使用完全相同的 [performance_test.go](../performance_test.go)。 +- 每個情境 500ms,3 輪、每輪 2 個樣本,共 6 個樣本。 +- 輪序:原版 → Ring-only → 修改後;修改後 → 原版 → Ring-only;Ring-only → 修改後 → 原版。 +- 版本依序執行,沒有同時執行其他測試或 lint;未使用 race instrumentation 做效能測量。 +- Queue 為單 producer、空任務,每批最多 1,024 筆。每批等到 handler 完成訊號, + callback 情境則等到 callback 訊號;最後 `Release()` 納入計時,確認全部任務成功。 +- 每次入列都檢查錯誤,不把容量滿載後的拒絕算成成功吞吐量。 +- Queue 建構呼叫位於 ResetTimer 之前,非同步啟動與最後釋放的成本仍可能攤提在測量內。 +- 未測真實業務任務、多 producer、外部 broker、長時間穩態、RSS 或 p95/p99。 + CPU 排程、主機背景負載及 GC 都會造成波動;本報告未宣稱統計顯著性。 + +原版與修改後的 6 次時間樣本範圍: + +| Queue 情境 | 原版 ns/task 範圍 | 修改後 ns/task 範圍 | +| --- | ---: | ---: | +| 1 worker | 2,511–2,557 | 1,183–1,256 | +| 4 workers | 1,544–1,658 | 1,299–1,607 | +| 12 workers | 1,894–2,118 | 1,846–1,952 | +| 4 workers+afterFn | 1,536–1,688 | 1,341–1,421 | + +## 重現與原始資料 + +將基準 commit 匯出到兩個暫存目錄,兩者都複製本次 `performance_test.go`; +其中一個再覆蓋本次 `ring.go` 作為 Ring-only。修改後版本使用目前工作目錄。 +每輪在各自目錄執行下列命令,依上述輪序重複三輪: + +```sh +rtk proxy go test -run '^$' \ + -bench '^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$' \ + -benchmem -benchtime=500ms -count=2 -cpu=12 . +``` + +原始資料包含每個樣本、執行時間與程式碼 SHA256,可核對測試與版本: + +- [原版](performance/baseline.txt) +- [僅修 Ring](performance/ring-only.txt) +- [兩項都修正](performance/optimized.txt) +- [中位數與範圍 JSON](performance/summary.json) + +## 正確性驗證 + +- 修改前 `go test -race ./...` 通過。 +- 修改後全套 `go test -race -covermode=atomic -coverprofile=/tmp/queue-performance-coverage.out ./...` 通過。 + Queue package statement coverage 為 **93.2%**;既有 goleak TestMain 同時檢查 goroutine 洩漏。 +- `go tool -modfile=go.tools.mod golangci-lint run`:**0 issues**。 +- 在 `-cpu=1,4,12` 各重複 30 次、開啟 race detector 的四項測試全部通過: + 動態增減/暫停恢復、錯誤/panic/retry/timeout 後重用 worker、阻塞 backend/重複 Start、 + afterFn 等待下一筆任務。 +- [scheduling_test.go](../scheduling_test.go) 另外驗證 FIFO 跨越擴縮容、25% 縮容門檻, + 以及 Release 等待尚未結束的 callback;原有 shutdown、容量限制等測試亦通過。 +- afterFn 等待後續任務的回歸測試已確認在原版通過,最終修改版也通過。 + +此為核心排程修改;正式合併前建議由模組維護者與另一位 reviewer 逐行檢查。 diff --git a/docs/performance/baseline.txt b/docs/performance/baseline.txt new file mode 100644 index 0000000..d928662 --- /dev/null +++ b/docs/performance/baseline.txt @@ -0,0 +1,67 @@ +# baseline +# baseline commit: 9721be66093a0e2889129e4751154d8388698b98 +# 2026-10-31T10:45:24.614465+08:00 +# go test -run ^$ -bench ^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$ -benchmem -benchtime=500ms -count=2 -cpu=12 . +# performance_test.go SHA256: a57ed948549c3348af240f0c69a8bd652cf8b752d72a93208cf892b4a5ffc231 +# queue.go SHA256: fd7b78f7269d316ffa25bfe4ebd0a81664431d6d08534df8e29fc357e6545fdf +# ring.go SHA256: 9d2cf12d3f5a02c7cebcc75c2d032122243384063d3b1f837fbe0d0eb4b9600f + +# Round 1 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 1707652 339.7 ns/op 3456 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=64-12 1729177 362.8 ns/op 3456 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=1024-12 161654 3559 ns/op 51200 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=1024-12 165234 3538 ns/op 51200 B/op 2 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 232165 2550 ns/op 1000 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 231847 2535 ns/op 1000 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 403672 1558 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 395530 1544 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 280562 2086 ns/op 1002 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 281010 2116 ns/op 1002 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 393162 1552 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 407179 1688 ns/op 1001 B/op 17 allocs/op +PASS +ok github.com/golang-queue/queue 8.581s + +# Round 2 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 1528264 405.3 ns/op 3456 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=64-12 1663270 336.9 ns/op 3456 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=1024-12 177080 3332 ns/op 51200 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=1024-12 179100 3431 ns/op 51200 B/op 2 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 230928 2526 ns/op 999 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 235040 2540 ns/op 1000 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 398450 1653 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 371175 1639 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 268393 2006 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 302671 2044 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 360284 1536 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 379545 1645 ns/op 1001 B/op 17 allocs/op +PASS +ok github.com/golang-queue/queue 8.341s + +# Round 3 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 1761234 364.3 ns/op 3456 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=64-12 1764464 334.4 ns/op 3456 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=1024-12 181333 3465 ns/op 51200 B/op 2 allocs/op +BenchmarkRingBoundary/backlog=1024-12 172911 3442 ns/op 51200 B/op 2 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 236456 2511 ns/op 1000 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 234081 2557 ns/op 1000 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 389548 1658 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 376849 1582 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 276811 2118 ns/op 1002 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 299121 1894 ns/op 1001 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 372774 1677 ns/op 1002 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 386558 1663 ns/op 1002 B/op 17 allocs/op +PASS +ok github.com/golang-queue/queue 8.411s diff --git a/docs/performance/optimized.txt b/docs/performance/optimized.txt new file mode 100644 index 0000000..6c935c5 --- /dev/null +++ b/docs/performance/optimized.txt @@ -0,0 +1,67 @@ +# optimized +# baseline commit: 9721be66093a0e2889129e4751154d8388698b98 +# 2026-10-31T10:45:24.614969+08:00 +# go test -run ^$ -bench ^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$ -benchmem -benchtime=500ms -count=2 -cpu=12 . +# performance_test.go SHA256: a57ed948549c3348af240f0c69a8bd652cf8b752d72a93208cf892b4a5ffc231 +# queue.go SHA256: 65e453712c611f4dccff15fe5fa8c64ed4967958171ab2041d87bf2cd87cb64d +# ring.go SHA256: d38f2e662c201c46278d084a751b26188a7c119c4ddd016630a6a0288f51d5d7 + +# Round 1 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 62200502 9.414 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=64-12 64260751 9.370 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65092399 9.111 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65145985 9.156 ns/op 0 B/op 0 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 498918 1199 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 504519 1256 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 431584 1299 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 491500 1408 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 318118 1854 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 333901 1864 ns/op 854 B/op 11 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 450871 1341 ns/op 894 B/op 13 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 460434 1344 ns/op 894 B/op 13 allocs/op +PASS +ok github.com/golang-queue/queue 8.159s + +# Round 2 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 63710948 9.300 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=64-12 64966119 9.350 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 64224645 9.129 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65879168 9.124 ns/op 0 B/op 0 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 489147 1183 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 507361 1195 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 460804 1530 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 391165 1607 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 293264 1930 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 327092 1952 ns/op 854 B/op 11 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 379497 1374 ns/op 894 B/op 13 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 398148 1421 ns/op 894 B/op 13 allocs/op +PASS +ok github.com/golang-queue/queue 8.479s + +# Round 3 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 63923698 9.321 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=64-12 64846461 9.361 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 64768880 9.201 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65377280 9.172 ns/op 0 B/op 0 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 485888 1197 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 501968 1194 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 463135 1469 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 437142 1349 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 314808 1846 ns/op 854 B/op 11 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 335983 1853 ns/op 854 B/op 11 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 441286 1373 ns/op 894 B/op 13 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 480596 1341 ns/op 894 B/op 13 allocs/op +PASS +ok github.com/golang-queue/queue 8.062s diff --git a/docs/performance/ring-only.txt b/docs/performance/ring-only.txt new file mode 100644 index 0000000..130e4e6 --- /dev/null +++ b/docs/performance/ring-only.txt @@ -0,0 +1,67 @@ +# ring-only +# baseline commit: 9721be66093a0e2889129e4751154d8388698b98 +# 2026-10-31T10:45:24.614786+08:00 +# go test -run ^$ -bench ^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$ -benchmem -benchtime=500ms -count=2 -cpu=12 . +# performance_test.go SHA256: a57ed948549c3348af240f0c69a8bd652cf8b752d72a93208cf892b4a5ffc231 +# queue.go SHA256: fd7b78f7269d316ffa25bfe4ebd0a81664431d6d08534df8e29fc357e6545fdf +# ring.go SHA256: d38f2e662c201c46278d084a751b26188a7c119c4ddd016630a6a0288f51d5d7 + +# Round 1 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 63411094 9.324 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=64-12 63519507 9.294 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65887003 9.155 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65422425 9.215 ns/op 0 B/op 0 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 223208 2549 ns/op 999 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 240972 2554 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 398856 1692 ns/op 999 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 363988 1655 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 293664 2074 ns/op 999 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 294046 2065 ns/op 998 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 337825 1647 ns/op 998 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 368608 1632 ns/op 998 B/op 17 allocs/op +PASS +ok github.com/golang-queue/queue 8.146s + +# Round 2 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 64417164 9.331 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=64-12 64936533 9.296 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65695532 9.129 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 65610518 9.148 ns/op 0 B/op 0 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 231363 2537 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 237847 2532 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 352791 1708 ns/op 999 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 362088 1683 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 314890 1915 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 289844 1917 ns/op 998 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 400255 1563 ns/op 998 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 346850 1640 ns/op 998 B/op 17 allocs/op +PASS +ok github.com/golang-queue/queue 8.015s + +# Round 3 +goos: darwin +goarch: arm64 +pkg: github.com/golang-queue/queue +cpu: Apple M4 Pro +BenchmarkRingBoundary/backlog=64-12 64178487 9.318 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=64-12 63515869 9.341 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 66197764 9.148 ns/op 0 B/op 0 allocs/op +BenchmarkRingBoundary/backlog=1024-12 66093248 9.136 ns/op 0 B/op 0 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 230487 2535 ns/op 999 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=1-12 235363 2538 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 385228 1588 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=4-12 348844 1655 ns/op 998 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 298677 2069 ns/op 999 B/op 17 allocs/op +BenchmarkQueueEndToEnd/workers=12-12 285526 2079 ns/op 998 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 394726 1758 ns/op 998 B/op 17 allocs/op +BenchmarkQueueAfterFnEndToEnd-12 407175 1583 ns/op 998 B/op 17 allocs/op +PASS +ok github.com/golang-queue/queue 8.129s diff --git a/docs/performance/summary.json b/docs/performance/summary.json new file mode 100644 index 0000000..8c77673 --- /dev/null +++ b/docs/performance/summary.json @@ -0,0 +1,206 @@ +{ + "baseline": { + "BenchmarkRingBoundary/backlog=64-12": { + "median": [ + 351.25, + 3456.0, + 2.0 + ], + "ns_range": [ + 334.4, + 405.3 + ] + }, + "BenchmarkRingBoundary/backlog=1024-12": { + "median": [ + 3453.5, + 51200.0, + 2.0 + ], + "ns_range": [ + 3332.0, + 3559.0 + ] + }, + "BenchmarkQueueEndToEnd/workers=1-12": { + "median": [ + 2537.5, + 1000.0, + 17.0 + ], + "ns_range": [ + 2511.0, + 2557.0 + ] + }, + "BenchmarkQueueEndToEnd/workers=4-12": { + "median": [ + 1610.5, + 1001.0, + 17.0 + ], + "ns_range": [ + 1544.0, + 1658.0 + ] + }, + "BenchmarkQueueEndToEnd/workers=12-12": { + "median": [ + 2065.0, + 1001.5, + 17.0 + ], + "ns_range": [ + 1894.0, + 2118.0 + ] + }, + "BenchmarkQueueAfterFnEndToEnd-12": { + "median": [ + 1654.0, + 1001.0, + 17.0 + ], + "ns_range": [ + 1536.0, + 1688.0 + ] + } + }, + "ring-only": { + "BenchmarkRingBoundary/backlog=64-12": { + "median": [ + 9.321, + 0.0, + 0.0 + ], + "ns_range": [ + 9.294, + 9.341 + ] + }, + "BenchmarkRingBoundary/backlog=1024-12": { + "median": [ + 9.148, + 0.0, + 0.0 + ], + "ns_range": [ + 9.129, + 9.215 + ] + }, + "BenchmarkQueueEndToEnd/workers=1-12": { + "median": [ + 2537.5, + 998.0, + 17.0 + ], + "ns_range": [ + 2532.0, + 2554.0 + ] + }, + "BenchmarkQueueEndToEnd/workers=4-12": { + "median": [ + 1669.0, + 998.0, + 17.0 + ], + "ns_range": [ + 1588.0, + 1708.0 + ] + }, + "BenchmarkQueueEndToEnd/workers=12-12": { + "median": [ + 2067.0, + 998.0, + 17.0 + ], + "ns_range": [ + 1915.0, + 2079.0 + ] + }, + "BenchmarkQueueAfterFnEndToEnd-12": { + "median": [ + 1636.0, + 998.0, + 17.0 + ], + "ns_range": [ + 1563.0, + 1758.0 + ] + } + }, + "optimized": { + "BenchmarkRingBoundary/backlog=64-12": { + "median": [ + 9.3555, + 0.0, + 0.0 + ], + "ns_range": [ + 9.3, + 9.414 + ] + }, + "BenchmarkRingBoundary/backlog=1024-12": { + "median": [ + 9.1425, + 0.0, + 0.0 + ], + "ns_range": [ + 9.111, + 9.201 + ] + }, + "BenchmarkQueueEndToEnd/workers=1-12": { + "median": [ + 1196.0, + 854.0, + 11.0 + ], + "ns_range": [ + 1183.0, + 1256.0 + ] + }, + "BenchmarkQueueEndToEnd/workers=4-12": { + "median": [ + 1438.5, + 854.0, + 11.0 + ], + "ns_range": [ + 1299.0, + 1607.0 + ] + }, + "BenchmarkQueueEndToEnd/workers=12-12": { + "median": [ + 1859.0, + 854.0, + 11.0 + ], + "ns_range": [ + 1846.0, + 1952.0 + ] + }, + "BenchmarkQueueAfterFnEndToEnd-12": { + "median": [ + 1358.5, + 894.0, + 13.0 + ], + "ns_range": [ + 1341.0, + 1421.0 + ] + } + } +} diff --git a/options.go b/options.go index c45161f..6132f55 100644 --- a/options.go +++ b/options.go @@ -123,7 +123,8 @@ func WithFn(fn func(context.Context, core.TaskMessage) error) Option { // WithAfterFn sets a callback function that will be executed after each job completes. // This callback runs regardless of whether the job succeeded or failed. -// It executes after metrics are updated but before the worker picks up the next task. +// It executes after metrics are updated without occupying a worker slot. +// Callbacks may overlap; Release waits for them to finish. // Useful for cleanup, logging, or triggering post-processing workflows. // // Example: diff --git a/performance_test.go b/performance_test.go new file mode 100644 index 0000000..23a113a --- /dev/null +++ b/performance_test.go @@ -0,0 +1,84 @@ +package queue + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/golang-queue/queue/job" +) + +// BenchmarkRingBoundary holds the backlog near a power-of-two capacity, where +// growing at full and shrinking at half can otherwise copy the entire backlog +// twice per operation. One operation is one successful enqueue/dequeue pair. +func BenchmarkRingBoundary(b *testing.B) { + for _, backlog := range []int{64, 1024} { + b.Run(fmt.Sprintf("backlog=%d", backlog), func(b *testing.B) { + w := NewRing() + m := job.NewTask(func(context.Context) error { return nil }) + for range backlog { + if err := w.Queue(&m); err != nil { + b.Fatal(err) + } + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if err := w.Queue(&m); err != nil { + b.Fatal(err) + } + if _, err := w.Request(); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// BenchmarkQueueEndToEnd measures public submission through handler completion +// and final Release. Batches bound the backlog independently of b.N. +func BenchmarkQueueEndToEnd(b *testing.B) { + for _, workers := range []int64{1, 4, 12} { + b.Run(fmt.Sprintf("workers=%d", workers), func(b *testing.B) { + benchmarkQueueCompletion(b, workers, false) + }) + } +} + +// BenchmarkQueueAfterFnEndToEnd also waits for every configured callback. +func BenchmarkQueueAfterFnEndToEnd(b *testing.B) { + benchmarkQueueCompletion(b, 4, true) +} + +func benchmarkQueueCompletion(b *testing.B, workers int64, withCallback bool) { + var completed sync.WaitGroup + opts := []Option{WithLogger(NewEmptyLogger())} + task := func(context.Context) error { + completed.Done() + return nil + } + if withCallback { + opts = append(opts, WithAfterFn(completed.Done)) + task = func(context.Context) error { return nil } + } + q := NewPool(workers, opts...) + b.ReportAllocs() + b.ResetTimer() + for submitted := 0; submitted < b.N; { + n := min(1024, b.N-submitted) + completed.Add(n) + for range n { + if err := q.QueueTask(task); err != nil { + b.Fatal(err) + } + } + completed.Wait() + submitted += n + } + q.Release() + b.StopTimer() + if got := q.SuccessTasks(); got != uint64(b.N) { + b.Fatalf("completed %d tasks, want %d", got, b.N) + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..4beada7 --- /dev/null +++ b/plan.md @@ -0,0 +1,64 @@ +# Queue performance implementation plan + +## Goal and classification + +Implement the two optimizations requested by the repository owner: stop Ring +resize oscillation and reuse dispatch/execution goroutines. Preserve public APIs, +serialized backend Request calls, retry/timeout/panic handling, dynamic worker +limits, and graceful draining. This is CORE CODE: all consumers depend on it, +orchestration must remain extensible, debt and failures propagate, and changes +need line-by-line review. Recommended release review: module owner plus another +reviewer. The user's instruction to implement items 1 and 2 authorizes this work. + +## Flow + +```mermaid +flowchart TD + Producer[Queue / QueueTask] --> Ring[Ring: grow at full, shrink at quarter] + Ring --> Dispatcher[Persistent dispatcher: serialized Request] + Dispatcher --> Workers[Reusable workers, resized with workerCount] + Workers --> Handler[Existing handler goroutine and timeout / retry logic] + Handler --> Workers + Workers --> Metrics[Existing completion metrics] + Metrics --> Dispatcher + Metrics --> Callback[Independent tracked afterFn goroutine] + style Ring fill:#dff0d8,stroke:#3c763d + style Dispatcher fill:#dff0d8,stroke:#3c763d + style Workers fill:#dff0d8,stroke:#3c763d + style Callback fill:#dff0d8,stroke:#3c763d +``` + +## Scope and constraints + +- May modify ring.go, queue.go, options.go callback documentation, focused + tests/benchmarks, this plan, and performance documentation. +- No public interface changes, dependency upgrades, timer/channel-result redesign, + lock-free queue, or task cancellation semantic changes. +- Keep a handler goroutine per task: timeout must still release the queue slot + even when a handler does not cooperate with cancellation. +- Workers complete their current task before retiring after a size decrease. +- Preserve callback independence: afterFn must be able to wait for another queued + task without holding a worker slot. Track callback goroutines through Release. +- Keep Request serialized, including custom blocking backends. + +## Verification + +- Establish the existing race-test baseline before product edits. +- Ring: FIFO across wrap/grow/shrink, maximum capacity, and no oscillation around + a power-of-two boundary; verify actual shrink still releases capacity. +- Queue: complete every accepted task once, preserve error/panic/timeout/retry + metrics and callbacks, drain on shutdown, increase/decrease/pause/resume worker + count, and release workers without leaks (existing goleak TestMain). +- Test a custom blocking backend and serialized Request compatibility. +- Run full race/coverage suite and repository lint; stress focused lifecycle tests. +- Run identical benchmark source against the original commit, Ring-only change, + and final change. Measure completed work, check enqueue errors, and report + repeated samples, allocation counts, environment, and limitations. + +## Done and rollback + +Done when both optimizations pass the checks and a reproducible before/after +report is saved, followed by the requested commit and PR. Rollback consists of reverting +the ring threshold and dispatcher/worker changes together with related tests. +Main risk: changed scheduling exposes shutdown or dynamic-scaling races; keep +handler execution logic intact and use targeted deterministic synchronization. diff --git a/queue.go b/queue.go index 6115337..f61b857 100644 --- a/queue.go +++ b/queue.go @@ -35,6 +35,7 @@ type ( ready chan struct{} // Channel to signal worker readiness notify chan struct{} // Channel to notify workers of new jobs worker core.Worker // The worker implementation that processes jobs + startOnce sync.Once // Ensures only one dispatcher requests tasks stopOnce sync.Once // Ensures shutdown is only performed once stopFlag atomic.Int32 // Atomic flag indicating if shutdown has started afterFn func() // Optional callback after each job execution @@ -71,7 +72,7 @@ func NewQueue(opts ...Option) (*Queue, error) { return q, nil } -// Start launches all worker goroutines and begins processing jobs. +// Start launches the dispatcher and workers once and begins processing jobs. // If workerCount is zero, Start is a no-op. func (q *Queue) Start() { q.Lock() @@ -80,8 +81,8 @@ func (q *Queue) Start() { if count == 0 { return } - q.routineGroup.Run(func() { - q.start() + q.startOnce.Do(func() { + q.routineGroup.Run(q.start) }) } @@ -198,7 +199,10 @@ func (q *Queue) work(task core.TaskMessage) { q.metric.IncFailureTask() } if q.afterFn != nil { - q.afterFn() + // Completion callbacks have never occupied a worker slot. Keep them + // independent so a callback can wait for another queued task, while + // still making Release wait for all callbacks to finish. + q.routineGroup.Run(q.afterFn) } }() @@ -313,86 +317,99 @@ func (q *Queue) UpdateWorkerCount(num int64) { q.schedule() } -// schedule checks if more workers can be started based on the current busy count. -// If so, it signals readiness to start a new worker. +// schedule wakes the dispatcher after a completion or worker-count update. +// Notifications may coalesce; the dispatcher always rechecks the current limit. func (q *Queue) schedule() { - q.Lock() - defer q.Unlock() - if q.BusyWorkers() >= q.workerCount { - return - } - select { case q.ready <- struct{}{}: default: } } -/* -start launches the main worker loop, which manages job scheduling and execution. +// consume reuses a worker goroutine until it is retired or dispatch has stopped. +// Retiring a busy worker does not interrupt its current task. +func (q *Queue) consume(tasks <-chan core.TaskMessage, retire <-chan struct{}) { + for { + // Prefer retirement before accepting another task. + select { + case <-retire: + return + default: + } + select { + case <-retire: + return + case task, ok := <-tasks: + if !ok { + return + } + q.work(task) + } + } +} -- It uses a ticker to periodically retry job requests if the queue is empty. -- For each available worker slot, it requests a new task from the worker. -- If a task is available, it is sent to the tasks channel and processed by a new goroutine. -- The loop exits when the quit channel is closed. -*/ +// start serializes backend requests and dispatches tasks to reusable workers. +// Only the dispatcher owns the worker set; completed tasks and count updates wake +// it to recheck capacity. The handler goroutine in handle still enforces timeout. func (q *Queue) start() { - tasks := make(chan core.TaskMessage, 1) + tasks := make(chan core.TaskMessage) + defer close(tasks) ticker := time.NewTicker(q.retryInterval) defer ticker.Stop() + var workers []chan struct{} for { - // Ensure the number of busy workers does not exceed the configured worker count. - q.schedule() + q.Lock() + count := q.workerCount + q.Unlock() + + // A nonpositive dynamic count pauses dispatch without interrupting work. + count = max(count, 0) + for int64(len(workers)) > count { + last := len(workers) - 1 + close(workers[last]) + workers[last] = nil + workers = workers[:last] + } + for int64(len(workers)) < count { + retire := make(chan struct{}) + workers = append(workers, retire) + q.routineGroup.Run(func() { q.consume(tasks, retire) }) + } + if q.BusyWorkers() >= count { + select { + case <-q.ready: + continue + case <-q.quit: + return + } + } select { - case <-q.ready: // Wait for a worker slot to become available - case <-q.quit: // Shutdown signal received + case <-q.quit: return + default: } - // Request a task from the worker in a background goroutine. - q.routineGroup.Run(func() { - for { - t, err := q.worker.Request() - if t == nil || err != nil { - if err != nil { - select { - case <-q.quit: - if !errors.Is(err, ErrNoTaskInQueue) { - close(tasks) - return - } - case <-ticker.C: - case <-q.notify: - } - } - } - if t != nil { - tasks <- t - return - } - - select { - case <-q.quit: - if !errors.Is(err, ErrNoTaskInQueue) { - close(tasks) - return - } - default: - } + // Request was already serialized by the old dispatcher waiting for each + // result. Calling it here removes a goroutine and channel hop per task. + task, err := q.worker.Request() + if task == nil || err != nil { + select { + case <-q.quit: + return + case <-ticker.C: + case <-q.notify: + case <-q.ready: + } + if task == nil { + continue } - }) - - task, ok := <-tasks - if !ok { - return } - // Start processing the new task in a separate goroutine. + // Once dequeued, the task must be handed off even during shutdown. + // The workers remain alive until this dispatcher closes tasks. q.metric.IncBusyWorker() - q.routineGroup.Run(func() { - q.work(task) - }) + tasks <- task } } diff --git a/ring.go b/ring.go index 3c51a8e..10619a1 100644 --- a/ring.go +++ b/ring.go @@ -13,7 +13,7 @@ var _ core.Worker = (*Ring)(nil) // Ring is an in-memory worker implementation using a dynamic circular buffer. // It implements the core.Worker interface and provides automatic resizing: // - Doubles capacity when full -// - Halves capacity when less than 25% utilized +// - Halves capacity when at most 25% utilized // // The ring buffer uses two pointers (head and tail) to track the queue boundaries: // - head: points to the next task to dequeue @@ -95,7 +95,7 @@ func (s *Ring) Queue(task core.TaskMessage) error { } // Request dequeues and returns the next task from the ring buffer. -// The buffer shrinks automatically (halves in size) when less than 25% full. +// The buffer shrinks automatically (halves in size) when at most 25% full. // Returns: // - (task, nil) if a task is successfully dequeued // - (nil, ErrNoTaskInQueue) if the queue is currently empty @@ -129,9 +129,9 @@ func (s *Ring) Request() (core.TaskMessage, error) { s.head = (s.head + 1) % len(s.taskQueue) // Advance head with wraparound s.count-- - // Shrink buffer if less than 25% full (to save memory) + // Shrink buffer if at most 25% full (to save memory) // Minimum buffer size is 2 to maintain circular buffer structure - if n := len(s.taskQueue) / 2; n >= 2 && s.count <= n { + if n := len(s.taskQueue) / 2; n >= 2 && s.count <= len(s.taskQueue)/4 { s.resize(n) } diff --git a/scheduling_test.go b/scheduling_test.go new file mode 100644 index 0000000..a6e4a21 --- /dev/null +++ b/scheduling_test.go @@ -0,0 +1,294 @@ +package queue + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-queue/queue/core" + "github.com/golang-queue/queue/job" + + "github.com/stretchr/testify/require" +) + +func receiveWithin[T any](t *testing.T, ch <-chan T) T { + t.Helper() + select { + case value := <-ch: + return value + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for queue progress") + var zero T + return zero + } +} + +func TestRingShrinkHysteresisAndFIFO(t *testing.T) { + w := NewRing() + var expected []*job.Message + for range 16 { + m := new(job.Message) + expected = append(expected, m) + require.NoError(t, w.Queue(m)) + } + for i := range 8 { + m, err := w.Request() + require.NoError(t, err) + require.Same(t, expected[i], m) + } + require.Len(t, w.taskQueue, 16, "half-full buffers must not shrink") + for i := 8; i < 12; i++ { + m, err := w.Request() + require.NoError(t, err) + require.Same(t, expected[i], m) + } + require.Len(t, w.taskQueue, 8, "quarter-full buffers should halve") + // Refill, wrap, grow, and drain to verify FIFO survives both resize paths. + for range 20 { + m := new(job.Message) + expected = append(expected, m) + require.NoError(t, w.Queue(m)) + } + for _, want := range expected[12:] { + m, err := w.Request() + require.NoError(t, err) + require.Same(t, want, m) + } + require.Len(t, w.taskQueue, 2) +} + +func TestQueueResizePauseAndDrain(t *testing.T) { + const total = 16 + started := make(chan int, total) + completed := make(chan struct{}, total) + release := make(chan struct{}) + var releaseOnce sync.Once + q := NewPool(2, WithLogger(NewEmptyLogger()), WithAfterFn(func() { completed <- struct{}{} })) + t.Cleanup(func() { + releaseOnce.Do(func() { close(release) }) + q.UpdateWorkerCount(2) + q.Release() + }) + for id := range total { + require.NoError(t, q.QueueTask(func(context.Context) error { + started <- id + <-release + return nil + })) + } + seen := make(map[int]bool) + receiveStarted := func(n int) { + for range n { + id := receiveWithin(t, started) + require.False(t, seen[id], "task executed twice") + seen[id] = true + } + } + receiveStarted(2) + q.UpdateWorkerCount(4) + receiveStarted(2) + require.EqualValues(t, 4, q.BusyWorkers()) + q.UpdateWorkerCount(1) + for range 4 { + select { + case release <- struct{}{}: + case <-time.After(5 * time.Second): + t.Fatal("workers did not finish during scale-down") + } + } + for range 4 { + receiveWithin(t, completed) + } + receiveStarted(1) + require.EqualValues(t, 1, q.BusyWorkers()) + q.UpdateWorkerCount(0) + release <- struct{}{} + receiveWithin(t, completed) + select { + case <-started: + t.Fatal("task started while paused") + case <-time.After(20 * time.Millisecond): + } + q.UpdateWorkerCount(2) + receiveStarted(2) + require.EqualValues(t, 2, q.BusyWorkers()) + releaseOnce.Do(func() { close(release) }) + q.Release() + receiveStarted(total - len(seen)) + require.Len(t, seen, total) + require.EqualValues(t, total, q.SuccessTasks()) + require.EqualValues(t, 0, q.BusyWorkers()) +} + +func TestReusableWorkerOutcomes(t *testing.T) { + completed := make(chan struct{}, 5) + lateRelease := make(chan struct{}) + lateDone := make(chan struct{}) + q := NewPool(1, WithLogger(NewEmptyLogger()), WithAfterFn(func() { completed <- struct{}{} })) + t.Cleanup(func() { + close(lateRelease) + q.Release() + }) + wantErr := errors.New("task failed") + require.NoError(t, q.QueueTask(func(context.Context) error { return wantErr })) + require.NoError(t, q.QueueTask(func(context.Context) error { panic("task panic") })) + var attempts atomic.Int32 + require.NoError(t, q.QueueTask(func(context.Context) error { + if attempts.Add(1) == 1 { + return wantErr + } + return nil + }, job.AllowOption{RetryCount: job.Int64(1), RetryDelay: job.Time(time.Millisecond)})) + require.NoError(t, q.QueueTask(func(context.Context) error { + defer close(lateDone) + <-lateRelease // Deliberately ignore cancellation to check timeout isolation. + return nil + }, job.AllowOption{Timeout: job.Time(10 * time.Millisecond)})) + require.NoError(t, q.QueueTask(func(context.Context) error { return nil })) + for range 5 { + receiveWithin(t, completed) + } + require.EqualValues(t, 2, attempts.Load()) + require.EqualValues(t, 2, q.SuccessTasks()) + require.EqualValues(t, 3, q.FailureTasks()) + require.EqualValues(t, 5, q.CompletedTasks()) + select { + case <-lateDone: + t.Fatal("timeout test handler finished before being released") + default: + } + // Release still completes even though the timed-out handler has not returned. + q.Release() +} + +func TestAfterFnCanWaitForNextTask(t *testing.T) { + var q *Queue + var callbacks atomic.Int32 + callbackDone := make(chan error, 1) + q = NewPool(1, WithLogger(NewEmptyLogger()), WithAfterFn(func() { + if callbacks.Add(1) != 1 { + return + } + nextTask := make(chan struct{}) + if err := q.QueueTask(func(context.Context) error { + close(nextTask) + return nil + }); err != nil { + callbackDone <- err + return + } + select { + case <-nextTask: + callbackDone <- nil + case <-time.After(time.Second): + callbackDone <- errors.New("afterFn blocked the next task") + } + })) + t.Cleanup(q.Release) + require.NoError(t, q.QueueTask(func(context.Context) error { return nil })) + require.NoError(t, receiveWithin(t, callbackDone)) + q.Release() + require.EqualValues(t, 2, q.SuccessTasks()) + require.EqualValues(t, 2, callbacks.Load()) +} + +func TestReleaseWaitsForAfterFn(t *testing.T) { + entered := make(chan struct{}) + unblock := make(chan struct{}) + var once sync.Once + q := NewPool(1, WithLogger(NewEmptyLogger()), WithAfterFn(func() { + close(entered) + <-unblock + })) + t.Cleanup(func() { + once.Do(func() { close(unblock) }) + q.Release() + }) + require.NoError(t, q.QueueTask(func(context.Context) error { return nil })) + receiveWithin(t, entered) + released := make(chan struct{}) + go func() { + q.Release() + close(released) + }() + select { + case <-released: + t.Fatal("Release returned before afterFn finished") + case <-time.After(20 * time.Millisecond): + } + once.Do(func() { close(unblock) }) + receiveWithin(t, released) +} + +// blockingWorker models backends whose Request blocks until work or shutdown. +// Counting active calls detects accidental parallel Request calls. +type blockingWorker struct { + requests chan core.TaskMessage + closed chan struct{} + entered chan struct{} + once sync.Once + active atomic.Int32 + parallel atomic.Bool +} + +func (w *blockingWorker) Queue(m core.TaskMessage) error { + w.requests <- m + return nil +} + +func (w *blockingWorker) Request() (core.TaskMessage, error) { + if w.active.Add(1) != 1 { + w.parallel.Store(true) + } + defer w.active.Add(-1) + select { + case w.entered <- struct{}{}: + default: + } + select { + case m := <-w.requests: + return m, nil + case <-w.closed: + return nil, ErrQueueHasBeenClosed + } +} + +func (w *blockingWorker) Run(context.Context, core.TaskMessage) error { return nil } + +func (w *blockingWorker) Shutdown() error { + w.once.Do(func() { close(w.closed) }) + return nil +} + +func TestBlockingBackendAndRepeatedStart(t *testing.T) { + w := &blockingWorker{ + requests: make(chan core.TaskMessage, 64), + closed: make(chan struct{}), + entered: make(chan struct{}, 128), + } + completed := make(chan struct{}, 64) + q, err := NewQueue(WithWorker(w), WithWorkerCount(4), WithLogger(NewEmptyLogger()), + WithAfterFn(func() { completed <- struct{}{} })) + require.NoError(t, err) + t.Cleanup(q.Release) + q.Start() + receiveWithin(t, w.entered) + q.Start() + for range 64 { + require.NoError(t, q.QueueTask(func(context.Context) error { return nil })) + } + for range 64 { + receiveWithin(t, completed) + } + released := make(chan struct{}) + go func() { + q.Release() + close(released) + }() + receiveWithin(t, released) + require.False(t, w.parallel.Load(), "backend Request must remain serialized") + require.EqualValues(t, 64, q.SuccessTasks()) +} From dc8131533355d3440557aad8a4c8ec26c1486ec1 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sat, 31 Oct 2026 10:52:04 +0800 Subject: [PATCH 2/2] docs(queue): keep performance artifacts local --- docs/performance.md | 145 ----------------------- docs/performance/baseline.txt | 67 ----------- docs/performance/optimized.txt | 67 ----------- docs/performance/ring-only.txt | 67 ----------- docs/performance/summary.json | 206 --------------------------------- plan.md | 64 ---------- 6 files changed, 616 deletions(-) delete mode 100644 docs/performance.md delete mode 100644 docs/performance/baseline.txt delete mode 100644 docs/performance/optimized.txt delete mode 100644 docs/performance/ring-only.txt delete mode 100644 docs/performance/summary.json delete mode 100644 plan.md diff --git a/docs/performance.md b/docs/performance.md deleted file mode 100644 index 37d9ce4..0000000 --- a/docs/performance.md +++ /dev/null @@ -1,145 +0,0 @@ -# Queue 效能改善報告 - -日期:2026-10-31。基準 commit:`9721be66093a0e2889129e4751154d8388698b98`。 -比較對象是該 commit 與本次工作目錄的修改;沒有更動 public API 或新增依賴。 - -## 結果 - -兩項修改都已完成。容量邊界的 Ring 擴縮容抖動已消除;一般短任務的端到端 -每筆時間中位數降低約 **10–53%**,配置次數由 **17 降至 11 次/任務**。 -這是單機空任務 benchmark 的結果,不代表實際 I/O 或 CPU 密集工作也會有相同增幅。 - -下表皆取 **6 次樣本的中位數**。Queue 的 ns/op 是整批任務的平均處理成本, -不是個別任務的排隊延遲或 p99。吞吐量增幅由修改前時間除以修改後時間計算。 - -| 端到端情境 | 原版 ns/task | 修改後 ns/task | 每筆時間降低 | 換算吞吐量增幅 | allocs/task | -| --- | ---: | ---: | ---: | ---: | ---: | -| 1 worker | 2,537.5 | 1,196.0 | 52.9% | 112.2% | 17 → 11 | -| 4 workers | 1,610.5 | 1,438.5 | 10.7% | 12.0% | 17 → 11 | -| 12 workers | 2,065.0 | 1,859.0 | 10.0% | 11.1% | 17 → 11 | -| 4 workers,含 afterFn | 1,654.0 | 1,358.5 | 17.9% | 21.8% | 17 → 13 | - -一般任務的配置量約從 **1,000–1,002 B/task 降為 854 B/task**,下降約 14.6–14.7%; -有 afterFn 時從 **1,001 B/task 降為 894 B/task**,下降約 10.7%。 - -4/12 workers 的時間樣本範圍有重疊,不能把中位數差異當成已證明的統計顯著改善。 -配置次數的減少則在本次所有樣本中一致。 - -## 1. Ring 縮容門檻 - -[ring.go](../ring.go) 原本在使用量降至容量的 50% 時就把容量減半。 -當 backlog 在 1,024 與 1,025 之間反覆變動,每次 enqueue/dequeue 都會 -在 1,024 與 2,048 容量之間切換,重新配置並複製整個 backlog,期間持有 Ring mutex。 - -修改後:滿載仍擴大一倍,但使用量降至 **25% 或以下**才縮成一半。 -最大待處理數限制、FIFO 與最小 buffer 大小 2 均保留。 - -| 穩定 backlog;每次加入+取出 | 原版 ns/op | 修改後 ns/op | 原版 B/op | 修改後 B/op | allocs/op | -| --- | ---: | ---: | ---: | ---: | ---: | -| 64 筆 | 351.25 | 9.36 | 3,456 | 0 | 2 → 0 | -| 1,024 筆 | 3,453.50 | 9.14 | 51,200 | 0 | 2 → 0 | - -這是刻意涵蓋容量邊界的 microbenchmark,不能解讀成整個 Queue 快數百倍。 -穩態不再配置;首次擴容仍會配置,攤提到大量迭代後 benchmark 顯示 0 B/op、0 allocs/op。 -代價是縮容更晚,部分負載下會保留較大的 backing array,以換取較少的複製與配置。 - -## 2. 重用 dispatcher 與 worker - -[queue.go](../queue.go) 改為單一常駐 dispatcher 直接呼叫 `Request()`, -再將任務交給可重用的 worker goroutine。backend 的 Request 仍然序列執行。 -動態增加 worker 會建立新的 worker;減少時,退役的 worker 會先完成目前任務。 -`Start()` 改為冪等,避免重複呼叫造成多個 dispatcher。 - -| 每筆任務新增的 goroutine | 原版 | 修改後 | -| --- | ---: | ---: | -| 沒有 afterFn | 3:Request、work、handler | 1:handler | -| 有 afterFn | 3:callback 在 work 中執行 | 2:handler、callback | - -上述數字是程式碼執行路徑的計數,不是 runtime profile 的取樣結果; -不包含初始化與擴增 worker 時建立的常駐 goroutine。 - -`handle()` 的 timeout、retry 與 panic 處理維持原實作。 -保留獨立 handler goroutine,才能維持 handler 不理會 context 時,timeout 仍釋放 -Queue 執行名額的行為;這次沒有改成同步執行 handler。 - -原本 afterFn 不占用執行名額,可能等待後續任務完成。因此修改後以受 routineGroup -追蹤的獨立 goroutine 執行 callback,保留此行為,且 `Release()` 仍會等待 callback。 -[options.go](../options.go) 的 callback 說明也已修正為實際行為。 - -常駐 worker 的取捨:Queue 閒置時也會保留 worker goroutine,直到縮減或釋放; -它們阻塞等待任務,不忙迴圈輪詢。阻塞式 backend 的 Request 仍需由 backend 自行 -在 Shutdown 時解除阻塞,與既有介面契約一致。 - -## 分離兩項修改的貢獻 - -額外測量「只改 Ring、保持原始 queue.go」版本。下表單位均為 ns/op 中位數。 - -| 情境 | 原版 | 僅修 Ring | 兩項都修正 | -| --- | ---: | ---: | ---: | -| Ring backlog 64 | 351.25 | 9.32 | 9.36 | -| Ring backlog 1,024 | 3,453.50 | 9.15 | 9.14 | -| Queue,1 worker | 2,537.5 | 2,537.5 | 1,196.0 | -| Queue,4 workers | 1,610.5 | 1,669.0 | 1,438.5 | -| Queue,12 workers | 2,065.0 | 2,067.0 | 1,859.0 | -| Queue,4 workers+afterFn | 1,654.0 | 1,636.0 | 1,358.5 | - -Ring 修正的主要收益出現在容量邊界。本次批次端到端情境沒有重現同樣程度的 -擴縮容抖動;一般任務仍為 17 allocs/task,必須加上排程修改才降至 11。 -因此不能把 Ring 邊界測試的倍數套用到一般吞吐量,也不把這裡 Ring-only 的小幅波動視為改善。 - -## 測量方式與限制 - -- 環境:Apple M4 Pro、darwin/arm64、Go 1.26.10、`GOMAXPROCS=12`(`-cpu=12`)。 -- 三個版本使用完全相同的 [performance_test.go](../performance_test.go)。 -- 每個情境 500ms,3 輪、每輪 2 個樣本,共 6 個樣本。 -- 輪序:原版 → Ring-only → 修改後;修改後 → 原版 → Ring-only;Ring-only → 修改後 → 原版。 -- 版本依序執行,沒有同時執行其他測試或 lint;未使用 race instrumentation 做效能測量。 -- Queue 為單 producer、空任務,每批最多 1,024 筆。每批等到 handler 完成訊號, - callback 情境則等到 callback 訊號;最後 `Release()` 納入計時,確認全部任務成功。 -- 每次入列都檢查錯誤,不把容量滿載後的拒絕算成成功吞吐量。 -- Queue 建構呼叫位於 ResetTimer 之前,非同步啟動與最後釋放的成本仍可能攤提在測量內。 -- 未測真實業務任務、多 producer、外部 broker、長時間穩態、RSS 或 p95/p99。 - CPU 排程、主機背景負載及 GC 都會造成波動;本報告未宣稱統計顯著性。 - -原版與修改後的 6 次時間樣本範圍: - -| Queue 情境 | 原版 ns/task 範圍 | 修改後 ns/task 範圍 | -| --- | ---: | ---: | -| 1 worker | 2,511–2,557 | 1,183–1,256 | -| 4 workers | 1,544–1,658 | 1,299–1,607 | -| 12 workers | 1,894–2,118 | 1,846–1,952 | -| 4 workers+afterFn | 1,536–1,688 | 1,341–1,421 | - -## 重現與原始資料 - -將基準 commit 匯出到兩個暫存目錄,兩者都複製本次 `performance_test.go`; -其中一個再覆蓋本次 `ring.go` 作為 Ring-only。修改後版本使用目前工作目錄。 -每輪在各自目錄執行下列命令,依上述輪序重複三輪: - -```sh -rtk proxy go test -run '^$' \ - -bench '^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$' \ - -benchmem -benchtime=500ms -count=2 -cpu=12 . -``` - -原始資料包含每個樣本、執行時間與程式碼 SHA256,可核對測試與版本: - -- [原版](performance/baseline.txt) -- [僅修 Ring](performance/ring-only.txt) -- [兩項都修正](performance/optimized.txt) -- [中位數與範圍 JSON](performance/summary.json) - -## 正確性驗證 - -- 修改前 `go test -race ./...` 通過。 -- 修改後全套 `go test -race -covermode=atomic -coverprofile=/tmp/queue-performance-coverage.out ./...` 通過。 - Queue package statement coverage 為 **93.2%**;既有 goleak TestMain 同時檢查 goroutine 洩漏。 -- `go tool -modfile=go.tools.mod golangci-lint run`:**0 issues**。 -- 在 `-cpu=1,4,12` 各重複 30 次、開啟 race detector 的四項測試全部通過: - 動態增減/暫停恢復、錯誤/panic/retry/timeout 後重用 worker、阻塞 backend/重複 Start、 - afterFn 等待下一筆任務。 -- [scheduling_test.go](../scheduling_test.go) 另外驗證 FIFO 跨越擴縮容、25% 縮容門檻, - 以及 Release 等待尚未結束的 callback;原有 shutdown、容量限制等測試亦通過。 -- afterFn 等待後續任務的回歸測試已確認在原版通過,最終修改版也通過。 - -此為核心排程修改;正式合併前建議由模組維護者與另一位 reviewer 逐行檢查。 diff --git a/docs/performance/baseline.txt b/docs/performance/baseline.txt deleted file mode 100644 index d928662..0000000 --- a/docs/performance/baseline.txt +++ /dev/null @@ -1,67 +0,0 @@ -# baseline -# baseline commit: 9721be66093a0e2889129e4751154d8388698b98 -# 2026-10-31T10:45:24.614465+08:00 -# go test -run ^$ -bench ^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$ -benchmem -benchtime=500ms -count=2 -cpu=12 . -# performance_test.go SHA256: a57ed948549c3348af240f0c69a8bd652cf8b752d72a93208cf892b4a5ffc231 -# queue.go SHA256: fd7b78f7269d316ffa25bfe4ebd0a81664431d6d08534df8e29fc357e6545fdf -# ring.go SHA256: 9d2cf12d3f5a02c7cebcc75c2d032122243384063d3b1f837fbe0d0eb4b9600f - -# Round 1 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 1707652 339.7 ns/op 3456 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=64-12 1729177 362.8 ns/op 3456 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=1024-12 161654 3559 ns/op 51200 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=1024-12 165234 3538 ns/op 51200 B/op 2 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 232165 2550 ns/op 1000 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 231847 2535 ns/op 1000 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 403672 1558 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 395530 1544 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 280562 2086 ns/op 1002 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 281010 2116 ns/op 1002 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 393162 1552 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 407179 1688 ns/op 1001 B/op 17 allocs/op -PASS -ok github.com/golang-queue/queue 8.581s - -# Round 2 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 1528264 405.3 ns/op 3456 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=64-12 1663270 336.9 ns/op 3456 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=1024-12 177080 3332 ns/op 51200 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=1024-12 179100 3431 ns/op 51200 B/op 2 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 230928 2526 ns/op 999 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 235040 2540 ns/op 1000 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 398450 1653 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 371175 1639 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 268393 2006 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 302671 2044 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 360284 1536 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 379545 1645 ns/op 1001 B/op 17 allocs/op -PASS -ok github.com/golang-queue/queue 8.341s - -# Round 3 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 1761234 364.3 ns/op 3456 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=64-12 1764464 334.4 ns/op 3456 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=1024-12 181333 3465 ns/op 51200 B/op 2 allocs/op -BenchmarkRingBoundary/backlog=1024-12 172911 3442 ns/op 51200 B/op 2 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 236456 2511 ns/op 1000 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 234081 2557 ns/op 1000 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 389548 1658 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 376849 1582 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 276811 2118 ns/op 1002 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 299121 1894 ns/op 1001 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 372774 1677 ns/op 1002 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 386558 1663 ns/op 1002 B/op 17 allocs/op -PASS -ok github.com/golang-queue/queue 8.411s diff --git a/docs/performance/optimized.txt b/docs/performance/optimized.txt deleted file mode 100644 index 6c935c5..0000000 --- a/docs/performance/optimized.txt +++ /dev/null @@ -1,67 +0,0 @@ -# optimized -# baseline commit: 9721be66093a0e2889129e4751154d8388698b98 -# 2026-10-31T10:45:24.614969+08:00 -# go test -run ^$ -bench ^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$ -benchmem -benchtime=500ms -count=2 -cpu=12 . -# performance_test.go SHA256: a57ed948549c3348af240f0c69a8bd652cf8b752d72a93208cf892b4a5ffc231 -# queue.go SHA256: 65e453712c611f4dccff15fe5fa8c64ed4967958171ab2041d87bf2cd87cb64d -# ring.go SHA256: d38f2e662c201c46278d084a751b26188a7c119c4ddd016630a6a0288f51d5d7 - -# Round 1 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 62200502 9.414 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=64-12 64260751 9.370 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65092399 9.111 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65145985 9.156 ns/op 0 B/op 0 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 498918 1199 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 504519 1256 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 431584 1299 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 491500 1408 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 318118 1854 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 333901 1864 ns/op 854 B/op 11 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 450871 1341 ns/op 894 B/op 13 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 460434 1344 ns/op 894 B/op 13 allocs/op -PASS -ok github.com/golang-queue/queue 8.159s - -# Round 2 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 63710948 9.300 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=64-12 64966119 9.350 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 64224645 9.129 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65879168 9.124 ns/op 0 B/op 0 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 489147 1183 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 507361 1195 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 460804 1530 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 391165 1607 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 293264 1930 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 327092 1952 ns/op 854 B/op 11 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 379497 1374 ns/op 894 B/op 13 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 398148 1421 ns/op 894 B/op 13 allocs/op -PASS -ok github.com/golang-queue/queue 8.479s - -# Round 3 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 63923698 9.321 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=64-12 64846461 9.361 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 64768880 9.201 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65377280 9.172 ns/op 0 B/op 0 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 485888 1197 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 501968 1194 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 463135 1469 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 437142 1349 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 314808 1846 ns/op 854 B/op 11 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 335983 1853 ns/op 854 B/op 11 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 441286 1373 ns/op 894 B/op 13 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 480596 1341 ns/op 894 B/op 13 allocs/op -PASS -ok github.com/golang-queue/queue 8.062s diff --git a/docs/performance/ring-only.txt b/docs/performance/ring-only.txt deleted file mode 100644 index 130e4e6..0000000 --- a/docs/performance/ring-only.txt +++ /dev/null @@ -1,67 +0,0 @@ -# ring-only -# baseline commit: 9721be66093a0e2889129e4751154d8388698b98 -# 2026-10-31T10:45:24.614786+08:00 -# go test -run ^$ -bench ^(BenchmarkRingBoundary|BenchmarkQueueEndToEnd|BenchmarkQueueAfterFnEndToEnd)$ -benchmem -benchtime=500ms -count=2 -cpu=12 . -# performance_test.go SHA256: a57ed948549c3348af240f0c69a8bd652cf8b752d72a93208cf892b4a5ffc231 -# queue.go SHA256: fd7b78f7269d316ffa25bfe4ebd0a81664431d6d08534df8e29fc357e6545fdf -# ring.go SHA256: d38f2e662c201c46278d084a751b26188a7c119c4ddd016630a6a0288f51d5d7 - -# Round 1 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 63411094 9.324 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=64-12 63519507 9.294 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65887003 9.155 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65422425 9.215 ns/op 0 B/op 0 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 223208 2549 ns/op 999 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 240972 2554 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 398856 1692 ns/op 999 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 363988 1655 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 293664 2074 ns/op 999 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 294046 2065 ns/op 998 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 337825 1647 ns/op 998 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 368608 1632 ns/op 998 B/op 17 allocs/op -PASS -ok github.com/golang-queue/queue 8.146s - -# Round 2 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 64417164 9.331 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=64-12 64936533 9.296 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65695532 9.129 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 65610518 9.148 ns/op 0 B/op 0 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 231363 2537 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 237847 2532 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 352791 1708 ns/op 999 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 362088 1683 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 314890 1915 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 289844 1917 ns/op 998 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 400255 1563 ns/op 998 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 346850 1640 ns/op 998 B/op 17 allocs/op -PASS -ok github.com/golang-queue/queue 8.015s - -# Round 3 -goos: darwin -goarch: arm64 -pkg: github.com/golang-queue/queue -cpu: Apple M4 Pro -BenchmarkRingBoundary/backlog=64-12 64178487 9.318 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=64-12 63515869 9.341 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 66197764 9.148 ns/op 0 B/op 0 allocs/op -BenchmarkRingBoundary/backlog=1024-12 66093248 9.136 ns/op 0 B/op 0 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 230487 2535 ns/op 999 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=1-12 235363 2538 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 385228 1588 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=4-12 348844 1655 ns/op 998 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 298677 2069 ns/op 999 B/op 17 allocs/op -BenchmarkQueueEndToEnd/workers=12-12 285526 2079 ns/op 998 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 394726 1758 ns/op 998 B/op 17 allocs/op -BenchmarkQueueAfterFnEndToEnd-12 407175 1583 ns/op 998 B/op 17 allocs/op -PASS -ok github.com/golang-queue/queue 8.129s diff --git a/docs/performance/summary.json b/docs/performance/summary.json deleted file mode 100644 index 8c77673..0000000 --- a/docs/performance/summary.json +++ /dev/null @@ -1,206 +0,0 @@ -{ - "baseline": { - "BenchmarkRingBoundary/backlog=64-12": { - "median": [ - 351.25, - 3456.0, - 2.0 - ], - "ns_range": [ - 334.4, - 405.3 - ] - }, - "BenchmarkRingBoundary/backlog=1024-12": { - "median": [ - 3453.5, - 51200.0, - 2.0 - ], - "ns_range": [ - 3332.0, - 3559.0 - ] - }, - "BenchmarkQueueEndToEnd/workers=1-12": { - "median": [ - 2537.5, - 1000.0, - 17.0 - ], - "ns_range": [ - 2511.0, - 2557.0 - ] - }, - "BenchmarkQueueEndToEnd/workers=4-12": { - "median": [ - 1610.5, - 1001.0, - 17.0 - ], - "ns_range": [ - 1544.0, - 1658.0 - ] - }, - "BenchmarkQueueEndToEnd/workers=12-12": { - "median": [ - 2065.0, - 1001.5, - 17.0 - ], - "ns_range": [ - 1894.0, - 2118.0 - ] - }, - "BenchmarkQueueAfterFnEndToEnd-12": { - "median": [ - 1654.0, - 1001.0, - 17.0 - ], - "ns_range": [ - 1536.0, - 1688.0 - ] - } - }, - "ring-only": { - "BenchmarkRingBoundary/backlog=64-12": { - "median": [ - 9.321, - 0.0, - 0.0 - ], - "ns_range": [ - 9.294, - 9.341 - ] - }, - "BenchmarkRingBoundary/backlog=1024-12": { - "median": [ - 9.148, - 0.0, - 0.0 - ], - "ns_range": [ - 9.129, - 9.215 - ] - }, - "BenchmarkQueueEndToEnd/workers=1-12": { - "median": [ - 2537.5, - 998.0, - 17.0 - ], - "ns_range": [ - 2532.0, - 2554.0 - ] - }, - "BenchmarkQueueEndToEnd/workers=4-12": { - "median": [ - 1669.0, - 998.0, - 17.0 - ], - "ns_range": [ - 1588.0, - 1708.0 - ] - }, - "BenchmarkQueueEndToEnd/workers=12-12": { - "median": [ - 2067.0, - 998.0, - 17.0 - ], - "ns_range": [ - 1915.0, - 2079.0 - ] - }, - "BenchmarkQueueAfterFnEndToEnd-12": { - "median": [ - 1636.0, - 998.0, - 17.0 - ], - "ns_range": [ - 1563.0, - 1758.0 - ] - } - }, - "optimized": { - "BenchmarkRingBoundary/backlog=64-12": { - "median": [ - 9.3555, - 0.0, - 0.0 - ], - "ns_range": [ - 9.3, - 9.414 - ] - }, - "BenchmarkRingBoundary/backlog=1024-12": { - "median": [ - 9.1425, - 0.0, - 0.0 - ], - "ns_range": [ - 9.111, - 9.201 - ] - }, - "BenchmarkQueueEndToEnd/workers=1-12": { - "median": [ - 1196.0, - 854.0, - 11.0 - ], - "ns_range": [ - 1183.0, - 1256.0 - ] - }, - "BenchmarkQueueEndToEnd/workers=4-12": { - "median": [ - 1438.5, - 854.0, - 11.0 - ], - "ns_range": [ - 1299.0, - 1607.0 - ] - }, - "BenchmarkQueueEndToEnd/workers=12-12": { - "median": [ - 1859.0, - 854.0, - 11.0 - ], - "ns_range": [ - 1846.0, - 1952.0 - ] - }, - "BenchmarkQueueAfterFnEndToEnd-12": { - "median": [ - 1358.5, - 894.0, - 13.0 - ], - "ns_range": [ - 1341.0, - 1421.0 - ] - } - } -} diff --git a/plan.md b/plan.md deleted file mode 100644 index 4beada7..0000000 --- a/plan.md +++ /dev/null @@ -1,64 +0,0 @@ -# Queue performance implementation plan - -## Goal and classification - -Implement the two optimizations requested by the repository owner: stop Ring -resize oscillation and reuse dispatch/execution goroutines. Preserve public APIs, -serialized backend Request calls, retry/timeout/panic handling, dynamic worker -limits, and graceful draining. This is CORE CODE: all consumers depend on it, -orchestration must remain extensible, debt and failures propagate, and changes -need line-by-line review. Recommended release review: module owner plus another -reviewer. The user's instruction to implement items 1 and 2 authorizes this work. - -## Flow - -```mermaid -flowchart TD - Producer[Queue / QueueTask] --> Ring[Ring: grow at full, shrink at quarter] - Ring --> Dispatcher[Persistent dispatcher: serialized Request] - Dispatcher --> Workers[Reusable workers, resized with workerCount] - Workers --> Handler[Existing handler goroutine and timeout / retry logic] - Handler --> Workers - Workers --> Metrics[Existing completion metrics] - Metrics --> Dispatcher - Metrics --> Callback[Independent tracked afterFn goroutine] - style Ring fill:#dff0d8,stroke:#3c763d - style Dispatcher fill:#dff0d8,stroke:#3c763d - style Workers fill:#dff0d8,stroke:#3c763d - style Callback fill:#dff0d8,stroke:#3c763d -``` - -## Scope and constraints - -- May modify ring.go, queue.go, options.go callback documentation, focused - tests/benchmarks, this plan, and performance documentation. -- No public interface changes, dependency upgrades, timer/channel-result redesign, - lock-free queue, or task cancellation semantic changes. -- Keep a handler goroutine per task: timeout must still release the queue slot - even when a handler does not cooperate with cancellation. -- Workers complete their current task before retiring after a size decrease. -- Preserve callback independence: afterFn must be able to wait for another queued - task without holding a worker slot. Track callback goroutines through Release. -- Keep Request serialized, including custom blocking backends. - -## Verification - -- Establish the existing race-test baseline before product edits. -- Ring: FIFO across wrap/grow/shrink, maximum capacity, and no oscillation around - a power-of-two boundary; verify actual shrink still releases capacity. -- Queue: complete every accepted task once, preserve error/panic/timeout/retry - metrics and callbacks, drain on shutdown, increase/decrease/pause/resume worker - count, and release workers without leaks (existing goleak TestMain). -- Test a custom blocking backend and serialized Request compatibility. -- Run full race/coverage suite and repository lint; stress focused lifecycle tests. -- Run identical benchmark source against the original commit, Ring-only change, - and final change. Measure completed work, check enqueue errors, and report - repeated samples, allocation counts, environment, and limitations. - -## Done and rollback - -Done when both optimizations pass the checks and a reproducible before/after -report is saved, followed by the requested commit and PR. Rollback consists of reverting -the ring threshold and dispatcher/worker changes together with related tests. -Main risk: changed scheduling exposes shutdown or dynamic-scaling races; keep -handler execution logic intact and use targeted deterministic synchronization.