Locking
This package provides a hybrid approach to lock analysis that combines static lock checking with dynamic deadlock detection. It serves as a drop-in replacement for sync.Mutex and sync.RWMutex while enabling both compile-time and runtime lock analysis.
Design Philosophy
The locking package addresses the challenge of comprehensive lock analysis in Go applications by:
- Static Analysis Compatibility: Uses type aliases in the default build to ensure full compatibility with static lock checkers like
checklocks
- Dynamic Deadlock Detection: Provides runtime deadlock detection through build tags with zero performance overhead in production
- Gradual Migration: Allows incremental replacement of
sync types throughout the codebase
- Lock State Assertions: Enables runtime verification of lock states for testing and debugging
Architecture
The package uses build tags to switch between two implementations:
- Default build (
!deadlock): Type aliases to sync.Mutex/RWMutex for zero overhead
- Debug build (
deadlock): Wraps github.com/linkdata/deadlock for runtime detection
Usage Examples
Basic Usage (Drop-in Replacement)
import "github.com/DataDog/dd-trace-go/v2/internal/locking"
type SafeCounter struct {
mu locking.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
// RWMutex usage
type Cache struct {
mu locking.RWMutex
data map[string]interface{}
}
func (c *Cache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
func (c *Cache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
Lock State Assertions
The assert package provides runtime verification of lock states using TryLock-based checks. While static analysis tools like checklocks provide compile-time guarantees through annotations, runtime assertions offer additional guarantees that static analysis cannot provide.
Available Assertion Functions
MutexLocked(m TryLocker) - Panics if mutex is NOT locked (works for both Mutex and RWMutex write locks)
RWMutexRLocked(m TryRLocker) - Panics if RWMutex is NOT read-locked (passes for both RLock and Lock)
Key Distinctions:
MutexLocked() uses TryLock() to verify exclusive lock (Mutex.Lock or RWMutex.Lock)
RWMutexRLocked() uses TryRLock() to verify read access is blocked (either RLock or Lock held)
Implementation: All assertions use TryLock-based verification:
- TryLock succeeds (returns true) → lock was NOT held → panic (assertion fails)
- TryLock fails (returns false) → lock IS held → no panic (assertion passes)
This approach works consistently without external dependencies in default and debug builds.
Note: When building with deadlock tag, these assertion functions become no-ops. The go-deadlock library provides comprehensive runtime deadlock detection, and attempting to use TryLock on already-held locks triggers false positives in go-deadlock's recursive locking detection. In deadlock builds, rely on go-deadlock's built-in verification instead of these assertions.
Static vs Runtime Analysis
Static Analysis (checklocks):
- Uses annotations like
// +checklocks:mu to verify lock requirements at compile time
- Cannot detect runtime-dependent lock patterns or complex conditional locking
- May miss violations in dynamically determined code paths
- Excellent for enforcing consistent locking patterns across large codebases
Runtime Assertions (TryLock-based):
- Verify actual lock state during program execution
- Catch violations that static analysis might miss
- Essential for testing complex synchronization scenarios
- Provide definitive verification of lock invariants
Runtime Assertion Examples
import "github.com/DataDog/dd-trace-go/v2/internal/locking/assert"
type SafeCounter struct {
mu locking.Mutex
count int
}
// +checklocks:c.mu
func (c *SafeCounter) unsafeIncrement() {
// Static checker ensures mu is held when this method is called
// Runtime assertion provides additional guarantee
assert.MutexLocked(&c.mu)
c.count++
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.unsafeIncrement() // Both static and runtime checks validate this
}
// Complex scenarios where runtime assertions shine
func (c *SafeCounter) ConditionalIncrement(condition bool) {
if condition {
c.mu.Lock()
defer c.mu.Unlock()
}
// Static analysis cannot verify this pattern
// Runtime assertion ensures safety regardless of condition
if condition {
assert.MutexLocked(&c.mu)
c.count++
}
}
// RWMutex assertions for read/write differentiation
type Cache struct {
mu locking.RWMutex
data map[string]interface{}
}
// +checklocksread:c.mu
func (c *Cache) unsafeGet(key string) interface{} {
// Verify either read or write lock is held
assert.RWMutexRLocked(&c.mu) // Passes for both RLock and Lock
return c.data[key]
}
// +checklocks:c.mu
func (c *Cache) unsafeSet(key string, value interface{}) {
// Verify write lock is held
assert.RWMutexLocked(&c.mu) // Only passes for Lock, not RLock
c.data[key] = value
}
When to Use Runtime Assertions
Runtime assertions are particularly valuable in:
- Test Scenarios: Verify lock invariants during unit and integration tests
- Complex Lock Patterns: Validate conditional or dynamically determined locking
- Debugging: Identify lock-related issues during development
- Critical Sections: Ensure absolute certainty about lock state in sensitive code
- Migration Verification: Confirm correctness when refactoring locking code
Testing with Deadlock Detection
Enable deadlock detection during testing:
# Run tests with deadlock detection
go test -v -timeout=300s -tags=deadlock ./...
# Run specific tracer tests with deadlock detection
go test -v -timeout=300s -tags=debug,deadlock ./ddtrace/tracer
# Run with both debug and deadlock tags for comprehensive testing
go test -v -timeout=300s -tags=debug,deadlock ./internal/...
Implementation Checklist
Integration with Static Analysis
The package is designed to work seamlessly with static lock checkers like checklocks. The type aliases ensure full compatibility with static analysis tools:
type SafeCounter struct {
// +checklocks:mu
count int
mu locking.Mutex
}
// +checklocks:c.mu
func (c *SafeCounter) unsafeIncrement() {
// Static checker will verify mu is held when this is called
c.count++
}
// +checklocksacquire:c.mu
func (c *SafeCounter) SafeIncrement() {
c.mu.Lock()
defer c.mu.Unlock()
c.unsafeIncrement() // Static checker validates this is safe
}
// Combining static and runtime checks for maximum safety
func (c *SafeCounter) IncrementWithFullVerification() {
c.mu.Lock()
defer c.mu.Unlock()
// Static analysis ensures this is safe at compile time
// Runtime assertion provides additional guarantee
assert.MutexLocked(&c.mu)
c.unsafeIncrement()
}
Checklocks Annotation Compatibility
The locking package supports all checklocks annotations:
// +checklocks:fieldname - Field requires associated mutex to be held
// +checklocksread:fieldname - Field requires read or write lock
// +checklocksacquire:mutexname - Function acquires the mutex
// +checklocksrelease:mutexname - Function releases the mutex
// +checklocksignore - Disable checking for specific code sections
This ensures a gradual migration path where static analysis continues to work while adding runtime verification capabilities.
Testing Scenarios
Unit Tests
# Test without deadlock detection (fast)
go test ./internal/locking
# Test with deadlock detection (comprehensive)
go test -tags=deadlock ./internal/locking
Integration Tests
# Test tracer components with deadlock detection
go test -v -timeout=300s -tags=deadlock ./ddtrace/tracer
# Test all components with maximum detection
go test -v -timeout=300s -tags=debug,deadlock ./...
Build Tag Testing Strategy
Test Coverage Matrix
| Build Configuration |
Test File |
Purpose |
Default (!deadlock && !debug) |
assert_sync_test.go |
TryLock assertions with sync.Mutex type aliases |
Debug (debug && !deadlock) |
assert_debug_test.go |
TryLock assertions in debug mode |
Deadlock (deadlock) |
assert_test.go |
Assertions with go-deadlock wrapper |
Debug+Deadlock (debug && deadlock) |
assert_debug_deadlock_test.go |
Combined debug and deadlock features |
CI Integration
The CI pipeline tests with:
BUILD_TAGS=debug - Tests debug-only configuration
BUILD_TAGS=debug,deadlock - Tests combined configuration
Running Tests Locally
Test all configurations:
# Default (sync.Mutex type aliases)
go test ./internal/locking/assert
# Debug build
go test -tags=debug ./internal/locking/assert
# Deadlock build
go test -tags=deadlock ./internal/locking/assert
# Debug + Deadlock
go test -tags=debug,deadlock ./internal/locking/assert
# With race detection
go test -race -tags=debug ./internal/locking/assert
go test -race -tags=debug,deadlock ./internal/locking/assert
- Zero Overhead: In the default build, type aliases ensure no performance penalty
- Debugging Mode: Deadlock detection adds runtime overhead, use only in testing
- Memory Usage: Default build has identical memory footprint to
sync types
- Static Analysis: Full compatibility with existing static analysis tools
Dependencies
Troubleshooting
Common Issues
- Build fails with deadlock tag: Ensure all dependencies are available
- Static checker warnings: Verify type aliases are used correctly
- Performance regression: Check that deadlock detection is not enabled in production builds
Debug Commands
# Verify build tags are working correctly
go build -tags=deadlock -v ./internal/locking
# Check for import conflicts
go mod why github.com/linkdata/deadlock
# Validate lock assertions
go test -v -run TestLockAssertions ./internal/locking/assert