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:
- 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.
- 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.
- Explore the basic synchronization primitives like
- Code example: Observing race conditions and fixing them.
- We'll demonstrate a race condition and then fix it using
sync.Mutexand Go'sdeferpattern.
- We'll demonstrate a race condition and then fix it using
Let's start with an example that demonstrates the risks of unsynchronized access to shared variables:
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:
- goroutine
1reads the value ofcounter(let's say it's15). - goroutine
2reads the value ofcounter(also15). - goroutine
1incrementscounterby1and writes the new value (16). - goroutine
2incrementscounterby1and writes the new value — also16, instead of17, since it read the value before goroutine1updated it. - The final value of
counteris16, instead of the expected17.
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:
Let's break down the code:
- We introduced a
SynchronizedCounterstruct that contains async.Mutexfield, which is the first difference from the previous example. - We added explicit
Lock()anddeferUnlock()calls in theincrementandgetCountmethods. 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
deferkeyword ensures that theUnlock()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
counter10,000times each. Since theincrementmethod is synchronized, the final count will be20,000as expected, no matter how many times you run the program.
Let's understand how sync.Mutex and the lock/unlock pattern work:
sync.Mutexis 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
deferkeyword in Go schedules a function call to be executed when the surrounding function returns. By usingdefer mutex.Unlock()right aftermutex.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.
