Youssef Ameachaq's Blog

Youssef Ameachaq

Understanding Mutex in Go


Concurrency is a powerful feature in Go, but managing shared resources can lead to race conditions—situations where multiple goroutines access and modify the same data concurrently, causing unexpected behavior. To handle this safely, Go provides the Mutex (short for “mutual exclusion”) in the sync package. This article will guide you through the basics of Mutex, its use cases, and practical examples.


What is a Mutex?

A Mutex is a synchronization primitive that ensures only one goroutine can access a critical section of code or a shared resource at a time. This helps avoid race conditions.

Key Methods of sync.Mutex

  1. Lock(): Acquires the lock. If another goroutine has the lock, the calling goroutine will wait until the lock is released.
  2. Unlock(): Releases the lock, allowing other goroutines to acquire it.

Example: Bank Account Without Mutex

Here’s an example of how a race condition can occur:

package main

import (
	"fmt"
	"time"
)

var balance int

func deposit(amount int) {
	for i := 0; i < amount; i++ {
		balance++ // Increment balance
	}
}

func main() {
	balance = 0
	go deposit(1000) // Goroutine 1
	go deposit(1000) // Goroutine 2

	time.Sleep(1 * time.Second) // Wait for goroutines to finish
	fmt.Println("Final Balance:", balance) // May not be 2000!
}

Output

The final balance may not be 2000 due to concurrent writes to the balance variable.


Fixing the Race Condition With Mutex

Using a sync.Mutex, we can ensure that only one goroutine updates the balance at a time:

package main

import (
	"fmt"
	"sync"
	"time"
)

var (
	balance int
	mu      sync.Mutex
)

func deposit(amount int, wg *sync.WaitGroup) {
	defer wg.Done()

	mu.Lock()   // Lock the mutex
	for i := 0; i < amount; i++ {
		balance++ // Safely update balance
	}
	mu.Unlock() // Unlock the mutex
}

func main() {
	balance = 0
	var wg sync.WaitGroup

	wg.Add(2)
	go deposit(1000, &wg) // Goroutine 1
	go deposit(1000, &wg) // Goroutine 2

	wg.Wait() // Wait for both goroutines to finish
	fmt.Println("Final Balance:", balance) // Always 2000
}

Explanation


Embedding Mutex in a Struct

Embedding a Mutex in a struct is a common pattern for encapsulating synchronization logic with the data it protects.

Example: Thread-Safe Counter

package main

import (
	"fmt"
	"sync"
)

// Counter struct with embedded Mutex
type Counter struct {
	mu    sync.Mutex
	value int
}

// Increment safely increments the counter
func (c *Counter) Increment() {
	c.mu.Lock()
	c.value++
	c.mu.Unlock()
}

// GetValue safely retrieves the current value
func (c *Counter) GetValue() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

func main() {
	counter := &Counter{}
	var wg sync.WaitGroup

	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Increment()
		}()
	}

	wg.Wait()
	fmt.Println("Final Counter Value:", counter.GetValue()) // Always 100
}

Benefits


Advanced Usage: Read-Write Mutex

For scenarios with multiple readers and fewer writers, sync.RWMutex can be more efficient. It allows multiple readers or a single writer at a time.

Example: Thread-Safe Cache

package main

import (
	"fmt"
	"sync"
)

// SafeCache is a thread-safe key-value store
type SafeCache struct {
	mu    sync.RWMutex
	store map[string]string
}

// Put stores a key-value pair in the cache
func (c *SafeCache) Put(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.store[key] = value
}

// Get retrieves a value by key
func (c *SafeCache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	value, exists := c.store[key]
	return value, exists
}

func main() {
	cache := &SafeCache{store: make(map[string]string)}
	var wg sync.WaitGroup

	// Writer goroutine
	wg.Add(1)
	go func() {
		defer wg.Done()
		cache.Put("greeting", "Hello, World!")
	}()

	// Reader goroutines
	wg.Add(1)
	go func() {
		defer wg.Done()
		if value, exists := cache.Get("greeting"); exists {
			fmt.Println("Cache Value:", value)
		} else {
			fmt.Println("Key not found!")
		}
	}()

	wg.Wait()
}

Key Takeaways

  1. Mutex is essential for preventing race conditions in concurrent programming.
  2. Embedding a Mutex in a struct helps encapsulate synchronization logic with the data it protects.
  3. Use sync.RWMutex for read-heavy workloads to improve performance.
  4. Always use defer to ensure locks are released, even if an error occurs or the function exits early.

Further Reading