Synchronizing Shared Data

Understanding Data Sharing Between Goroutines

Welcome back! Now that you have a good grasp of goroutine lifecycles and basic operations, let's move forward to a critical and exciting part of concurrent programming: data sharing between goroutines. In this lesson, we will explore how goroutines can share data using primitive approaches and understand the importance of synchronizing this access.

What You'll Learn

Data sharing between goroutines, while powerful, can lead to unpredictable behavior if not managed correctly. We will cover:

  1. Shared variables and risks of unsynchronized access.
    • Learn how goroutines can share data through shared variables.
    • Understand the risks of unsynchronized access, such as race conditions.
  2. Introduction to synchronization primitives.
    • Explore the basic synchronization primitives like sync.Mutex.
    • Understand how these tools prevent race conditions by ensuring that only one goroutine can access the shared resource at a time.
  3. Code example: Observing race conditions and fixing them.
    • We'll demonstrate a race condition and then fix it using sync.Mutex and Go's defer pattern.

Let's start with an example that demonstrates the risks of unsynchronized access to shared variables:

package main

import (
    "fmt"
    "sync"
)

var counter int

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 10000; i++ {
        counter++
    }
}

func main() {
    var wg sync.WaitGroup
    wg.Add(2)
    go increment(&wg)
    go increment(&wg)
    wg.Wait()
    fmt.Println("Final counter (without synchronization):", counter)
}

This code might produce different results each time it's run due to race conditions. This is because both goroutines are accessing the shared variable counter without any synchronization. Here is a quick scenario that explains the issue:

  1. goroutine 1 reads the value of counter (let's say it's 15).
  2. goroutine 2 reads the value of counter (also 15).
  3. goroutine 1 increments counter by 1 and writes the new value (16).
  4. goroutine 2 increments counter by 1 and writes the new value — also 16, instead of 17, since it read the value before goroutine 1 updated it.
  5. The final value of counter is 16, instead of the expected 17.

Using sync.Mutex

Now, since you understand the risks of unsynchronized access, let's explore how to prevent such issues using synchronization primitives.

Let's start with the most basic synchronization primitive: sync.Mutex. A mutex is a lock that allows only one goroutine to access a shared resource at a time. Here's how you can use it to fix problems like the one we just discussed:

package main

import (
    "fmt"
    "sync"
)

type SynchronizedCounter struct {
    mutex sync.Mutex
    count int
}

func (sc *SynchronizedCounter) increment() {
    // Acquire the lock, release it when the function ends using defer
    sc.mutex.Lock()
    defer sc.mutex.Unlock()
    sc.count++
}

func (sc *SynchronizedCounter) getCount() int {
    // Acquire the lock, release it when the function ends using defer
    sc.mutex.Lock()
    defer sc.mutex.Unlock()
    return sc.count
}

func main() {
    counter := &SynchronizedCounter{}
    var wg sync.WaitGroup
    wg.Add(2)
    
    go func() {
        defer wg.Done()
        for i := 0; i < 10000; i++ {
            counter.increment()
        }
    }()
    
    go func() {
        defer wg.Done()
        for i := 0; i < 10000; i++ {
            counter.increment()
        }
    }()
    
    wg.Wait()
    fmt.Println("Final count with synchronization:", counter.getCount())
}

Let's break down the code:

  • We introduced a SynchronizedCounter struct that contains a sync.Mutex field, which is the first difference from the previous example.
  • We added explicit Lock() and defer Unlock() calls in the increment and getCount methods. This ensures that only one goroutine can access the shared resource at a time. When a goroutine acquires the lock, no other goroutine can access the shared resource until the lock is released.
  • The defer keyword ensures that the Unlock() method is called when the function returns, even if a panic occurs. This is crucial for preventing deadlocks.
  • We created two goroutines that increment the counter 10,000 times each. Since the increment method is synchronized, the final count will be 20,000 as expected, no matter how many times you run the program.

Let's understand how sync.Mutex and the lock/unlock pattern work:

  • sync.Mutex is a synchronization primitive that provides exclusive access to shared resources. Under the hood, it uses the operating system's native locking mechanism to ensure that only one goroutine can access the shared resource at a time.
  • When you call Lock() on a mutex, the current goroutine attempts to acquire the lock. If another goroutine already holds the lock, the current goroutine will block until the lock becomes available.
  • When you call Unlock() on a mutex, you release the lock, allowing other waiting goroutines to acquire it.
  • The defer keyword in Go schedules a function call to be executed when the surrounding function returns. By using defer mutex.Unlock() right after mutex.Lock(), we ensure that the lock is always released, even if the function returns early or panics. This pattern is idiomatic in Go and helps prevent common mistakes like forgetting to unlock a mutex.

Why It Matters

Understanding how to manage data sharing between goroutines is paramount for writing reliable and efficient concurrent programs. Here's why:

  • Avoiding race conditions. Race conditions can lead to unpredictable and erroneous behavior in your application. Synchronization helps to prevent such issues.
  • Data integrity. By ensuring that shared data is accessed in a controlled manner, you can maintain the integrity of your program's state.
  • Enhancing robustness. Synchronization primitives make your concurrent code more robust and easier to debug, as they eliminate many common concurrency-related bugs.

Excited to dive deeper into this crucial aspect of concurrency? Let's move on to the practice section and solidify your understanding through hands-on coding.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal