Skip to content

Commit bdb618c

Browse files
reduce allocations
1 parent 7c2aeed commit bdb618c

4 files changed

Lines changed: 144 additions & 127 deletions

File tree

alloc_test.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,17 @@ import (
44
"testing"
55
)
66

7-
func BenchmarkCheckDeadlock(b *testing.B) {
8-
ch := make(chan struct{})
9-
close(ch)
7+
func BenchmarkRegisterDeregister(b *testing.B) {
108
for i := 0; i < b.N; i++ {
11-
checkDeadlock(nil, nil, 0, ch)
9+
id := dw.register(nil, nil, 0)
10+
dw.deregister(id)
11+
}
12+
}
13+
14+
func BenchmarkLockUnlock(b *testing.B) {
15+
var mu Mutex
16+
for i := 0; i < b.N; i++ {
17+
mu.Lock()
18+
mu.Unlock()
1219
}
1320
}

deadlock.go

Lines changed: 130 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"os"
99
"sync"
10+
"sync/atomic"
1011
"time"
1112

1213
"github.com/petermattis/goid"
@@ -197,104 +198,158 @@ func lock(lockFn func(), ptr interface{}) {
197198
if Opts.DeadlockTimeout <= 0 {
198199
lockFn()
199200
} else {
200-
ch := make(chan struct{})
201201
currentID := goid.Get()
202-
go checkDeadlock(stack, ptr, currentID, ch)
202+
e := dw.register(stack, ptr, currentID)
203203
lockFn()
204+
dw.deregister(e)
204205
postLock(stack, ptr)
205-
close(ch)
206206
return
207207
}
208208
postLock(stack, ptr)
209209
}
210210

211-
var timersPool sync.Pool
211+
// pendingEntry tracks a goroutine that is waiting to acquire a lock. Entries are
212+
// pooled to avoid per-lock heap allocations (goroutine stacks, channels, closures).
213+
//
214+
// Timer safety invariants:
215+
// - checkFn is allocated once per entry and reused across pool cycles, so recycling
216+
// an entry does not allocate a new closure.
217+
// - The done flag synchronizes the callback with deregister: deregister sets done=1
218+
// before calling Stop(), and the callback checks done before acting. Because both
219+
// use atomic operations, the callback is guaranteed to observe done=1 if deregister
220+
// has already run — even if the runtime already scheduled the callback.
221+
// - An entry is only returned to the pool when timer.Stop() returns true, meaning
222+
// the timer was successfully cancelled and the callback will never run. This prevents
223+
// a recycled entry from being mutated by an in-flight callback.
224+
// - When Stop() returns false (callback already firing or queued), the entry is
225+
// intentionally leaked to GC. This only happens in the rare deadlock-timeout path.
226+
type pendingEntry struct {
227+
stack []uintptr
228+
ptr interface{}
229+
gid int64
230+
done int32 // atomic: 0=pending, 1=acquired
231+
timer *time.Timer
232+
checkFn func()
233+
}
212234

213-
func acquireTimer(d time.Duration) *time.Timer {
214-
if shouldDisableTimerPool() {
215-
return time.NewTimer(Opts.DeadlockTimeout)
235+
func newPendingEntry() *pendingEntry {
236+
e := &pendingEntry{}
237+
// Capture e by pointer so the closure is stable across pool reuse — no new
238+
// closure allocation when the entry is recycled.
239+
e.checkFn = func() {
240+
// If the lock was acquired (done=1), the entry may already be back in the
241+
// pool or being reused. Bail out unconditionally.
242+
if atomic.LoadInt32(&e.done) != 0 {
243+
return
244+
}
245+
onDeadlockTimeout(e)
216246
}
247+
return e
248+
}
217249

218-
t, ok := timersPool.Get().(*time.Timer)
219-
if ok {
220-
_ = t.Reset(d)
221-
return t
222-
}
223-
return time.NewTimer(Opts.DeadlockTimeout)
250+
var pendingPool = sync.Pool{
251+
New: func() interface{} {
252+
return newPendingEntry()
253+
},
224254
}
225255

226-
func releaseTimer(t *time.Timer) {
227-
stopped := t.Stop()
256+
type deadlockWatcher struct{}
228257

229-
// Skip timer pooling if disabled
258+
var dw deadlockWatcher
259+
260+
func (w *deadlockWatcher) register(stack []uintptr, ptr interface{}, gid int64) *pendingEntry {
261+
var e *pendingEntry
230262
if shouldDisableTimerPool() {
231-
return
263+
e = newPendingEntry()
264+
} else {
265+
e = pendingPool.Get().(*pendingEntry)
232266
}
233-
234-
// Use non-blocking drain to avoid hanging in synctest bubbles.
235-
// Blocking on t.C can deadlock when testing/synctest virtualizes time.
236-
if !stopped {
237-
select {
238-
case <-t.C:
239-
default:
240-
}
267+
e.stack = stack
268+
e.ptr = ptr
269+
e.gid = gid
270+
atomic.StoreInt32(&e.done, 0)
271+
if e.timer == nil {
272+
// First use (freshly allocated entry): create the AfterFunc timer.
273+
// AfterFunc avoids the channel-drain problems of channel-based timers,
274+
// which are especially problematic under testing/synctest.
275+
e.timer = time.AfterFunc(Opts.DeadlockTimeout, e.checkFn)
276+
} else {
277+
// Reused from pool: the timer was previously Stop()'d successfully
278+
// (guaranteed by deregister), so Reset is safe here.
279+
e.timer.Reset(Opts.DeadlockTimeout)
241280
}
281+
return e
282+
}
242283

243-
timersPool.Put(t)
284+
// deregister marks the lock as acquired and cancels the deadlock timer.
285+
// Must be called exactly once per register call. The entry pointer is
286+
// stack-local in lock(), so concurrent or duplicate calls cannot occur.
287+
func (w *deadlockWatcher) deregister(e *pendingEntry) {
288+
// Mark done BEFORE stopping the timer. The callback checks done with an
289+
// atomic load, so even if the timer fires concurrently, the callback will
290+
// see done=1 and return without acting.
291+
atomic.StoreInt32(&e.done, 1)
292+
stopped := e.timer.Stop()
293+
// Only recycle the entry if Stop() confirmed the callback won't run.
294+
// If Stop() returned false the callback is already executing or queued;
295+
// recycling would race with the callback reading entry fields.
296+
if stopped && !shouldDisableTimerPool() {
297+
e.stack = nil
298+
e.ptr = nil
299+
e.gid = 0
300+
pendingPool.Put(e)
301+
}
244302
}
245303

246-
func checkDeadlock(stack []uintptr, ptr interface{}, currentID int64, ch <-chan struct{}) {
247-
t := acquireTimer(Opts.DeadlockTimeout)
248-
defer releaseTimer(t)
249-
for {
250-
select {
251-
case <-t.C:
252-
lo.mu.Lock()
253-
holders, ok := lo.cur[ptr]
254-
if !ok || len(holders) == 0 {
255-
lo.mu.Unlock()
256-
break // Nobody seems to be holding the lock, try again.
257-
}
258-
Opts.mu.Lock()
259-
fmt.Fprintln(Opts.LogBuf, header)
260-
for _, prev := range holders {
261-
fmt.Fprintln(Opts.LogBuf, "Previous place where the lock was grabbed")
262-
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", prev.gid, ptr)
263-
printStack(Opts.LogBuf, prev.stack)
264-
}
265-
fmt.Fprintln(Opts.LogBuf, "Have been trying to lock it again for more than", Opts.DeadlockTimeout)
266-
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", currentID, ptr)
267-
printStack(Opts.LogBuf, stack)
268-
stacks := stacks()
269-
grs := bytes.Split(stacks, []byte("\n\n"))
270-
for _, prev := range holders {
271-
for _, g := range grs {
272-
if goid.ExtractGID(g) == prev.gid {
273-
fmt.Fprintln(Opts.LogBuf, "Here is what goroutine", prev.gid, "doing now")
274-
Opts.LogBuf.Write(g)
275-
fmt.Fprintln(Opts.LogBuf)
276-
}
277-
}
278-
}
279-
lo.other(ptr)
280-
if Opts.PrintAllCurrentGoroutines {
281-
fmt.Fprintln(Opts.LogBuf, "All current goroutines:")
282-
Opts.LogBuf.Write(stacks)
283-
}
284-
fmt.Fprintln(Opts.LogBuf)
285-
if buf, ok := Opts.LogBuf.(*bufio.Writer); ok {
286-
buf.Flush()
304+
func onDeadlockTimeout(e *pendingEntry) {
305+
lo.mu.Lock()
306+
holders, ok := lo.cur[e.ptr]
307+
if !ok || len(holders) == 0 {
308+
// Lock appears unheld (transient state — holder may have just released).
309+
// Reschedule if the waiter is still pending. Note: this creates a new timer
310+
// (e.timer is not updated), so if deregister runs later it will Stop() the
311+
// original (already-fired) timer, get false, and skip pooling. The new timer's
312+
// callback will then observe done=1 and no-op. This is safe but means the
313+
// entry won't be recycled — acceptable since this is the rare timeout path.
314+
lo.mu.Unlock()
315+
if atomic.LoadInt32(&e.done) == 0 {
316+
time.AfterFunc(Opts.DeadlockTimeout, e.checkFn)
317+
}
318+
return
319+
}
320+
Opts.mu.Lock()
321+
fmt.Fprintln(Opts.LogBuf, header)
322+
for _, prev := range holders {
323+
fmt.Fprintln(Opts.LogBuf, "Previous place where the lock was grabbed")
324+
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", prev.gid, e.ptr)
325+
printStack(Opts.LogBuf, prev.stack)
326+
}
327+
fmt.Fprintln(Opts.LogBuf, "Have been trying to lock it again for more than", Opts.DeadlockTimeout)
328+
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", e.gid, e.ptr)
329+
printStack(Opts.LogBuf, e.stack)
330+
stacks := stacks()
331+
grs := bytes.Split(stacks, []byte("\n\n"))
332+
for _, prev := range holders {
333+
for _, g := range grs {
334+
if goid.ExtractGID(g) == prev.gid {
335+
fmt.Fprintln(Opts.LogBuf, "Here is what goroutine", prev.gid, "doing now")
336+
Opts.LogBuf.Write(g)
337+
fmt.Fprintln(Opts.LogBuf)
287338
}
288-
Opts.mu.Unlock()
289-
lo.mu.Unlock()
290-
Opts.OnPotentialDeadlock()
291-
<-ch
292-
return
293-
case <-ch:
294-
return
295339
}
296-
t.Reset(Opts.DeadlockTimeout)
297340
}
341+
lo.other(e.ptr)
342+
if Opts.PrintAllCurrentGoroutines {
343+
fmt.Fprintln(Opts.LogBuf, "All current goroutines:")
344+
Opts.LogBuf.Write(stacks)
345+
}
346+
fmt.Fprintln(Opts.LogBuf)
347+
if buf, ok := Opts.LogBuf.(*bufio.Writer); ok {
348+
buf.Flush()
349+
}
350+
Opts.mu.Unlock()
351+
lo.mu.Unlock()
352+
Opts.OnPotentialDeadlock()
298353
}
299354

300355
type lockOrder struct {

synctest_comparison_test.go

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,76 +5,32 @@ import (
55
"time"
66
)
77

8-
// clearTimerPool drains all timers from the pool
9-
func clearTimerPool() {
10-
// Keep getting timers until the pool returns nil
11-
for {
12-
obj := timersPool.Get()
13-
if obj == nil {
14-
break
15-
}
16-
// Don't try to stop the timers - just discard them
17-
// Stopping them would trigger "stop of synctest timer from outside bubble"
18-
// if they were created inside a synctest bubble
19-
}
20-
}
21-
228
func TestNormalDeadlockDetection(t *testing.T) {
23-
// Clear the timer pool
24-
clearTimerPool()
25-
26-
// Configure deadlock detection - same as synctest version
279
oldTimeout := Opts.DeadlockTimeout
2810
oldOnDeadlock := Opts.OnPotentialDeadlock
29-
Opts.DeadlockTimeout = 20 * time.Millisecond // Shorter timeout
11+
Opts.DeadlockTimeout = 20 * time.Millisecond
3012
Opts.OnPotentialDeadlock = func() {
3113
t.Log("Deadlock detected!")
32-
// Don't exit, just log
3314
}
3415
defer func() {
3516
Opts.DeadlockTimeout = oldTimeout
3617
Opts.OnPotentialDeadlock = oldOnDeadlock
3718
}()
3819

39-
// Same test logic as synctest version, but without synctest.Run
40-
t.Log("Starting normal test")
41-
42-
// Simple test - just lock and unlock
4320
var mu Mutex
44-
t.Log("About to acquire first lock")
4521
mu.Lock()
46-
t.Log("First lock acquired")
4722
mu.Unlock()
48-
t.Log("First lock released - simple test succeeded")
4923

50-
// Test with concurrent lock attempt
51-
t.Log("About to start concurrent test")
5224
mu.Lock()
53-
t.Log("Main goroutine has lock, starting concurrent goroutine")
5425

5526
done := make(chan bool, 1)
5627
go func() {
57-
t.Log("Concurrent goroutine: attempting to acquire lock")
58-
// This will trigger deadlock detection
5928
mu.Lock()
60-
t.Log("Concurrent goroutine: lock acquired")
6129
done <- true
6230
mu.Unlock()
63-
t.Log("Concurrent goroutine: lock released")
6431
}()
6532

66-
t.Log("Main goroutine: sleeping to allow deadlock detection")
67-
// Give deadlock detection time to trigger
6833
time.Sleep(30 * time.Millisecond)
69-
t.Log("Main goroutine: finished sleeping")
70-
71-
t.Log("Main goroutine: releasing lock")
7234
mu.Unlock()
73-
74-
t.Log("Main goroutine: waiting for concurrent goroutine to finish")
7535
<-done
76-
t.Log("Main goroutine: concurrent goroutine finished")
77-
78-
// Clear pool again after test
79-
clearTimerPool()
8036
}

timerpool_go125.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
package deadlock
44

5-
// shouldDisableTimerPool determines if timer pooling should be disabled
6-
// In Go 1.25, timer pooling is enabled by default for performance. The synctest
7-
// compatibility fix (skipping channel drain) is handled separately in releaseTimer().
5+
// shouldDisableTimerPool determines if timer/entry pooling should be disabled.
6+
// In Go 1.25, pooling is enabled by default for performance.
87
func shouldDisableTimerPool() bool {
98
switch Opts.TimerPool {
109
case TimerPoolDefault:

0 commit comments

Comments
 (0)