Contents

How to Write Go Code That Accesses Shared Resources Without Using Exclusive Locks 🤔

In Go, using a Mutex (Mutual Exclusion) is one way to manage access to shared data across multiple goroutines to prevent race conditions, which can lead to data corruption. However, sometimes using a Mutex can cause performance issues and program complexity, and it can hurt performance. So let’s try to write code that avoids unnecessary Mutex locks and see how it’s done.

Reasons to Avoid Exclusive Locks

  • Deadlock: Occurs when two or more goroutines are waiting for each other to release a misplaced mutex, causing the program to lock up and stop working.
  • Livelock: Occurs when two or more goroutines repeatedly release and lock a mutex, preventing any goroutine from making progress.
  • Overhead: Locking and unlocking a mutex has a cost in terms of context switching and managing the state of the mutex, which can affect program performance, especially in cases of high contention.
  • Complexity: Managing mutexes in complex programs can make the code harder to read and understand.
  • Additional: Dmitry Vyukov — Go scheduler: Implementing language with lightweight concurrency

Ways to Avoid Exclusive Locks (as far as I know)

1. Use Channels Instead of Mutexes

Channels are the primary tool for communication between goroutines in Go and can be used instead of mutexes to safely manage data access.

Example
package main

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

func main() {
	const (
		items      		= int[]{1, 2, 3, 4, 5, 6, 7}
		producerSleep   = 100 * time.Millisecond
		consumerSleep   = 150 * time.Millisecond
		channelBuffer   = 2 // The size of the channel buffer for the producer and consumer
	)

	dataChannel := make(chan int, channelBuffer)
	var wg sync.WaitGroup

	wg.Add(2) // Create a wait queue for 2 goroutines

	go producer(dataChannel, items, producerSleep, &wg)
	go consumer(dataChannel, consumerSleep, &wg)

	wg.Wait() // Wait until both goroutines are done
}

func veryLongTask(input int, sleepDuration time.Duration) (output int, err error) {
	output = input * 2
	time.Sleep(sleepDuration)
	return
}

func producer(ch chan<- int, items []int, sleepDuration time.Duration, wg *sync.WaitGroup) {
	defer wg.Done()
	for _, item := range items {
		// Send to work
		output, _ := veryLongTask(item, sleepDuration)
		ch <- output // Send data to the channel after the work is done
		fmt.Println("Produced:", item)
	}
	close(ch) // Close the channel after all data has been sent
}

func consumer(ch <-chan int, sleepDuration time.Duration, wg *sync.WaitGroup) {
	defer wg.Done()
	for data := range ch {
		// Receive data from the channel
		fmt.Println("Consumed:", data)
		time.Sleep(sleepDuration)
	}
}

2. Use Lock-Free Data Structures

Using data structures designed to avoid mutexes, such as sync.Map, allows for safe data access without having to manage mutexes yourself.

Example
package main

import (
	"fmt"
	"sync"
)

func main() {
	var m sync.Map

	// Store data
	m.Store("key1", "value1")
	m.Store("key2", "value2")

	// Read data
	m.Range(func(key, value interface{}) bool {
		fmt.Println(key, value)
		return true
	})
}

3. Use Atomic Operations

Go has the sync/atomic package, which provides functions for atomic operations (similar to the Atomicity property in databases), allowing for safe incrementing or decrementing of variables from concurrent access by multiple goroutines.

Example
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var counter int64
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			atomic.AddInt64(&counter, 1) // Increment the counter variable
		}()
	}

	wg.Wait() // Wait for all goroutines to finish
	fmt.Println("Final Counter:", counter)
}

4. Use a Read/Write Mutex

If you want to allow multiple goroutines to read data but only one goroutine to write data, and you need to use a mutex, you can use sync.RWMutex, which allows for concurrent reads without waiting for writes.

Example
package main

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

type SafeData struct {
	mu sync.RWMutex
	data int
}

func (s *SafeData) Read() int {
	s.mu.RLock() // Use RLock for reading
	defer s.mu.RUnlock()
	return s.data
}

func (s *SafeData) Write(value int) {
	s.mu.Lock() // Use Lock for writing
	defer s.mu.Unlock()
	s.data = value
}

func main() {
	safeData := SafeData{}

	var wg sync.WaitGroup

	// Goroutine for writing data
	wg.Add(1)
	go func() {
		defer wg.Done()
		for i := 0; i < 5; i++ {
			safeData.Write(i)
			fmt.Println("Written:", i)
			time.Sleep(100 * time.Millisecond)
		}
	}()

	// Goroutines for reading data
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println("Read:", safeData.Read())
			time.Sleep(50 * time.Millisecond)
		}()
	}

	wg.Wait() // Wait for all goroutines to finish
}

Conclusion

Avoiding the use of Mutex locks in Go can be done in several ways, such as using channels, lock-free data structures, atomic operations, and read/write mutexes. Choosing the right method will help improve performance and reduce the complexity of your program, making it run smoothly and safely from concurrent data access (race conditions).

Andrew Gerrand
Do not communicate by sharing memory; instead, share memory by communicating.

Additional: How To Avoid Locks (Mutex) In Your Golang Programs?