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? 😉