Go Condition Variables

Exploring Inter-thread Communication with Condition Variables

Welcome to the next chapter in our journey through Go concurrency. In our previous lesson, we delved into synchronization primitives using the sync/atomic package and learned how to manage shared data effectively with atomic operations. Building on that knowledge, this lesson will introduce you to inter-thread communication using condition variables. While Go's philosophy typically favors channels for communication between goroutines, sync.Cond remains an important synchronization primitive for certain patterns where you need fine-grained control over waiting and signaling. Condition variables are a crucial part of the concurrency toolkit, allowing goroutines to coordinate their activities seamlessly.

What You'll Learn

In this lesson, you'll gain a solid understanding of how to use condition variables for better inter-goroutine communication:

  • Introduction to sync.Cond: You'll learn about its purpose and how it enables goroutines to wait for certain conditions or events to occur before proceeding.
  • Implementing Wait and Signal patterns: We'll explore how to use the Wait(), Signal(), and Broadcast() methods to manage goroutine execution flow.

Introduction to sync.Cond

A sync.Cond is a synchronization primitive that allows goroutines to wait for a specific condition to be met before proceeding. It is used in conjunction with a sync.Mutex (or any sync.Locker) to protect shared data and coordinate the activities of multiple goroutines. Condition variables provide a mechanism for goroutines to block efficiently, reducing CPU usage and improving responsiveness.

When creating a sync.Cond, you must provide a sync.Locker (typically a *sync.Mutex) that will be used to protect the shared state. The condition variable works closely with this lock to ensure thread-safe operation.

The key methods associated with sync.Cond are:

  • Wait(): This method blocks the current goroutine until the condition variable is notified. It automatically releases the associated lock before blocking and reacquires it when the goroutine wakes up. This allows other goroutines to acquire the lock while the current one is waiting.
  • Signal(): This method notifies one waiting goroutine, if any, that the condition has changed. The notified goroutine will wake up and attempt to reacquire the lock.
  • Broadcast(): This method notifies all waiting goroutines that the condition has changed. Each goroutine will wake up and attempt to reacquire the lock.

Consider the following code snippet, which demonstrates a simple example of using condition variables in action:

package main

import (
    "fmt"
    "sync"
    "time"
)

var (
    printMutex sync.Mutex              // Mutex for critical section
    cv         *sync.Cond               // Condition variable to block goroutines
    ready      bool                     // Shared data (condition)
)

func printID(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    
    printMutex.Lock()                   // Acquire the lock
    for !ready {
        cv.Wait()                       // Wait until the condition is met. When the 'ready' flag is true, the goroutine will be unblocked and continue execution
    }
    // Proceed after the condition is met
    fmt.Printf("Goroutine %d\n", id)
    printMutex.Unlock()                 // Release the lock
}

func setReady() {
    time.Sleep(3 * time.Second)         // simulate work
    
    printMutex.Lock()                   // Lock the mutex
    ready = true                        // Set the condition to true
    printMutex.Unlock()                 // Unlock the mutex
    
    fmt.Println("Goroutines are still waiting")
    cv.Broadcast()                      // Notify all waiting goroutines that the condition has changed
}

func main() {
    cv = sync.NewCond(&printMutex)      // Create condition variable with the mutex
    var wg sync.WaitGroup
    
    // Spawn 10 goroutines
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go printID(i, &wg)
    }
    
    fmt.Println("Goroutines are waiting for the condition to be met...")
    setReady()                          // Set the condition to true after 3 seconds
    
    wg.Wait()                           // Wait for all goroutines to complete
}

In this code snippet, we explore the use of condition variables for inter-goroutine communication through a simple example.

  1. Mutex and condition variable declaration: We begin by declaring a sync.Mutex (printMutex) and a *sync.Cond (cv). The mutex is used to protect access to the shared data (ready), while the condition variable provides the mechanism for goroutines to block and be notified when the state changes. Note that we create the condition variable using sync.NewCond(&printMutex), passing a pointer to the mutex that will protect the shared state.

  2. Shared data: The bool flag ready indicates when the condition has been satisfied. Initially set to false, it determines whether a goroutine can proceed with its task.

  3. Goroutine function (printID): Inside printID, we acquire the lock by calling printMutex.Lock(), ensuring exclusive access to the critical section. The goroutine then enters a loop where it calls cv.Wait() to block until the ready condition is met. The Wait() method automatically releases the lock before blocking and reacquires it when the goroutine is notified. Upon receiving a notification that the condition is satisfied, the goroutine resumes execution, enabling it to print its identifier. Finally, we explicitly unlock the mutex with printMutex.Unlock().

  4. Notifier function (setReady): The setReady function simulates work by sleeping for 3 seconds. It then locks the mutex with printMutex.Lock(), sets the ready flag to true, and unlocks the mutex. After printing a message indicating that goroutines are waiting, cv.Broadcast() is called to unblock all waiting goroutines. Note that if you use Signal() instead of Broadcast(), only one goroutine will be unblocked - the goroutine is chosen non-deterministically.

  5. Main function: In the main function, we first create the condition variable by calling sync.NewCond(&printMutex). We then spawn 10 goroutines using a sync.WaitGroup to track their completion. Each goroutine calls the printID function and is initially blocked by the condition variable, waiting for the ready flag to be set to true. After 3 seconds, the setReady function is called, changing the condition and notifying all waiting goroutines. The goroutines are then unblocked and proceed to print their identifiers. Finally, we wait for all goroutines to complete using wg.Wait().

This code exemplifies a basic producer-consumer pattern, where setReady acts as the producer that meets the condition, allowing the consumer goroutines in printID to proceed with their task after the condition has changed.

Why It Matters

Understanding condition variables is key to building programs that involve complex goroutine interactions. Whether you're developing software that requires resource sharing, implementing producer-consumer models, or coordinating tasks, condition variables provide the necessary synchronization mechanisms. By mastering this concept, you enhance your ability to control goroutine behavior, reduce unnecessary CPU usage, and build responsive applications. While Go encourages the use of channels for many synchronization patterns ("Don't communicate by sharing memory; share memory by communicating"), sync.Cond remains valuable for scenarios requiring fine-grained waiting and notification patterns.

Does the prospect of mastering these concepts excite you? Gear up for the practice section, where you'll apply what you've learned and transform this knowledge into practical skills!

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