Why Copying Go Lock Is a Bad Idea
When working with Go structs that contain mutexes, you might be tempted to copy them. But is it safe to copy a struct with a sync.Mutex? Let’s explore why copying structs with locks can lead to unexpected behavior and subtle bugs.
Let’s create a calculator!
Our calculator should perform basic operations like addition and save the history of all operations. It must be thread-safe since many users will use it concurrently - we expect it to be quite popular!
func NewCalculator() Calculator {
return Calculator{
history: make([]string, 0),
}
}
type Calculator struct {
lock sync.Mutex
history []string
}
func (c *Calculator) Add(x, y int) int {
res := x+y
c.lock.Lock()
defer c.lock.Unlock()
time.Sleep(5 * time.Second) // yes, it's a slow calculator. Here we are simulating a long calculation.
c.history = append(c.history, fmt.Sprintf("%d + %d = %d", x, y, res))
return res
}
func (c *Calculator) GetHistory() []string {
c.lock.Lock()
defer c.lock.Unlock()
result := make([]string, len(c.history))
copy(result, c.history)
return result
}
The calculator above does everything we need:
- It can sum two numbers
- It can return the history of operations
It’s time to use it. We present the calculator to Alice and Bob and ask them for feedback:
func main() {
// this is a calculator for Alice
calculatorForAlice := NewCalculator()
wg := sync.WaitGroup{}
// Alice starts calculating
wg.Add(1)
go func() {
defer wg.Done()
calculatorForAlice.Add(1, 2)
}()
time.Sleep(2 * time.Second) // wait for the locking to happen
// Bob is reluctant to share the calculator history with Alice
// so he creates a copy of the calculator
calculatorForBob := calculatorForAlice
// Bob starts calculating
wg.Add(1)
go func() {
defer wg.Done()
calculatorForBob.Add(3, 4)
}()
// wait for both Alice and Bob to finish calculating
wg.Wait()
// print the calculator history for both Alice and Bob
fmt.Println(calculatorForAlice.GetHistory())
fmt.Println(calculatorForBob.GetHistory())
}
Bob is quite private and doesn’t want to share his calculation history with Alice. The code above:
- Creates the calculator for Alice
- Alice starts calculating
- Copies the calculator for Bob
- Bob starts calculating
Then we wait for everyone to finish their calculations and display the history of operations.
The Problem
When we run the calculator, we get:
fatal error: all goroutines are asleep - deadlock!
Here’s the full trace:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x140000100e0?, 0x0?)
/usr/local/go/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x140000100e0)
/usr/local/go/src/sync/waitgroup.go:206 +0x1b4
main.main()
/calculator.go:39 +0x238
goroutine 5 [sync.Mutex.Lock]:
internal/sync.runtime_SemacquireMutex(0x0?, 0x0?, 0x0?)
/usr/local/go/src/runtime/sema.go:95 +0x28
internal/sync.(*Mutex).lockSlow(0x1400010a0a0)
/usr/local/go/src/internal/sync/mutex.go:149 +0x330
internal/sync.(*Mutex).Lock(0x1400010a0a0)
/usr/local/go/src/internal/sync/mutex.go:70 +0x8c
sync.(*Mutex).Lock(0x1400010a0a0)
/usr/local/go/src/sync/mutex.go:46 +0x28
main.(*Calculator).Add(0x1400010a0a0, 0x3, 0x4)
/calculator.go:60 +0x48
main.main.func2()
/calculator.go:35 +0x78
created by main.main in goroutine 1
/calculator.go:33 +0x230
Why did this happen? We copied the calculator, so they should be separate with their own locks and histories. What could go wrong?
Understanding the State
A mutex in Go looks like this , and it in turn uses the internal implementation shown here :
// A Mutex is a mutual exclusion lock.
//
// See package [sync.Mutex] documentation.
type Mutex struct {
state int32
sema uint32
}
So, it’s just a struct that has an internal state, which consists of:
- the state of the mutex - locked/unlocked
- the sema - the semaphore, which controls the locking lifecycle
When Bob copied the Calculator, that also copied the Mutex located inside. At the moment he did it, the lock was already acquired, so the mutex was in the locked state. When Alice finished her calculation and added an item to the calculation history, she released the lock on her original mutex. However, Bob’s copy has a separate mutex with its own internal state (including the semaphore), and since it was copied in a locked state, Bob’s attempt to acquire the lock will wait forever - hence the deadlock.
Even if the mutex wasn’t locked at the time of copying, copying mutexes is still incorrect because the semaphore (
sema) is tied to the runtime and won’t function properly in a copied mutex. In this case the bugs are going to be more subtle.
Detection
You can use two ways to detect copying the lock:
- go vet
- go test -race
go vet
This tool contains a set of checks, and one of them is copylocks. The usage is as simple as:
go vet -copylocks
# somepackage
# [somepackage]
./calculator.go:30:22: assignment copies lock value to calculatorForBob: somepackage.Calculator contains sync.Mutex
or you can just run go vet
go test -race
The race detector can help identify issues, though in this specific case, the deadlock will occur before a data race can be detected. However, go test -race is still valuable for catching other concurrency issues. When testing code that copies structs with mutexes, you might see warnings like:
go test -race
==================
WARNING: DATA RACE
Read at 0x00c0000741c0 by goroutine 6:
somepackage.RunCalculator()
/calculator.go:30 +0x15c
somepackage.TestDeadlock()
/calculator_test.go:8 +0x20
testing.tRunner()
/usr/local/go/src/testing/testing.go:1934 +0x164
testing.(*T).Run.gowrap1()
/usr/local/go/src/testing/testing.go:1997 +0x3c
Note: In our specific example, the deadlock occurs first, but the race detector is still a valuable tool for detecting other concurrency issues in your code.
Conclusion
The key takeaway is simple: never copy structs that contain mutexes or other sync primitives. Copying a mutex copies its internal state, which can lead to deadlocks and data races.
There are many such places, like our Calculator, that have mutexes. It’s important to be aware of them; otherwise, you can get into trouble sooner or later. In the example in this post, I deliberately added a sleep to wait for the lock to be acquired but not released yet. In a real scenario, such a situation can happen anytime, depending on how frequently the code is used, by how many goroutines, etc.
The good news is that you can catch these issues early. Always run go vet on your code (it includes the copylocks check by default) or other analyzers, and use go test -race to detect data races during testing.
Examples of the structs you don’t want to copy:
Most of these mutexes guard connections, and it’s best to avoid copying them. That’s why the constructors of the structs above return pointers to the structs. As a best practice, always use pointers for structs that contain mutexes. One way to fix the Calculator is to return a pointer:
func NewCalculator() *Calculator {
return &Calculator{
history: make([]string, 0),
}
}
Now both Alice and Bob are happy:
go run calculator.go
Alice finished calculating
Bob finished calculating
[1 + 2 = 3 3 + 4 = 7]
[1 + 2 = 3 3 + 4 = 7]
Except that Alice still sees Bob’s calculations despite Bob’s hard attempts to hide them, but that’s another story. Did you catch any other issues? 😉