Using Mutex
In the previous lab, we learned about race conditions that occur when multiple goroutines access shared data concurrently. Now, we'll explore how to use mutexes to protect shared data and prevent these race conditions.
What is a Mutex?#
A mutex (mutual exclusion) is a synchronization primitive that provides a way to ensure that only one goroutine can access a particular resource at a time. The sync package in Go provides two types of mutexes:
sync.Mutex: A basic mutex that can be locked and unlockedsync.RWMutex: A reader/writer mutex that allows multiple readers but only one writer
Using a Basic Mutex#
Here's how to use a basic mutex:
package mainimport (string">"fmt"string">"sync")var (msg stringwg sync.WaitGroupmutex sync.Mutex)func updateMessage(s string) {defer wg.Done()string">"comment">// Lock the mutex before accessing shared datamutex.Lock()msg = sstring">"comment">// Unlock the mutex when donemutex.Unlock()}func main() {msg = string">"Hello, world!"wg.Add(2)go updateMessage(string">"Hello, one")go updateMessage(string">"Hello, two")wg.Wait()fmt.Println(msg)}
In this updated version:
- We create a mutex using
sync.Mutex - Before accessing the shared variable
msg, we callmutex.Lock() - After we're done with the access, we call
mutex.Unlock() - This ensures that only one goroutine can update
msgat a time
Mutex Best Practices#
1. Always Unlock After Locking#
It's essential to ensure that for every call to Lock(), there's a corresponding call to Unlock(). A common pattern is to use defer to guarantee unlocking:
func updateMessage(s string) {defer wg.Done()mutex.Lock()defer mutex.Unlock() string">"comment">// This guarantees unlocking even if the function panicsmsg = s}
2. Keep Lock Sections Small#
To maximize concurrency, keep the code between Lock() and Unlock() as small as possible:
string">"comment">// Good: Lock only what needs protectionmutex.Lock()sharedVariable += 1mutex.Unlock()string">"comment">// Process non-shared data without the locklocalData := process(someData)string">"comment">// Bad: Locking more than necessarymutex.Lock()sharedVariable += 1localData := process(someData) string">"comment">// This doesn't need the lockmutex.Unlock()
3. Be Aware of Deadlocks#
Improper use of mutexes can lead to deadlocks. A deadlock occurs when two or more goroutines are waiting for each other to release a resource, resulting in all of them being blocked forever.
Real-World Example: Bank Balance#
Let's fix our bank balance example from the previous lab using a mutex:
package mainimport (string">"fmt"string">"sync")func main() {string">"comment">// Variable for bank balancevar bankBalance intvar balance sync.Mutexstring">"comment">// Print starting balancefmt.Printf(string">"Initial account balance: $%d.00\n", bankBalance)string">"comment">// Define weekly revenueincomes := []int{500, 10, 50, 100}var wg sync.WaitGroupwg.Add(len(incomes))string">"comment">// Loop through 52 weeks and add incomefor i, income := range incomes {go func(i int, income int) {defer wg.Done()for week := 1; week <= 52; week++ {string">"comment">// Lock the mutex before accessing bankBalancebalance.Lock()temp := bankBalancetemp += incomebankBalance = tempbalance.Unlock()fmt.Printf(string">"On week %d, you earned $%d.00 from source %d\n",week, income, i)}}(i, income)}wg.Wait()string">"comment">// Print final balancefmt.Printf(string">"Final bank balance: $%d.00\n", bankBalance)}
In this version:
- We create a mutex
balanceto protect access tobankBalance - We lock the mutex before reading or writing
bankBalance - We unlock the mutex after we're done with the access
- This ensures that only one goroutine can update
bankBalanceat a time
Testing the Fixed Code#
We can verify that our mutex-protected code doesn't have race conditions by running it with the race detector:
go run -race .
No race conditions should be reported, confirming that our code is now safe for concurrent execution.
Reader/Writer Mutex (RWMutex)#
For scenarios where you have many readers and few writers, the sync.RWMutex can provide better performance:
package mainimport (string">"fmt"string">"sync"string">"time")type BankAccount struct {balance intmutex sync.RWMutex}func (b *BankAccount) Balance() int {b.mutex.RLock() string">"comment">// Multiple readers can hold the read lockdefer b.mutex.RUnlock()return b.balance}func (b *BankAccount) Deposit(amount int) {b.mutex.Lock() string">"comment">// Only one writer can hold the write lockdefer b.mutex.Unlock()b.balance += amount}func main() {account := &BankAccount{}var wg sync.WaitGroupstring">"comment">// Launch 5 writers (deposits)for i := 0; i < 5; i++ {wg.Add(1)go func(amount int) {defer wg.Done()account.Deposit(amount)}(i * 100)}string">"comment">// Launch 10 readers (balance checks)for i := 0; i < 10; i++ {wg.Add(1)go func() {defer wg.Done()balance := account.Balance()fmt.Printf(string">"Balance: $%d.00\n", balance)}()}wg.Wait()fmt.Printf(string">"Final balance: $%d.00\n", account.Balance())}
In this example:
- We use
RLock()andRUnlock()for read-only operations - We use
Lock()andUnlock()for write operations - Multiple goroutines can read the balance simultaneously
- Only one goroutine can deposit at a time, and no reads can happen during a deposit
Common Mistakes with Mutexes#
1. Forgetting to Unlock#
Forgetting to unlock a mutex after locking it will cause any other goroutine that tries to acquire the lock to block indefinitely.
2. Copying a Mutex#
Mutexes should not be copied after their first use. Always use pointers to pass mutexes around:
string">"comment">// Good: Pass mutex by pointerfunc process(m *sync.Mutex) {m.Lock()defer m.Unlock()string">"comment">// Process data}string">"comment">// Bad: Copy mutex (will not work correctly)func process(m sync.Mutex) {m.Lock()defer m.Unlock()string">"comment">// Process data}
3. Holding a Lock Too Long#
Holding a lock for longer than necessary reduces concurrency:
string">"comment">// Bad: Lock held during I/Omutex.Lock()result := complexCalculation() string">"comment">// CPU-bound, OKfmt.Println(result) string">"comment">// I/O, don't hold lockmutex.Unlock()string">"comment">// Good: Release lock before I/Omutex.Lock()result := complexCalculation()mutex.Unlock()fmt.Println(result)
Summary#
- Mutexes provide a way to ensure exclusive access to shared resources
- Use
mutex.Lock()andmutex.Unlock()to protect critical sections - Always unlock a mutex after locking it, ideally using
defer - Keep the locked section as small as possible for better concurrency
- Use
sync.RWMutexwhen you have many more reads than writes - Be careful to avoid deadlocks, copied mutexes, and long lock durations
Using mutexes is one way to handle shared data in concurrent programs. In the next lab, we'll explore the Producer-Consumer pattern, a common concurrent programming pattern that uses channels for communication between goroutines.