Understanding Race Conditions: Solving Concurrency Puzzles in Simple Terms with Go
Who enjoys race condition issues? Race conditions can lead to unexpected and undesired consequences when multiple sections of a program attempt to access and modify shared data simultaneously. Trust me, it can be a nightmare to identify the culprit if you're implementing concurrency in your project and overlook ensuring thread-safe access to shared variables among concurrent tasks. But what exactly is a race condition in programming? Picture it as a race between different program components, where the ultimate outcome depends on which one reaches and modifies the shared data first. However, if the order of execution is not properly controlled, chaos ensues, resulting in inconsistent and erroneous results. It's similar to a race where the actions of participants can interfere with one another, leading to confusion and incorrect outcomes.
To gain a better understanding of race conditions, let's consider a scenario in Go where we have two concurrent jobs or functions attempting to access and modify a shared map variable. This situation creates a potential race condition since these concurrent jobs may interfere with each other when using the same variable. To prevent such interference and ensure data integrity, we'll explore a solution using synchronization primitives, such as mutex, in Go.
Let's create a simple struct in Go that has two child variables: one being a mutex variable and the other a simple map.
package main
import (
"fmt"
"sync"
)
type SafeMap struct {
// mu is our mutual exclusion lock.
mu sync.Mutex
Map map[string]int
}
func (s *SafeMap) Put(key string, value int) {
//we have to go with the logic of locking and unlocking when done.
s.mu.Lock()
defer s.mu.Unlock()
s.Map[key] = value
}
func (s *SafeMap) Get(key string) (int, bool) {
//we have to go with the logic of locking and unlocking when done.
s.mu.Lock()
defer s.mu.Unlock()
value, ok := s.Map[key]
return value, ok
}
func main(){
safeMap := SafeMap{
Map: make(map[string]int),
}
//our wait group
wg := sync.WaitGroup{}
wg.Add(1)
// Concurrent write operations
go func() {
for i := 0; i < 10; i++ {
key := fmt.Sprintf("Key%d", i)
safeMap.Put(key, i)
}
}()
// Concurrent read operations
go func() {
for i := 0; i < 10; i++ {
key := fmt.Sprintf("Key%d", i)
value, ok := safeMap.Get(key)
if ok {
fmt.Printf("Value for %s: %d\n", key, value)
} else {
fmt.Printf("Key %s not found\n", key)
}
}
}()
go func () {
//Wait 1 sec for our write and read operations to finish
time.Sleep(1 * time.Second)
wg.Done()
}()
wg.Wait()
}
Or if you don't want to worry about creating your own struct that has mutex (manual way of locking and unlocking) on it, you can rely on sync.Map
which's available since Go 1.9 to safely handle concurrent access to maps without external synchronization.
Updated code using sync.Map
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var safeMap sync.Map
wg := sync.WaitGroup{}
wg.Add(1)
// Concurrent write operations
go func() {
for i := 0; i < 10; i++ {
key := fmt.Sprintf("Key%d", i)
safeMap.Store(key, i)
}
}()
// Concurrent read operations
go func() {
for i := 0; i < 10; i++ {
key := fmt.Sprintf("Key%d", i)
value, ok := safeMap.Load(key)
if ok {
fmt.Printf("Value for %s: %d\n", key, value)
} else {
fmt.Printf("Key %s not found\n", key)
}
}
}()
go func() {
// Wait 1 second for write and read operations to finish
time.Sleep(1 * time.Second)
wg.Done()
}()
wg.Wait()
}