paint-brush
Understanding sync.Cond in Go: A Guide for Beginner'sby@ivanlemeshev
718 reads
718 reads

Understanding sync.Cond in Go: A Guide for Beginner's

by Ivan LemeshevApril 28th, 2024
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

In conclusion, sync.Cond is a useful type in the Go programming language that allows for synchronization and coordination between goroutines based on specific conditions. It provides a way to create and manage condition variables. It has methods to wait for, signal, and broadcast conditions. By using `sync.Cond`, you can write more controlled and synchronized concurrent programs in Go. It's important to note that sync.Cond is just one of the synchronization primitives provided by the Go standard library, and its usage depends on the specific requirements of your concurrent program. In some cases, other synchronization primitives like channels or sync.WaitGroup might be more suitable.
featured image - Understanding sync.Cond in Go: A Guide for Beginner's
Ivan Lemeshev HackerNoon profile picture

Introduction

I want to discuss the sync.Cond type and use cases and when to use it.

What Is sync.Cond?

In the Go programming language, sync.Cond is a type defined in the sync package representing a condition variable. Condition variables are synchronization primitives used for coordinating goroutines by allowing them to wait for a specific condition to become true before proceeding.


The sync.Cond type provides a way to create and manage condition variables. It has three main methods:


  1. Wait(): This method causes the calling goroutine to wait until another goroutine signals the condition variable. When the goroutine calls Wait(), it releases the associated lock and suspends execution until another goroutine calls Signal() or Broadcast() on the same sync.Cond variable.


  2. Signal(): This method wakes up one goroutine waiting on the condition variable. If multiple goroutines are waiting, only one of them is awakened. The choice of which goroutine gets awakened is arbitrary and not guaranteed.


  3. Broadcast(): This method wakes up all goroutines waiting for the condition variable. When Broadcast() is called, all waiting goroutines are awakened and can proceed.


Note that sync.Cond requires an associated sync.Mutex to synchronize access to the condition variable.


By using sync.Cond, you can coordinate the execution of goroutines based on specific conditions, allowing for more controlled and synchronized concurrent programming in Go.

Common Use Cases

sync.Cond is commonly used in scenarios where goroutines need to coordinate and communicate with each other based on specific conditions. Let's consider common use cases for sync.Cond.

Goroutine Synchronization

sync.Cond can be used to synchronize the execution of multiple goroutines. For example, you might have various goroutines that must wait for a specific condition to be satisfied before proceeding. The waiting goroutines can call cond.Wait(), and the signaling goroutine can call cond.Signal() or cond.Broadcast() to wake up the waiting goroutines when the condition is met.


package main

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

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex

	cond := sync.NewCond(&mu)

	wg.Add(2)

	go func() {
		fmt.Println("Goroutine 1 is started")
		defer wg.Done()

		cond.L.Lock()
		defer cond.L.Unlock()

		fmt.Println("Goroutine 1 is waiting for condition")
		cond.Wait()
		fmt.Println("Goroutine 1 met the condition")

		fmt.Println("Goroutine 1 is done")
	}()

	go func() {
		fmt.Println("Goroutine 2 is started")
		defer wg.Done()

		time.Sleep(5 * time.Second) // Simulating some work

		cond.L.Lock()
		defer cond.L.Unlock()

		fmt.Println("Goroutine 2 is signaling condition")
		cond.Signal()
		fmt.Println("Goroutine 2 completed signaling")

		fmt.Println("Goroutine 2 is done")
	}()

	wg.Wait()
}


In this example, we have two goroutines. The first goroutine waits for a condition using cond.Wait(), while the second goroutine signals the condition using cond.Signal().


When the program executes, the first goroutine acquires the lock and then calls cond.Wait(). Since the condition is not yet met, the first goroutine releases the lock and suspends its execution.


Meanwhile, the second goroutine sleeps for five seconds, simulating some work. It acquires the lock and then calls cond.Signal(). It wakes up the waiting goroutine, which then acquires the lock and executes.


The usage of sync.Cond ensures that the first goroutine waits until the second goroutine signals the condition, allowing for synchronization and coordination between the two goroutines.

Producer–Consumer Problem

sync.Cond can be useful in solving the producer-consumer problem, a classic synchronization problem involving two types of processes, producers, and consumers, that share a common fixed-size buffer or queue. The producer goroutines can use cond.Signal() or cond.Broadcast() to notify the consumer goroutines when new data is available for consumption.


package main

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

const MaxMessageChannelSize = 5

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex

	cond := sync.NewCond(&mu)

	messageChannel := NewMessageChannel(MaxMessageChannelSize)
	producer := NewProducer(cond, messageChannel)
	consumer := NewConsumer(cond, messageChannel)

	wg.Add(2)

	go func() {
		defer wg.Done()

		for i := range 10 {
			producer.Produce(fmt.Sprintf("Message %d", i))
		}
	}()

	go func() {
		defer wg.Done()

		for range 10 {
			consumer.Consume()
		}
	}()

	wg.Wait()
}

type MessageChannel struct {
	maxBufferSize int
	buffer        []string
}

func NewMessageChannel(size int) *MessageChannel {
	return &MessageChannel{
		maxBufferSize: size,
		buffer:        make([]string, 0, size),
	}
}

func (mc *MessageChannel) IsEmpty() bool {
	return len(mc.buffer) == 0
}

func (mc *MessageChannel) IsFull() bool {
	return len(mc.buffer) == mc.maxBufferSize
}

func (mc *MessageChannel) Add(message string) {
	mc.buffer = append(mc.buffer, message)
}

func (mc *MessageChannel) Get() string {
	message := mc.buffer[0]
	mc.buffer = mc.buffer[1:]
	return message
}

type Producer struct {
	cond           *sync.Cond
	messageChannel *MessageChannel
}

func NewProducer(cond *sync.Cond, messageChannel *MessageChannel) *Producer {
	return &Producer{
		cond:           cond,
		messageChannel: messageChannel,
	}
}

func (p *Producer) Produce(message string) {
	time.Sleep(500 * time.Millisecond) // Simulating some work

	p.cond.L.Lock()
	defer p.cond.L.Unlock()

	for p.messageChannel.IsFull() {
		fmt.Println("Producer is waiting because the message channel is full")
		p.cond.Wait()
	}

	p.messageChannel.Add(message)
	fmt.Println("Producer produced the message:", message)

	p.cond.Signal()
}

type Consumer struct {
	id             int
	cond           *sync.Cond
	messageChannel *MessageChannel
}

func NewConsumer(cond *sync.Cond, messageChannel *MessageChannel) *Consumer {
	return &Consumer{
		cond:           cond,
		messageChannel: messageChannel,
	}
}

func (c *Consumer) Consume() {
	time.Sleep(1 * time.Second) // Simulating some work

	c.cond.L.Lock()
	defer c.cond.L.Unlock()

	for c.messageChannel.IsEmpty() {
		fmt.Println("Consumer is waiting because the message channel is empty")
		c.cond.Wait()
	}

	message := c.messageChannel.Get()

	fmt.Println("Consumer consumed the message:", message)
	c.cond.Signal()
}


In this example, we have a producer goroutine that produces messages and adds them to the message channel and a consumer goroutine that consumes messages. The message channel has a maximum size defined by MaxMessageChannelSize.


The producer goroutine adds messages to the message channel and uses cond.Signal() to notify the consumer goroutine when new data is available. If the message channel is full, the producer goroutine waits using cond.Wait() until the consumer consumes some data and frees up space in the message channel.


Similarly, the consumer goroutine consumes messages from the message channel and uses cond.Signal() to notify the producer goroutine when space becomes available in the message channel. If it is empty, the consumer goroutine waits using cond.Wait() until the producer produces some data and adds it to the message channel.


Here, sync.Cond allows coordination and synchronization between the producer and consumer goroutines. It ensures that the consumer waits when the message channel is empty, and the producer waits when it is full, thereby solving the producer-consumer problem.

Resource Synchronization

Suppose multiple goroutines need exclusive access to a shared resource. sync.Cond can be used to coordinate the access. For example, a pool of worker goroutines might need to wait until a certain number of resources become available before they can start processing. The goroutines can wait on the condition variable using cond.Wait(), and notify about releasing resource using cond.Signal() or cond.Broadcast().


package main

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

const MaxResources = 3

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex

	cond := sync.NewCond(&mu)

	resourceProvider := NewResourceProvider(cond, MaxResources)

	wg.Add(10)

	for i := range 10 {
		go func(workerID int) {
			defer wg.Done()

			worker := NewWorker(workerID, cond, resourceProvider)
			worker.Run()
		}(i)
	}

	wg.Wait()
}

type ResourceProvider struct {
	maxResources       int
	availableResources int
	cond               *sync.Cond
}

func NewResourceProvider(cond *sync.Cond, maxResources int) *ResourceProvider {
	return &ResourceProvider{
		cond:               cond,
		availableResources: maxResources,
	}
}

func (rp *ResourceProvider) AvailableResources() int {
	return rp.availableResources
}

func (rp *ResourceProvider) AcquireResoirce() {
	rp.availableResources--
}

func (rp *ResourceProvider) ReleaseResource() {
	rp.availableResources++
}

type Worker struct {
	id   int
	cond *sync.Cond
	rp   *ResourceProvider
}

func NewWorker(workerID int, cond *sync.Cond, rp *ResourceProvider) *Worker {
	return &Worker{
		id:   workerID,
		cond: cond,
		rp:   rp,
	}
}

func (w *Worker) Run() {
	w.cond.L.Lock()

	for w.rp.AvailableResources() == 0 {
		fmt.Printf("Worker %d is waiting for resources\n", w.id)
		w.cond.Wait()
	}

	w.rp.AcquireResoirce()
	fmt.Printf("Worker %d acquired resource. Remaining resources: %d\n", w.id, w.rp.AvailableResources())
	w.cond.L.Unlock()

	time.Sleep(1 * time.Second) // Simulating work

	w.cond.L.Lock()
	defer w.cond.L.Unlock()

	w.rp.ReleaseResource()
	fmt.Printf("Worker %d released resource. Remaining resources: %d\n", w.id, w.rp.AvailableResources())
	w.cond.Signal()
}


In this example, we have multiple worker goroutines that need exclusive access to limited resources. The worker goroutines acquire and release resources using cond.Signal() to coordinate with other workers. If no resources are available, the worker goroutines wait using cond.Wait() until the other goroutine releases the resource.


In this example, sync.Cond allows for synchronization and coordination between the worker goroutines, ensuring that the worker goroutines wait when no resources are available, thereby effectively synchronizing resource access.

Event Notification

sync.Cond can be used to notify goroutines about specific events or changes in the system. For instance, you can have goroutines waiting for a specific event. When the event happens, the signaling goroutine can use cond.Signal() or cond.Broadcast() to wake up the waiting goroutines and allow them to handle the event.


package main

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

const maxWorkersCount = 10

func main() {
	var counter int32

	var wg sync.WaitGroup
	var mu sync.Mutex

	cond := sync.NewCond(&mu)

	wg.Add(maxWorkersCount)

	for i := range maxWorkersCount {
		go func(workerID int) {
			defer wg.Done()

			fmt.Printf("Worker %d performing work\n", workerID)
			time.Sleep(1 * time.Second) // Simulate work

			cond.L.Lock()
			defer cond.L.Unlock()

			counter++

			if counter == maxWorkersCount {
				fmt.Println("All workers have reached the barrier")
				cond.Broadcast()
			} else {
				fmt.Printf("Worker %d is waiting at the barrier\n", workerID)
				cond.Wait()
			}

			fmt.Printf("Worker %d passed the barrier\n", workerID)
		}(i)
	}

	wg.Wait()
}


Here, we have multiple worker goroutines that perform work and synchronize at a barrier point. The worker goroutines increment a counter and then either wait at the barrier or signal the barrier using cond.Wait() and cond.Broadcast() based on the count of workers reaching the barrier.


Each worker goroutine performs some work and then acquires the lock to increment the counter variable. If the current worker is the last one to reach the barrier, it broadcasts the barrier condition using cond.Broadcast() to wake up all waiting workers. Otherwise, it waits at the barrier using cond.Wait() to be notified by the last worker.


The barrier synchronization ensures that all worker goroutines reach the barrier before any of them proceeds beyond it. It can be useful in scenarios requiring synchronizing the execution of multiple goroutines at a specific point in their workflow.


Note that the barrier is implemented using a simple counter in this example. However, in more complex scenarios, you may need to consider additional synchronization mechanisms or conditions to ensure correct synchronization and avoid race conditions.

Conclusion

In conclusion, sync.Cond is a useful type in the Go programming language that allows for synchronization and coordination between goroutines based on specific conditions. It provides a way to create and manage condition variables. It has methods to wait for, signal, and broadcast conditions. By using sync.Cond, you can write more controlled and synchronized concurrent programs in Go.


It's important to note that sync.Cond is just one of the synchronization primitives provided by the Go standard library, and its usage depends on the specific requirements of your concurrent program. In some cases, other synchronization primitives like channels or sync.WaitGroup might be more suitable.