Skip to content

Commit b458967

Browse files
authored
perf(linked): recycle nodes via internal free list + refresh README benchmarks (#58)
* perf(linked): recycle popped nodes via internal free list Offer allocated a fresh *node[T] every call and Get handed popped nodes to the GC. In the steady-state offer/get pattern that's one allocation per cycle — visible in the benchmark as 16 B/op / 1 alloc. Maintain an internal free stack of popped nodes (capped at 64 to avoid retaining memory after a large drain). Offer pulls from it first, Get pushes onto it. No cross-goroutine pool contention because the list lives under the queue's existing lock. Benchmark on darwin/arm64, M4 Pro: before BenchmarkLinkedQueue/Get_Offer-12 19.70 ns/op 16 B/op 1 allocs/op after BenchmarkLinkedQueue/Get_Offer-12 14.75 ns/op 0 B/op 0 allocs/op before BenchmarkLinkedQueue/Offer-12 21.71 ns/op 16 B/op 1 allocs/op after BenchmarkLinkedQueue/Offer-12 22.42 ns/op 16 B/op 1 allocs/op Offer-only pattern keeps the same alloc profile (free list stays empty). Peek unchanged. Get no longer issues an allocation in the common case. Refs #47 * docs(readme): refresh benchmarks post-fixes Numbers taken on an Apple M4 Pro (darwin/arm64), go test -bench=. -count=3. The old table was from October 2023 on an 8-core machine and predates all the recent fixes (pointer-retention, Broadcast-vs-Signal, heap.Pop correction, and the linked queue's node free list). Most notable change: LinkedQueue/Get_Offer drops from 16 B / 1 alloc to 0 B / 0 allocs. * docs(readme): drop hardware details from benchmark block * docs(readme): drop MarshalJSON from benchmark table * test(linked): cover free-list reuse and cap-reached branches The node free list added in this branch introduced two new branches that the existing tests didn't hit — "Offer after Get reuses a recycled node" and "drain past freeCap stops growing the free list". Coverage fell to 98.4% and the 100% gate failed. Add a dedicated sub-test that exercises both paths. No observable behaviour change. * test(linked): split recycle coverage test to keep gocognit under 20
1 parent b7d73c6 commit b458967

3 files changed

Lines changed: 118 additions & 16 deletions

File tree

README.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -269,19 +269,19 @@ func main() {
269269

270270
## Benchmarks
271271

272-
Results as of October 2023.
272+
Run locally with `go test -bench=. -benchmem -benchtime=3s -count=3`. Reported numbers are per-operation timings and allocations; absolute values vary by hardware, but the shape (zero-alloc reads everywhere, zero-alloc offer/get for Circular and Linked) should be stable.
273273

274274
```text
275-
BenchmarkBlockingQueue/Peek-8 84873882 13.98 ns/op 0 B/op 0 allocs/op
276-
BenchmarkBlockingQueue/Get_Offer-8 27135865 47.00 ns/op 44 B/op 0 allocs/op
277-
BenchmarkBlockingQueue/Offer-8 53750395 25.40 ns/op 43 B/op 0 allocs/op
278-
BenchmarkCircularQueue/Peek-8 86001980 13.76 ns/op 0 B/op 0 allocs/op
279-
BenchmarkCircularQueue/Get_Offer-8 32379159 36.83 ns/op 0 B/op 0 allocs/op
280-
BenchmarkCircularQueue/Offer-8 63956366 18.77 ns/op 0 B/op 0 allocs/op
281-
BenchmarkLinkedQueue/Peek-8 1000000000 0.4179 ns/op 0 B/op 0 allocs/op
282-
BenchmarkLinkedQueue/Get_Offer-8 61257436 18.48 ns/op 16 B/op 1 allocs/op
283-
BenchmarkLinkedQueue/Offer-8 38975062 30.74 ns/op 16 B/op 1 allocs/op
284-
BenchmarkPriorityQueue/Peek-8 86633734 14.02 ns/op 0 B/op 0 allocs/op
285-
BenchmarkPriorityQueue/Get_Offer-8 29347177 39.88 ns/op 0 B/op 0 allocs/op
286-
BenchmarkPriorityQueue/Offer-8 40117958 31.37 ns/op 54 B/op 0 allocs/op
275+
BenchmarkBlockingQueue/Peek 3.8 ns/op 0 B/op 0 allocs/op
276+
BenchmarkBlockingQueue/Get_Offer 22.9 ns/op 8 B/op 1 allocs/op
277+
BenchmarkBlockingQueue/Offer 13.0 ns/op 49 B/op 0 allocs/op
278+
BenchmarkCircularQueue/Peek 3.9 ns/op 0 B/op 0 allocs/op
279+
BenchmarkCircularQueue/Get_Offer 13.9 ns/op 0 B/op 0 allocs/op
280+
BenchmarkCircularQueue/Offer 6.5 ns/op 0 B/op 0 allocs/op
281+
BenchmarkLinkedQueue/Peek 3.9 ns/op 0 B/op 0 allocs/op
282+
BenchmarkLinkedQueue/Get_Offer 14.7 ns/op 0 B/op 0 allocs/op
283+
BenchmarkLinkedQueue/Offer 22.7 ns/op 16 B/op 1 allocs/op
284+
BenchmarkPriorityQueue/Peek 3.9 ns/op 0 B/op 0 allocs/op
285+
BenchmarkPriorityQueue/Get_Offer 18.1 ns/op 0 B/op 0 allocs/op
286+
BenchmarkPriorityQueue/Offer 17.1 ns/op 48 B/op 0 allocs/op
287287
```

linked.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,16 @@ type Linked[T comparable] struct {
2323
initialElements []T // initial elements with which the queue was created, allowing for a reset to its original state if needed.
2424
// synchronization
2525
lock sync.RWMutex
26+
// free is a stack of recycled nodes. Offer pulls from here before
27+
// allocating; Get pushes onto it. Bounded to freeCap so Clear/Reset
28+
// can't cause unbounded retention.
29+
free *node[T]
30+
freeLen int
2631
}
2732

33+
// freeCap is the maximum number of nodes cached for reuse.
34+
const freeCap = 64
35+
2836
// NewLinked creates a new Linked containing the given elements.
2937
func NewLinked[T comparable](elements []T) *Linked[T] {
3038
queue := &Linked[T]{
@@ -52,14 +60,18 @@ func (lq *Linked[T]) Get() (elem T, _ error) {
5260
return elem, ErrNoElementsAvailable
5361
}
5462

55-
value := lq.head.value
56-
lq.head = lq.head.next
63+
popped := lq.head
64+
value := popped.value
65+
66+
lq.head = popped.next
5767
lq.size--
5868

5969
if lq.isEmpty() {
6070
lq.tail = nil
6171
}
6272

73+
lq.recycle(popped)
74+
6375
return value, nil
6476
}
6577

@@ -73,7 +85,8 @@ func (lq *Linked[T]) Offer(value T) error {
7385

7486
// offer inserts the element into the queue.
7587
func (lq *Linked[T]) offer(value T) error {
76-
newNode := &node[T]{value: value}
88+
newNode := lq.acquireNode()
89+
newNode.value = value
7790

7891
if lq.isEmpty() {
7992
lq.head = newNode
@@ -87,6 +100,35 @@ func (lq *Linked[T]) offer(value T) error {
87100
return nil
88101
}
89102

103+
// acquireNode pulls a node off the free list or allocates a fresh one.
104+
func (lq *Linked[T]) acquireNode() *node[T] {
105+
if lq.free == nil {
106+
return &node[T]{}
107+
}
108+
109+
n := lq.free
110+
lq.free = n.next
111+
lq.freeLen--
112+
n.next = nil
113+
114+
return n
115+
}
116+
117+
// recycle zeroes a popped node and returns it to the free list, capped
118+
// at freeCap to avoid pinning memory after a large drain.
119+
func (lq *Linked[T]) recycle(n *node[T]) {
120+
if lq.freeLen >= freeCap {
121+
return
122+
}
123+
124+
var zero T
125+
126+
n.value = zero
127+
n.next = lq.free
128+
lq.free = n
129+
lq.freeLen++
130+
}
131+
90132
// Reset sets the queue to its initial state.
91133
func (lq *Linked[T]) Reset() {
92134
lq.lock.Lock()

linked_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,66 @@ func TestLinked(t *testing.T) {
2222
t.Run("Reset", testLinkedReset)
2323
t.Run("Iterator", testLinkedIterator)
2424
t.Run("MarshalJSON", testLinkedMarshalJSON)
25+
t.Run("OfferReusesPoppedNode", testLinkedOfferReusesPoppedNode)
26+
t.Run("DrainBeyondFreeCap", testLinkedDrainBeyondFreeCap)
27+
}
28+
29+
// testLinkedOfferReusesPoppedNode drives the free-list-has-node branch
30+
// of the internal node recycling.
31+
func testLinkedOfferReusesPoppedNode(t *testing.T) {
32+
t.Parallel()
33+
34+
linkedQueue := queue.NewLinked([]int{1, 2, 3})
35+
36+
got, err := linkedQueue.Get()
37+
if err != nil || got != 1 {
38+
t.Fatalf("get: %d %v", got, err)
39+
}
40+
41+
// This Offer reuses the node just released by Get.
42+
if err := linkedQueue.Offer(4); err != nil {
43+
t.Fatalf("offer: %v", err)
44+
}
45+
46+
cleared := linkedQueue.Clear()
47+
expected := []int{2, 3, 4}
48+
49+
if !reflect.DeepEqual(expected, cleared) {
50+
t.Fatalf("expected %v got %v", expected, cleared)
51+
}
52+
}
53+
54+
// testLinkedDrainBeyondFreeCap drives the cap-reached branch of the
55+
// internal recycle() so the free list stops growing past its cap.
56+
func testLinkedDrainBeyondFreeCap(t *testing.T) {
57+
t.Parallel()
58+
59+
// Use a size that exceeds the free-list cap so the cap-reached
60+
// branch of recycle() executes.
61+
const n = 128
62+
63+
linkedQueue := queue.NewLinked[int](nil)
64+
65+
for i := 0; i < n; i++ {
66+
if err := linkedQueue.Offer(i); err != nil {
67+
t.Fatalf("offer %d: %v", i, err)
68+
}
69+
}
70+
71+
for i := 0; i < n; i++ {
72+
got, err := linkedQueue.Get()
73+
if err != nil {
74+
t.Fatalf("get %d: %v", i, err)
75+
}
76+
77+
if got != i {
78+
t.Fatalf("get %d: want %d got %d", i, i, got)
79+
}
80+
}
81+
82+
if !linkedQueue.IsEmpty() {
83+
t.Fatal("queue should be empty")
84+
}
2585
}
2686

2787
func testLinkedGet(t *testing.T) {

0 commit comments

Comments
 (0)