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()
}

Stay safe from the 'Race Condition' bug!

✌️