Bump github.com/sasha-s/go-deadlock from 0.3.6 to 0.3.9

Bumps [github.com/sasha-s/go-deadlock](https://github.com/sasha-s/go-deadlock) from 0.3.6 to 0.3.9.
- [Release notes](https://github.com/sasha-s/go-deadlock/releases)
- [Commits](https://github.com/sasha-s/go-deadlock/compare/v0.3.6...v0.3.9)

---
updated-dependencies:
- dependency-name: github.com/sasha-s/go-deadlock
  dependency-version: 0.3.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
This commit is contained in:
dependabot[bot]
2026-04-01 09:24:57 +00:00
committed by GitHub
parent 8aaed47c63
commit 5928e851dd
20 changed files with 963 additions and 140 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ require (
github.com/sahilm/fuzzy v0.1.1
github.com/samber/lo v1.31.0
github.com/sanity-io/litter v1.5.8
github.com/sasha-s/go-deadlock v0.3.6
github.com/sasha-s/go-deadlock v0.3.9
github.com/sirupsen/logrus v1.9.3
github.com/spf13/afero v1.15.0
github.com/spkg/bom v1.0.1
+2 -2
View File
@@ -109,8 +109,8 @@ github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM=
github.com/samber/lo v1.31.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw=
github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo=
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+32 -3
View File
@@ -1,4 +1,4 @@
# Online deadlock detection in go (golang). [![Try it online](https://img.shields.io/badge/try%20it-online-blue.svg)](https://wandbox.org/permlink/hJc6QCZowxbNm9WW) [![Docs](https://godoc.org/github.com/sasha-s/go-deadlock?status.svg)](https://godoc.org/github.com/sasha-s/go-deadlock) [![codecov](https://codecov.io/gh/sasha-s/go-deadlock/branch/master/graph/badge.svg)](https://codecov.io/gh/sasha-s/go-deadlock) [![version](https://badge.fury.io/gh/sasha-s%2Fgo-deadlock.svg)](https://github.com/sasha-s/go-deadlock/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/sasha-s/go-deadlock)](https://goreportcard.com/report/github.com/sasha-s/go-deadlock) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
# Online deadlock detection in go (golang). [![Try it online](https://img.shields.io/badge/try%20it-online-blue.svg)](https://wandbox.org/permlink/hJc6QCZowxbNm9WW) [![Docs](https://godoc.org/github.com/sasha-s/go-deadlock?status.svg)](https://godoc.org/github.com/sasha-s/go-deadlock) [![codecov](https://codecov.io/gh/sasha-s/go-deadlock/branch/main/graph/badge.svg)](https://codecov.io/gh/sasha-s/go-deadlock) [![version](https://badge.fury.io/gh/sasha-s%2Fgo-deadlock.svg)](https://github.com/sasha-s/go-deadlock/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/sasha-s/go-deadlock)](https://goreportcard.com/report/github.com/sasha-s/go-deadlock) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
## Why
Deadlocks happen and are painful to debug.
@@ -172,16 +172,45 @@ func main() {
rlockTwice()
}
```
## Build Tags and Compatibility Modes
go-deadlock supports multiple build configurations for different use cases:
* **Normal mode** (default): Full deadlock detection with timer pooling
* **Synctest mode**: Compatible with Go's `testing/synctest` package - use either:
* `-tags=deadlock_synctest` (recommended for Go 1.25+)
* `-tags=goexperiment.synctest` (for experimental synctest)
* **Disabled mode** (`-tags=deadlock_disable`): Zero overhead, no detection
**Why synctest mode?** `sync.Mutex` is not durably blocking in synctest bubbles, but channels are. Synctest mode uses channel-based mutexes to ensure proper behavior with `testing/synctest`.
### Quick Examples
```bash
# Normal development/testing
go test ./...
# Testing with synctest (Go 1.25+, recommended)
GODEBUG=asynctimerchan=0 go test -tags=deadlock_synctest ./...
# Testing with experimental synctest
GODEBUG=asynctimerchan=0 go test -tags=goexperiment.synctest ./...
# Production build with zero overhead
go build -tags=deadlock_disable ./...
```
## Configuring go-deadlock
Have a look at [Opts](https://pkg.go.dev/github.com/sasha-s/go-deadlock#pkg-variables).
* `Opts.Disable`: disables deadlock detection altogether
* `Opts.Disable`: disables deadlock detection altogether (runtime option; see also `deadlock_disable` build tag)
* `Opts.DisableLockOrderDetection`: disables lock order based deadlock detection.
* `Opts.DeadlockTimeout`: blocking on mutex for longer than DeadlockTimeout is considered a deadlock. ignored if negative
* `Opts.OnPotentialDeadlock`: callback for then deadlock is detected
* `Opts.MaxMapSize`: size of happens before // happens after table
* `Opts.PrintAllCurrentGoroutines`: dump stacktraces of all goroutines when inconsistent locking is detected, verbose
* `Opts.LogBuf`: where to write deadlock info/stacktraces
* `Opts.TimerPool`: controls timer pooling behavior (auto-configured based on build tags)
+30
View File
@@ -0,0 +1,30 @@
//go:build go1.24
package deadlock
import (
"unsafe"
"weak"
)
type beforeAfter struct {
before weak.Pointer[byte]
after weak.Pointer[byte]
}
// ptrFromInterface extracts the data pointer from an interface{} value.
// An interface (eface) is {type *_type, data unsafe.Pointer}; we grab the second word.
func ptrFromInterface(i interface{}) *byte {
type eface struct {
_ uintptr
data unsafe.Pointer
}
return (*byte)((*eface)(unsafe.Pointer(&i)).data)
}
func newBeforeAfter(before, after interface{}) beforeAfter {
return beforeAfter{
before: weak.Make(ptrFromInterface(before)),
after: weak.Make(ptrFromInterface(after)),
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !go1.24
package deadlock
type beforeAfter struct {
before interface{}
after interface{}
}
func newBeforeAfter(before, after interface{}) beforeAfter {
return beforeAfter{before: before, after: after}
}
+284 -127
View File
@@ -7,11 +7,24 @@ import (
"io"
"os"
"sync"
"sync/atomic"
"time"
"github.com/petermattis/goid"
)
// TimerPoolMode controls timer pooling behavior
type TimerPoolMode int
const (
// TimerPoolDefault automatically chooses based on build environment
TimerPoolDefault TimerPoolMode = iota
// TimerPoolEnabled always uses timer pooling for performance
TimerPoolEnabled
// TimerPoolDisabled disables timer pooling (required for testing/synctest)
TimerPoolDisabled
)
// Opts control how deadlock detection behaves.
// Options are supposed to be set once at a startup (say, when parsing flags).
var Opts = struct {
@@ -31,7 +44,12 @@ var Opts = struct {
MaxMapSize int
// Will dump stacktraces of all goroutines when inconsistent locking is detected.
PrintAllCurrentGoroutines bool
mu *sync.Mutex // Protects the LogBuf.
// Controls timer pooling behavior.
// TimerPoolDefault: Automatically choose based on build environment
// TimerPoolEnabled: Always use timer pooling
// TimerPoolDisabled: Never use timer pooling
TimerPool TimerPoolMode
mu *sync.Mutex // Protects the LogBuf.
// Will print deadlock info to log buffer.
LogBuf io.Writer
}{
@@ -75,7 +93,7 @@ var NewCond = sync.NewCond
// A Mutex is a drop-in replacement for sync.Mutex.
// Performs deadlock detection unless disabled in Opts.
type Mutex struct {
mu sync.Mutex
mu StandardMutex
}
// Lock locks the mutex.
@@ -104,7 +122,7 @@ func (m *Mutex) Unlock() {
// An RWMutex is a drop-in replacement for sync.RWMutex.
// Performs deadlock detection unless disabled in Opts.
type RWMutex struct {
mu sync.RWMutex
mu StandardRWMutex
}
// Lock locks rw for writing.
@@ -155,15 +173,15 @@ func (m *RWMutex) RUnlock() {
// RLocker returns a Locker interface that implements
// the Lock and Unlock methods by calling RLock and RUnlock.
func (m *RWMutex) RLocker() sync.Locker {
return (*rlocker)(m)
return m.mu.RLocker()
}
func preLock(stack []uintptr, p interface{}) {
lo.preLock(stack, p)
}
func postLock(stack []uintptr, p interface{}) {
lo.postLock(stack, p)
func postLock(stack []uintptr, buf *[stackBufSize]uintptr, p interface{}) {
lo.postLock(stack, buf, p)
}
func postUnlock(p interface{}) {
@@ -175,104 +193,180 @@ func lock(lockFn func(), ptr interface{}) {
lockFn()
return
}
stack := callers(1)
stack, buf := callers(1)
// Cache timeout before preLock so all Opts reads complete before preLock
// may call OnPotentialDeadlock. If preLock detects a problem (recursive
// lock, order violation) the goroutine may block forever in lockFn below,
// and reading Opts after preLock would race with any later Opts write.
timeout := Opts.DeadlockTimeout
preLock(stack, ptr)
if Opts.DeadlockTimeout <= 0 {
if timeout <= 0 {
lockFn()
} else {
ch := make(chan struct{})
currentID := goid.Get()
go checkDeadlock(stack, ptr, currentID, ch)
e := dw.register(stack, ptr, currentID, timeout)
lockFn()
postLock(stack, ptr)
close(ch)
dw.deregister(e)
postLock(stack, buf, ptr)
return
}
postLock(stack, ptr)
postLock(stack, buf, ptr)
}
var timersPool sync.Pool
func acquireTimer(d time.Duration) *time.Timer {
t, ok := timersPool.Get().(*time.Timer)
if ok {
_ = t.Reset(d)
return t
}
return time.NewTimer(Opts.DeadlockTimeout)
// pendingEntry tracks a goroutine that is waiting to acquire a lock. Entries are
// pooled to avoid per-lock heap allocations (goroutine stacks, channels, closures).
//
// Timer safety invariants:
// - checkFn is allocated once per entry and reused across pool cycles, so recycling
// an entry does not allocate a new closure.
// - The done flag synchronizes the callback with deregister: deregister sets done=1
// before calling Stop(), and the callback checks done before acting. Because both
// use atomic operations, the callback is guaranteed to observe done=1 if deregister
// has already run, even if the runtime already scheduled the callback.
// - An entry is only returned to the pool when timer.Stop() returns true, meaning
// the timer was successfully cancelled and the callback will never run. This prevents
// a recycled entry from being mutated by an in-flight callback.
// - When Stop() returns false (callback already firing or queued), the entry is
// intentionally leaked to GC. This only happens in the rare deadlock-timeout path.
type pendingEntry struct {
stack []uintptr
ptr interface{}
gid int64
done int32 // atomic: 0=pending, 1=acquired
timer *time.Timer
checkFn func()
}
func releaseTimer(t *time.Timer) {
if !t.Stop() {
<-t.C
}
timersPool.Put(t)
}
func checkDeadlock(stack []uintptr, ptr interface{}, currentID int64, ch <-chan struct{}) {
t := acquireTimer(Opts.DeadlockTimeout)
defer releaseTimer(t)
for {
select {
case <-t.C:
lo.mu.Lock()
prev, ok := lo.cur[ptr]
if !ok {
lo.mu.Unlock()
break // Nobody seems to be holding the lock, try again.
}
Opts.mu.Lock()
fmt.Fprintln(Opts.LogBuf, header)
fmt.Fprintln(Opts.LogBuf, "Previous place where the lock was grabbed")
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", prev.gid, ptr)
printStack(Opts.LogBuf, prev.stack)
fmt.Fprintln(Opts.LogBuf, "Have been trying to lock it again for more than", Opts.DeadlockTimeout)
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", currentID, ptr)
printStack(Opts.LogBuf, stack)
stacks := stacks()
grs := bytes.Split(stacks, []byte("\n\n"))
for _, g := range grs {
if goid.ExtractGID(g) == prev.gid {
fmt.Fprintln(Opts.LogBuf, "Here is what goroutine", prev.gid, "doing now")
Opts.LogBuf.Write(g)
fmt.Fprintln(Opts.LogBuf)
}
}
lo.other(ptr)
if Opts.PrintAllCurrentGoroutines {
fmt.Fprintln(Opts.LogBuf, "All current goroutines:")
Opts.LogBuf.Write(stacks)
}
fmt.Fprintln(Opts.LogBuf)
if buf, ok := Opts.LogBuf.(*bufio.Writer); ok {
buf.Flush()
}
Opts.mu.Unlock()
lo.mu.Unlock()
Opts.OnPotentialDeadlock()
<-ch
return
case <-ch:
func newPendingEntry() *pendingEntry {
e := &pendingEntry{}
// Capture e by pointer so the closure is stable across pool reuse, no new
// closure allocation when the entry is recycled.
e.checkFn = func() {
// If the lock was acquired (done=1), the entry may already be back in the
// pool or being reused. Bail out unconditionally.
if atomic.LoadInt32(&e.done) != 0 {
return
}
t.Reset(Opts.DeadlockTimeout)
onDeadlockTimeout(e)
}
return e
}
var pendingPool = sync.Pool{
New: func() interface{} {
return newPendingEntry()
},
}
type deadlockWatcher struct{}
var dw deadlockWatcher
func (w *deadlockWatcher) register(stack []uintptr, ptr interface{}, gid int64, timeout time.Duration) *pendingEntry {
var e *pendingEntry
if shouldDisableTimerPool() {
e = newPendingEntry()
} else {
e = pendingPool.Get().(*pendingEntry)
}
e.stack = stack
e.ptr = ptr
e.gid = gid
atomic.StoreInt32(&e.done, 0)
if e.timer == nil {
// First use (freshly allocated entry): create the AfterFunc timer.
// AfterFunc avoids the channel-drain problems of channel-based timers,
// which are especially problematic under testing/synctest.
e.timer = time.AfterFunc(timeout, e.checkFn)
} else {
// Reused from pool: the timer was previously Stop()'d successfully
// (guaranteed by deregister), so Reset is safe here.
e.timer.Reset(timeout)
}
return e
}
// deregister marks the lock as acquired and cancels the deadlock timer.
// Must be called exactly once per register call. The entry pointer is
// stack-local in lock(), so concurrent or duplicate calls cannot occur.
func (w *deadlockWatcher) deregister(e *pendingEntry) {
// Mark done BEFORE stopping the timer. The callback checks done with an
// atomic load, so even if the timer fires concurrently, the callback will
// see done=1 and return without acting.
atomic.StoreInt32(&e.done, 1)
stopped := e.timer.Stop()
// Only recycle the entry if Stop() confirmed the callback won't run.
// If Stop() returned false the callback is already executing or queued;
// recycling would race with the callback reading entry fields.
if stopped && !shouldDisableTimerPool() {
e.stack = nil
e.ptr = nil
e.gid = 0
pendingPool.Put(e)
}
}
func onDeadlockTimeout(e *pendingEntry) {
lo.mu.Lock()
holders, ok := lo.cur[e.ptr]
if !ok || len(holders) == 0 {
// Lock appears unheld (transient state, holder may have just released).
// Reschedule if the waiter is still pending. Note: this creates a new timer
// (e.timer is not updated), so if deregister runs later it will Stop() the
// original (already-fired) timer, get false, and skip pooling. The new timer's
// callback will then observe done=1 and no-op. This is safe but means the
// entry won't be recycled, acceptable since this is the rare timeout path.
lo.mu.Unlock()
if atomic.LoadInt32(&e.done) == 0 {
time.AfterFunc(Opts.DeadlockTimeout, e.checkFn)
}
return
}
Opts.mu.Lock()
fmt.Fprintln(Opts.LogBuf, header)
for _, prev := range holders {
fmt.Fprintln(Opts.LogBuf, "Previous place where the lock was grabbed")
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", prev.gid, e.ptr)
printStack(Opts.LogBuf, prev.stack)
}
fmt.Fprintln(Opts.LogBuf, "Have been trying to lock it again for more than", Opts.DeadlockTimeout)
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", e.gid, e.ptr)
printStack(Opts.LogBuf, e.stack)
stacks := stacks()
grs := bytes.Split(stacks, []byte("\n\n"))
for _, prev := range holders {
for _, g := range grs {
if goid.ExtractGID(g) == prev.gid {
fmt.Fprintln(Opts.LogBuf, "Here is what goroutine", prev.gid, "doing now")
Opts.LogBuf.Write(g)
fmt.Fprintln(Opts.LogBuf)
}
}
}
lo.other(e.ptr)
if Opts.PrintAllCurrentGoroutines {
fmt.Fprintln(Opts.LogBuf, "All current goroutines:")
Opts.LogBuf.Write(stacks)
}
fmt.Fprintln(Opts.LogBuf)
if buf, ok := Opts.LogBuf.(*bufio.Writer); ok {
buf.Flush()
}
Opts.mu.Unlock()
lo.mu.Unlock()
Opts.OnPotentialDeadlock()
}
type lockOrder struct {
mu sync.Mutex
cur map[interface{}]stackGID // stacktraces + gids for the locks currently taken.
order map[beforeAfter]ss // expected order of locks.
cur map[interface{}][]stackGID // stacktraces + gids for the locks currently taken.
order map[beforeAfter]ss // expected order of locks.
}
type stackGID struct {
stack []uintptr
gid int64
}
type beforeAfter struct {
before interface{}
after interface{}
buf *[stackBufSize]uintptr // pooled backing array; returned via releaseStackBuf in postUnlock
}
type ss struct {
@@ -284,15 +378,31 @@ var lo = newLockOrder()
func newLockOrder() *lockOrder {
return &lockOrder{
cur: map[interface{}]stackGID{},
cur: map[interface{}][]stackGID{},
order: map[beforeAfter]ss{},
}
}
func (l *lockOrder) postLock(stack []uintptr, p interface{}) {
// holdersPool recycles []stackGID slices used by lockOrder.cur to track which
// goroutines currently hold each lock. Slices are returned to the pool in
// postUnlock when a lock's holder count drops to zero, and reused in postLock
// for the next lock acquisition, avoiding a new slice allocation per mutex.
var holdersPool sync.Pool
// postLock records the current goroutine as a holder of lock p. It tries to
// reuse a pooled []stackGID slice before allocating, and stores the pooled
// stack buffer in the entry so postUnlock can release it later.
func (l *lockOrder) postLock(stack []uintptr, buf *[stackBufSize]uintptr, p interface{}) {
gid := goid.Get()
entry := stackGID{stack, gid, buf}
l.mu.Lock()
l.cur[p] = stackGID{stack, gid}
holders := l.cur[p]
if holders == nil {
if s, ok := holdersPool.Get().([]stackGID); ok {
holders = s[:0]
}
}
l.cur[p] = append(holders, entry)
l.mu.Unlock()
}
@@ -302,84 +412,131 @@ func (l *lockOrder) preLock(stack []uintptr, p interface{}) {
}
gid := goid.Get()
l.mu.Lock()
for b, bs := range l.cur {
for b, holders := range l.cur {
if b == p {
if bs.gid == gid {
for _, bs := range holders {
if bs.gid == gid {
Opts.mu.Lock()
fmt.Fprintln(Opts.LogBuf, header, "Recursive locking:")
fmt.Fprintf(Opts.LogBuf, "current goroutine %d lock %p\n", gid, b)
printStack(Opts.LogBuf, stack)
fmt.Fprintln(Opts.LogBuf, "Previous place where the lock was grabbed (same goroutine)")
printStack(Opts.LogBuf, bs.stack)
l.other(p)
if buf, ok := Opts.LogBuf.(*bufio.Writer); ok {
buf.Flush()
}
Opts.mu.Unlock()
Opts.OnPotentialDeadlock()
break
}
}
continue
}
for _, bs := range holders {
if bs.gid != gid { // We want locks taken in the same goroutine only.
continue
}
if s, ok := l.order[newBeforeAfter(p, b)]; ok {
Opts.mu.Lock()
fmt.Fprintln(Opts.LogBuf, header, "Recursive locking:")
fmt.Fprintf(Opts.LogBuf, "current goroutine %d lock %p\n", gid, b)
printStack(Opts.LogBuf, stack)
fmt.Fprintln(Opts.LogBuf, "Previous place where the lock was grabbed (same goroutine)")
fmt.Fprintln(Opts.LogBuf, header, "Inconsistent locking. saw this ordering in one goroutine:")
fmt.Fprintln(Opts.LogBuf, "happened before")
printStack(Opts.LogBuf, s.before)
fmt.Fprintln(Opts.LogBuf, "happened after")
printStack(Opts.LogBuf, s.after)
fmt.Fprintln(Opts.LogBuf, "in another goroutine: happened before")
printStack(Opts.LogBuf, bs.stack)
fmt.Fprintln(Opts.LogBuf, "happened after")
printStack(Opts.LogBuf, stack)
l.other(p)
fmt.Fprintln(Opts.LogBuf)
if buf, ok := Opts.LogBuf.(*bufio.Writer); ok {
buf.Flush()
}
Opts.mu.Unlock()
Opts.OnPotentialDeadlock()
}
continue
}
if bs.gid != gid { // We want locks taken in the same goroutine only.
continue
}
if s, ok := l.order[beforeAfter{p, b}]; ok {
Opts.mu.Lock()
fmt.Fprintln(Opts.LogBuf, header, "Inconsistent locking. saw this ordering in one goroutine:")
fmt.Fprintln(Opts.LogBuf, "happened before")
printStack(Opts.LogBuf, s.before)
fmt.Fprintln(Opts.LogBuf, "happened after")
printStack(Opts.LogBuf, s.after)
fmt.Fprintln(Opts.LogBuf, "in another goroutine: happened before")
printStack(Opts.LogBuf, bs.stack)
fmt.Fprintln(Opts.LogBuf, "happened after")
printStack(Opts.LogBuf, stack)
l.other(p)
fmt.Fprintln(Opts.LogBuf)
if buf, ok := Opts.LogBuf.(*bufio.Writer); ok {
buf.Flush()
// Copy both stacks: they're backed by pooled buffers that will be
// recycled in postUnlock, but l.order entries persist until MaxMapSize.
l.order[newBeforeAfter(b, p)] = ss{copyStack(bs.stack), copyStack(stack)}
if len(l.order) == Opts.MaxMapSize { // Reset the map to keep memory footprint bounded.
l.order = map[beforeAfter]ss{}
}
Opts.mu.Unlock()
Opts.OnPotentialDeadlock()
}
l.order[beforeAfter{b, p}] = ss{bs.stack, stack}
if len(l.order) == Opts.MaxMapSize { // Reset the map to keep memory footprint bounded.
l.order = map[beforeAfter]ss{}
}
}
l.mu.Unlock()
}
func (l *lockOrder) postUnlock(p interface{}) {
gid := goid.Get()
l.mu.Lock()
delete(l.cur, p)
holders := l.cur[p]
idx := -1
for i, h := range holders {
if h.gid == gid {
idx = i
break
}
}
if idx >= 0 {
removedBuf := holders[idx].buf
holders[idx] = holders[len(holders)-1]
holders[len(holders)-1] = stackGID{}
holders = holders[:len(holders)-1]
releaseStackBuf(removedBuf)
} else if len(holders) > 0 {
// Cross-goroutine unlock: Go permits one goroutine to Lock and a different
// goroutine to Unlock, so the unlocking gid may not match any holder entry.
// This is a rare edge case in practice, the vast majority of code unlocks
// from the same goroutine that locked. We remove an arbitrary entry to keep
// the holder count consistent with the real lock state (the lock *was*
// released, so one entry must go). The trade-off: for RWMutex with multiple
// concurrent readers we may discard the wrong reader's stack trace, making a
// future deadlock report show a slightly misleading "previous lock site".
// Detection correctness is unaffected.
removedBuf := holders[len(holders)-1].buf
holders[len(holders)-1] = stackGID{}
holders = holders[:len(holders)-1]
releaseStackBuf(removedBuf)
}
if len(holders) == 0 {
// Delete the map key so the mutex pointer is not retained, allowing GC of
// the struct it's embedded in. Recycle the backing slice via pool so the
// next postLock on any mutex can reuse it instead of allocating.
if cap(holders) > 0 {
holdersPool.Put(holders[:0])
}
delete(l.cur, p)
} else {
l.cur[p] = holders
}
l.mu.Unlock()
}
type rlocker RWMutex
func (r *rlocker) Lock() { (*RWMutex)(r).RLock() }
func (r *rlocker) Unlock() { (*RWMutex)(r).RUnlock() }
// Under lo.mu Locked.
func (l *lockOrder) other(ptr interface{}) {
empty := true
for k := range l.cur {
for k, holders := range l.cur {
if k == ptr {
continue
}
empty = false
if len(holders) > 0 {
empty = false
break
}
}
if empty {
return
}
fmt.Fprintln(Opts.LogBuf, "Other goroutines holding locks:")
for k, pp := range l.cur {
for k, holders := range l.cur {
if k == ptr {
continue
}
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", pp.gid, k)
printStack(Opts.LogBuf, pp.stack)
for _, pp := range holders {
fmt.Fprintf(Opts.LogBuf, "goroutine %v lock %p\n", pp.gid, k)
printStack(Opts.LogBuf, pp.stack)
}
}
fmt.Fprintln(Opts.LogBuf)
}
+1
View File
@@ -1,3 +1,4 @@
//go:build go1.9
// +build go1.9
package deadlock
+21
View File
@@ -0,0 +1,21 @@
package deadlock
import "sync"
// MutexImpl defines the interface for mutex implementations
type MutexImpl interface {
Lock()
Unlock()
TryLock() bool
}
// RWMutexImpl defines the interface for rwmutex implementations
type RWMutexImpl interface {
Lock()
Unlock()
RLock()
RUnlock()
TryLock() bool
TryRLock() bool
RLocker() sync.Locker
}
+32 -3
View File
@@ -13,9 +13,38 @@ import (
"sync"
)
func callers(skip int) []uintptr {
s := make([]uintptr, 50) // Most relevant context seem to appear near the top of the stack.
return s[:runtime.Callers(2+skip, s)]
const stackBufSize = 50
var stackBufPool = sync.Pool{
New: func() interface{} {
return new([stackBufSize]uintptr)
},
}
// callers returns a stack trace backed by a pooled buffer. The caller must
// eventually return buf via releaseStackBuf — typically through the
// postLock/postUnlock path which stores it in stackGID.buf.
func callers(skip int) ([]uintptr, *[stackBufSize]uintptr) {
buf := stackBufPool.Get().(*[stackBufSize]uintptr)
n := runtime.Callers(2+skip, buf[:])
return buf[:n], buf
}
// releaseStackBuf returns a pooled stack buffer obtained from callers(). Safe to
// call with nil (e.g. when the buffer was already handed off via stackGID.buf).
func releaseStackBuf(buf *[stackBufSize]uintptr) {
if buf != nil {
stackBufPool.Put(buf)
}
}
// copyStack creates an independent copy of a stack trace. Required when storing
// stacks in long-lived structures (e.g. l.order) because the originals are backed
// by pooled buffers that will be recycled in postUnlock.
func copyStack(s []uintptr) []uintptr {
c := make([]uintptr, len(s))
copy(c, s)
return c
}
func printStack(w io.Writer, stack []uintptr) {
+55
View File
@@ -0,0 +1,55 @@
//go:build deadlock_disable && !go1.18
package deadlock
import "sync"
// StandardMutex wraps sync.Mutex with no deadlock detection
type StandardMutex struct {
mu sync.Mutex
}
func (m *StandardMutex) Lock() {
m.mu.Lock()
}
func (m *StandardMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardMutex) TryLock() bool {
panic("TryLock requires Go 1.18 or later")
}
// StandardRWMutex wraps sync.RWMutex with no deadlock detection
type StandardRWMutex struct {
mu sync.RWMutex
}
func (m *StandardRWMutex) Lock() {
m.mu.Lock()
}
func (m *StandardRWMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardRWMutex) RLock() {
m.mu.RLock()
}
func (m *StandardRWMutex) RUnlock() {
m.mu.RUnlock()
}
func (m *StandardRWMutex) TryLock() bool {
panic("TryLock requires Go 1.18 or later")
}
func (m *StandardRWMutex) TryRLock() bool {
panic("TryRLock requires Go 1.18 or later")
}
func (m *StandardRWMutex) RLocker() sync.Locker {
return m.mu.RLocker()
}
+55
View File
@@ -0,0 +1,55 @@
//go:build deadlock_disable && go1.18
package deadlock
import "sync"
// StandardMutex wraps sync.Mutex with no deadlock detection
type StandardMutex struct {
mu sync.Mutex
}
func (m *StandardMutex) Lock() {
m.mu.Lock()
}
func (m *StandardMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardMutex) TryLock() bool {
return m.mu.TryLock()
}
// StandardRWMutex wraps sync.RWMutex with no deadlock detection
type StandardRWMutex struct {
mu sync.RWMutex
}
func (m *StandardRWMutex) Lock() {
m.mu.Lock()
}
func (m *StandardRWMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardRWMutex) RLock() {
m.mu.RLock()
}
func (m *StandardRWMutex) RUnlock() {
m.mu.RUnlock()
}
func (m *StandardRWMutex) TryLock() bool {
return m.mu.TryLock()
}
func (m *StandardRWMutex) TryRLock() bool {
return m.mu.TryRLock()
}
func (m *StandardRWMutex) RLocker() sync.Locker {
return m.mu.RLocker()
}
+65
View File
@@ -0,0 +1,65 @@
//go:build !goexperiment.synctest && !deadlock_synctest && !deadlock_disable && go1.18
// +build !goexperiment.synctest,!deadlock_synctest,!deadlock_disable,go1.18
package deadlock
import "sync"
// StandardMutex wraps sync.Mutex
type StandardMutex struct {
mu sync.Mutex
}
func (m *StandardMutex) Lock() {
m.mu.Lock()
}
func (m *StandardMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardMutex) TryLock() bool {
return m.mu.TryLock()
}
// StandardRWMutex wraps sync.RWMutex
type StandardRWMutex struct {
mu sync.RWMutex
}
func (m *StandardRWMutex) Lock() {
m.mu.Lock()
}
func (m *StandardRWMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardRWMutex) RLock() {
m.mu.RLock()
}
func (m *StandardRWMutex) RUnlock() {
m.mu.RUnlock()
}
func (m *StandardRWMutex) TryLock() bool {
return m.mu.TryLock()
}
func (m *StandardRWMutex) TryRLock() bool {
return m.mu.TryRLock()
}
func (m *StandardRWMutex) RLocker() sync.Locker {
return m.mu.RLocker()
}
// Default factory functions
func newStandardMutex() MutexImpl {
return &StandardMutex{}
}
func newStandardRWMutex() RWMutexImpl {
return &StandardRWMutex{}
}
+68
View File
@@ -0,0 +1,68 @@
//go:build !goexperiment.synctest && !deadlock_synctest && !deadlock_disable && !go1.18
// +build !goexperiment.synctest,!deadlock_synctest,!deadlock_disable,!go1.18
package deadlock
import "sync"
// StandardMutex wraps sync.Mutex
type StandardMutex struct {
mu sync.Mutex
}
func (m *StandardMutex) Lock() {
m.mu.Lock()
}
func (m *StandardMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardMutex) TryLock() bool {
// TryLock is not available before Go 1.18
panic("TryLock requires Go 1.18 or later")
}
// StandardRWMutex wraps sync.RWMutex
type StandardRWMutex struct {
mu sync.RWMutex
}
func (m *StandardRWMutex) Lock() {
m.mu.Lock()
}
func (m *StandardRWMutex) Unlock() {
m.mu.Unlock()
}
func (m *StandardRWMutex) RLock() {
m.mu.RLock()
}
func (m *StandardRWMutex) RUnlock() {
m.mu.RUnlock()
}
func (m *StandardRWMutex) TryLock() bool {
// TryLock is not available before Go 1.18
panic("TryLock requires Go 1.18 or later")
}
func (m *StandardRWMutex) TryRLock() bool {
// TryRLock is not available before Go 1.18
panic("TryRLock requires Go 1.18 or later")
}
func (m *StandardRWMutex) RLocker() sync.Locker {
return m.mu.RLocker()
}
// Default factory functions
func newStandardMutex() MutexImpl {
return &StandardMutex{}
}
func newStandardRWMutex() RWMutexImpl {
return &StandardRWMutex{}
}
+234
View File
@@ -0,0 +1,234 @@
//go:build (goexperiment.synctest || deadlock_synctest) && !deadlock_disable
package deadlock
import (
"sync"
"sync/atomic"
)
// ChannelMutex implements MutexImpl using channels for synctest compatibility
type ChannelMutex struct {
ch chan struct{}
locked int32 // atomic
once sync.Once
}
func (m *ChannelMutex) init() {
m.once.Do(func() {
m.ch = make(chan struct{}, 1)
})
}
func (m *ChannelMutex) Lock() {
m.init()
m.ch <- struct{}{}
atomic.StoreInt32(&m.locked, 1)
}
func (m *ChannelMutex) Unlock() {
if atomic.LoadInt32(&m.locked) == 0 {
panic("unlock of unlocked mutex")
}
atomic.StoreInt32(&m.locked, 0)
<-m.ch
}
func (m *ChannelMutex) TryLock() bool {
m.init()
select {
case m.ch <- struct{}{}:
atomic.StoreInt32(&m.locked, 1)
return true
default:
return false
}
}
// ChannelRWMutex implements RWMutexImpl with writer priority using channels.
// Implements writer-priority semantics using the "Third Readers-Writers Problem" solution.
type ChannelRWMutex struct {
resource chan struct{} // The actual resource being protected
readTry chan struct{} // Gate that closes when writers waiting
rmutex chan struct{} // Protects readCount modifications
wmutex chan struct{} // Protects writeCount modifications
readCount int32 // Number of active readers
writeCount int32 // Number of waiting/active writers
once sync.Once
}
func (m *ChannelRWMutex) init() {
m.once.Do(func() {
m.resource = make(chan struct{}, 1)
m.readTry = make(chan struct{}, 1)
m.rmutex = make(chan struct{}, 1)
m.wmutex = make(chan struct{}, 1)
// Initially, all semaphores are "released" (have a token)
m.resource <- struct{}{}
m.readTry <- struct{}{}
m.rmutex <- struct{}{}
m.wmutex <- struct{}{}
})
}
func (m *ChannelRWMutex) Lock() {
m.init()
// Protect writeCount modification
<-m.wmutex
count := atomic.AddInt32(&m.writeCount, 1)
if count == 1 {
// First writer: close the gate to block new readers
<-m.readTry
}
m.wmutex <- struct{}{} // Release wmutex
// Acquire the resource (wait for existing readers to finish)
<-m.resource
}
func (m *ChannelRWMutex) Unlock() {
// Release the resource
m.resource <- struct{}{}
// Protect writeCount modification
<-m.wmutex
count := atomic.AddInt32(&m.writeCount, -1)
if count == 0 {
// Last writer: reopen the gate for readers
m.readTry <- struct{}{}
}
m.wmutex <- struct{}{}
}
func (m *ChannelRWMutex) RLock() {
m.init()
// Wait at the gate (blocks if writers are waiting)
<-m.readTry
// Protect readCount modification
<-m.rmutex
count := atomic.AddInt32(&m.readCount, 1)
if count == 1 {
// First reader: acquire the resource to block writers
<-m.resource
}
m.rmutex <- struct{}{} // Release rmutex
// Release the gate so other readers can pass
m.readTry <- struct{}{}
}
func (m *ChannelRWMutex) RUnlock() {
<-m.rmutex
count := atomic.AddInt32(&m.readCount, -1)
if count < 0 {
m.rmutex <- struct{}{}
panic("RUnlock of unlocked RWMutex")
}
if count == 0 {
// Last reader: release the resource for writers
m.resource <- struct{}{}
}
m.rmutex <- struct{}{}
}
func (m *ChannelRWMutex) TryLock() bool {
m.init()
// Try to acquire wmutex
select {
case <-m.wmutex:
default:
return false
}
count := atomic.AddInt32(&m.writeCount, 1)
if count == 1 {
// First writer: try to close gate
select {
case <-m.readTry:
default:
// Failed, rollback
atomic.AddInt32(&m.writeCount, -1)
m.wmutex <- struct{}{}
return false
}
}
m.wmutex <- struct{}{}
// Try to acquire resource
select {
case <-m.resource:
return true
default:
// Failed, rollback writer count
<-m.wmutex
count = atomic.AddInt32(&m.writeCount, -1)
if count == 0 {
m.readTry <- struct{}{}
}
m.wmutex <- struct{}{}
return false
}
}
func (m *ChannelRWMutex) TryRLock() bool {
m.init()
// Try to pass through the gate
select {
case <-m.readTry:
default:
return false
}
// Try to acquire rmutex
select {
case <-m.rmutex:
default:
// Failed, release gate
m.readTry <- struct{}{}
return false
}
count := atomic.AddInt32(&m.readCount, 1)
if count == 1 {
// First reader: try to acquire resource
select {
case <-m.resource:
default:
// Failed, rollback
atomic.AddInt32(&m.readCount, -1)
m.rmutex <- struct{}{}
m.readTry <- struct{}{}
return false
}
}
m.rmutex <- struct{}{}
m.readTry <- struct{}{}
return true
}
func (m *ChannelRWMutex) RLocker() sync.Locker {
return (*channelRLocker)(m)
}
type channelRLocker ChannelRWMutex
func (r *channelRLocker) Lock() { (*ChannelRWMutex)(r).RLock() }
func (r *channelRLocker) Unlock() { (*ChannelRWMutex)(r).RUnlock() }
// Factory functions for synctest
func newChannelMutex() MutexImpl {
return &ChannelMutex{
ch: make(chan struct{}, 1),
}
}
func newChannelRWMutex() RWMutexImpl {
m := &ChannelRWMutex{}
m.init()
return m
}
// Type aliases to override the standard mutex types for synctest
type StandardMutex = ChannelMutex
type StandardRWMutex = ChannelRWMutex
+18
View File
@@ -0,0 +1,18 @@
//go:build !goexperiment.synctest && !deadlock_synctest && !deadlock_disable && !go1.25
package deadlock
// shouldDisableTimerPool determines if timer pooling should be disabled
// In normal builds, timer pooling is enabled by default for performance
func shouldDisableTimerPool() bool {
switch Opts.TimerPool {
case TimerPoolDefault:
return false // Default: enable timer pooling for performance
case TimerPoolEnabled:
return false
case TimerPoolDisabled:
return true
default:
return false
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build deadlock_disable
package deadlock
// shouldDisableTimerPool always returns true when deadlock detection is disabled
// since there's no timer pool or deadlock detection happening anyway
func shouldDisableTimerPool() bool {
return true
}
+18
View File
@@ -0,0 +1,18 @@
//go:build !goexperiment.synctest && !deadlock_synctest && !deadlock_disable && go1.25
package deadlock
// shouldDisableTimerPool determines if timer/entry pooling should be disabled.
// In Go 1.25, pooling is enabled by default for performance.
func shouldDisableTimerPool() bool {
switch Opts.TimerPool {
case TimerPoolDefault:
return false // Default: enable timer pooling for performance
case TimerPoolEnabled:
return false
case TimerPoolDisabled:
return true
default:
return false
}
}
+18
View File
@@ -0,0 +1,18 @@
//go:build (goexperiment.synctest || deadlock_synctest) && !deadlock_disable
package deadlock
// shouldDisableTimerPool determines if timer pooling should be disabled
// In synctest builds, timer pooling is disabled by default to avoid cross-bubble issues
func shouldDisableTimerPool() bool {
switch Opts.TimerPool {
case TimerPoolDefault:
return true // Default: disable timer pooling for synctest compatibility
case TimerPoolEnabled:
return false
case TimerPoolDisabled:
return true
default:
return true
}
}
+7 -3
View File
@@ -1,3 +1,4 @@
//go:build go1.18
// +build go1.18
package deadlock
@@ -27,13 +28,16 @@ func trylock(lockFn func() bool, ptr interface{}) bool {
if Opts.Disable {
return lockFn()
}
stack := callers(1)
stack, buf := callers(1)
preLock(stack, ptr)
ret := lockFn()
if ret {
postLock(stack, ptr)
postLock(stack, buf, ptr)
} else {
// TryLock failed: the stack won't be stored in stackGID.buf (postLock is
// skipped), so we must release the pooled buffer directly to avoid a leak.
releaseStackBuf(buf)
postUnlock(ptr)
}
}
return ret
}
+1 -1
View File
@@ -165,7 +165,7 @@ github.com/samber/lo
# github.com/sanity-io/litter v1.5.8
## explicit; go 1.16
github.com/sanity-io/litter
# github.com/sasha-s/go-deadlock v0.3.6
# github.com/sasha-s/go-deadlock v0.3.9
## explicit
github.com/sasha-s/go-deadlock
# github.com/sirupsen/logrus v1.9.3