Synchronization primitives

12 minutes read

In modern programming, efficiently handling concurrency is crucial. However, challenges arise when synchronizing data access among multiple threads. The Go language offers tools to manage these synchronization issues, which you'll delve into throughout this topic.

Synchronization of access to shared resources

Imagine you have a warehouse with boxes for fruits. Several employees work in this warehouse, collecting fruits and placing them into the respective boxes. Every time an employee puts a fruit in a box, they record it in the system. The challenge is to ensure that the system correctly tracks how many fruits of each type have been placed in the boxes, allowing multiple employees to update the data simultaneously without conflicts.

This real-life scenario can be mapped to programming using the concept of shared resources. Here:

Shared resources - data, like a variable, object, or data structure, which can be accessed by multiple goroutines or threads simultaneously. In our warehouse example, the shared resource is the box itself, with which different employees (or threads) might try to interact at the same time, whether to add or remove fruits or update the count of fruits within it.

Critical section - a segment of code where a thread interacts with a shared resource and requires exclusive access to it, preventing conflicts and errors from concurrent accesses. Using the same example, the act of an employee putting a fruit in the box and subsequently updating the system embodies the critical section, where it's crucial to ensure that no other employee is making simultaneous updates to avoid conflicts.

To ensure safe access to shared resources, Go uses synchronization primitives like mutexes from the sync package. The sync.Mutex object has two main methods: Lock() and Unlock(). Lock locks the mutex, while Unlock releases it. If a goroutine attempts to lock an already locked mutex, it will be blocked until the mutex is released.

Let's consider an example where each goroutine represents a warehouse worker. Each worker places one apple in a box and then records this addition in the system:

func main() {
    var boxMutex sync.Mutex // Creating a mutex instance for the box
    var appleBox = 0        // The box that contains apples
    var wg sync.WaitGroup

    // Launching goroutines, each representing a worker who records apple addition in the system
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            boxMutex.Lock()   // Acquiring the mutex lock to ensure exclusive access
            appleBox++        // A worker adds an apple to the system after placing it in the box
            boxMutex.Unlock() // Releasing the mutex lock after updating the shared resource
        }()
    }

    wg.Wait()
    fmt.Println("Apple box contains:", appleBox)
}

// Output:
// Apple box contains: 100

Using a mutex, you can ensure that only one thread will have access to the critical section at a given time.

Incorrect usage of mutexes can lead to various complications like:

  • Deadlocks: When threads become stuck, waiting for resources held by each other. For instance, in a warehouse with two boxes, two workers each hold a mutex on one box. Both try to acquire the other's mutex without releasing their own, leading to a deadlock where neither can proceed.
  • Data Races: When multiple threads access data simultaneously without proper synchronization, leading to unpredictable results. For instance, if two workers simultaneously try to record an apple addition in the system without synchronization, the actual recorded number may be inaccurate, as one worker's modifications might overwrite the other's. (To observe this, comment out the mutex-related lines in the code example and run it multiple times.)

In the given code example, the use of sync.Mutex ensures synchronized access to the appleBox variable, illustrating the scenario of updating the fruit count in the warehouse.

Optimizing access for multiple reads

As workers in the warehouse add fruits to a box, a cashier uses the system to check the available quantity for sale. Using a mutex could be inefficient here, as it allows only one cashier to read the data at a time, causing delays. RWMutex is a more suitable choice, allowing multiple cashiers to read the fruit quantities simultaneously unless an update is in progress, such as during a sale or addition of new fruits. During updates, all other access is blocked until completion.

In multithreaded scenarios, multiple goroutines often access shared data. A regular mutex might not be the most efficient when many goroutines are primarily reading data. It blocks all readers, even if no writer is active, leading to unnecessary waits, especially when reads dominate writes.

Go offers a solution with the RWMutex (Read-Write Mutex) object, which has methods like RLock(), RUnlock(), Lock(), and Unlock(). The first two are used for reading, and the last two for writing.

type FruitSales struct {
    inventory map[string]int
    mutex     sync.RWMutex // Using RWMutex instead of Mutex
}

func (fs *FruitSales) CheckQuantity(fruit string) (int, bool) {
    fs.mutex.RLock()
    defer fs.mutex.RUnlock()
    quantity, exists := fs.inventory[fruit]
    return quantity, exists // Cashiers can concurrently check the available quantity of fruits for sale.
}

func (fs *FruitSales) RecordSale(fruit string, quantity int) {
    fs.mutex.Lock()
    defer fs.mutex.Unlock() // Updates are exclusive; all wait until the update is completed.
    fs.inventory[fruit] -= quantity
}

The main distinction between RWMutex and a standard Mutex lies in their handling of concurrent readers:

  • A Mutex provides exclusive access, prohibiting other goroutines from both reading and writing.
  • An RWMutex, on the other hand, supports multiple concurrent reads but ensures singular write access. All other access is blocked while a write is ongoing, but multiple concurrent reads are allowed without a write.

Guaranteeing single-time code execution with sync.Once

Every morning, the first worker or cashier arriving at work has an important ritual: they must log into the inventory system, marking the beginning of the store's work shift. The system should register the beginning of the work shift only once per day, during the first worker's or cashier's initial login to the system. It's crucial that this initialization occurs only once, regardless of how many times workers log into the system during the shift.

Sometimes, it's necessary to ensure a particular block of code is executed exactly once, even if multiple threads invoke it. For this purpose, in Go, the sync.Once structure is provided.

sync.Once offers a thread-safe and idempotent way of performing initialization, which can be especially useful when setting up global resources or other operations that must be carried out only once.

var startShiftOnce sync.Once
var shiftGroup sync.WaitGroup

func initializeShift() {
    fmt.Println("Marking the start of the store's work shift...")
}

func startShift() {
    defer shiftGroup.Done()
    startShiftOnce.Do(initializeShift)
    // ... other code for starting the shift
}

func main() {
    for i := 0; i < 5; i++ {
        shiftGroup.Add(1)
        go startShift()
    }

    shiftGroup.Wait()
    fmt.Println("The start of the store's work shift has been marked.")
}

// Output:
// Marking the start of the store's work shift...
// The start of the store's work shift has been marked.

Conclusion

Reflecting on our journey through the story at the store, let's recap what you've learned today:

  • You've explored the crucial role of multithreading and synchronization in modern programming.
  • You studied the principles of mutex operation for secure resource access.
  • Evaluated the advantages of RWMutex in multithreaded reading.
  • Familiarized yourself with the sync.Once structure, which ensures guaranteed one-time code execution.
4 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo