Practical Go Synchronization Patterns
While the built-in synchronization mechanisms provided by Go are powerful and versatile, there are also several common synchronization patterns that can be implemented using these primitives. In this section, we'll explore some of these practical patterns and how they can be used in real-world applications.
Producer-Consumer Pattern
The producer-consumer pattern is a common concurrency pattern where one or more producer goroutines generate data, and one or more consumer goroutines process that data. This pattern can be implemented using channels to communicate between the producers and consumers.
package main
import (
"fmt"
"sync"
)
func main() {
const numProducers = 3
const numConsumers = 2
var wg sync.WaitGroup
wg.Add(numProducers + numConsumers)
ch := make(chan int, 10)
for i := 0; i < numProducers; i++ {
go func() {
defer wg.Done()
produceData(ch)
}()
}
for i := 0; i < numConsumers; i++ {
go func() {
defer wg.Done()
consumeData(ch)
}()
}
wg.Wait()
}
func produceData(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
}
func consumeData(ch <-chan int) {
for v := range ch {
fmt.Println("Consumed:", v)
}
}
In this example, we create a channel to communicate between the producer and consumer goroutines. The producers generate data and send it to the channel, while the consumers read from the channel and process the data.
Read-Write Lock
The read-write lock pattern is useful when you have shared data that is accessed more frequently for reading than for writing. The sync.RWMutex
type provides the RLock()
, RUnlock()
, Lock()
, and Unlock()
methods to manage read and write access to the shared data.
package main
import (
"fmt"
"sync"
)
func main() {
var rwm sync.RWMutex
var count int
var wg sync.WaitGroup
wg.Add(100)
for i := 0; i < 100; i++ {
go func() {
defer wg.Done()
rwm.RLock()
defer rwm.RUnlock()
fmt.Println("Count:", count)
}()
}
for i := 0; i < 10; i++ {
go func() {
rwm.Lock()
defer rwm.Unlock()
count++
}()
}
wg.Wait()
fmt.Println("Final count:", count)
}
In this example, we use a sync.RWMutex
to protect the count
variable. The reader goroutines acquire a read lock to access the variable, while the writer goroutines acquire a write lock to modify the variable.
Semaphore
Semaphores are a synchronization primitive that can be used to limit the number of concurrent operations. The chan struct{}
type can be used to implement a semaphore in Go.
package main
import (
"fmt"
"sync"
)
func main() {
const maxConcurrent = 5
var wg sync.WaitGroup
wg.Add(10)
sem := make(chan struct{}, maxConcurrent)
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fmt.Println("Executing task...")
}()
}
wg.Wait()
}
In this example, we create a buffered channel of size maxConcurrent
to act as a semaphore. Each goroutine attempts to send a value to the channel, which will block if the channel is full. When a goroutine finishes its task, it removes a value from the channel, allowing another goroutine to proceed.
Barrier
A barrier is a synchronization primitive that allows a set of goroutines to wait until all of them have reached a certain point in their execution. The sync.WaitGroup
type can be used to implement a barrier in Go.
package main
import (
"fmt"
"sync"
)
func main() {
const numGoroutines = 5
var wg sync.WaitGroup
wg.Add(numGoroutines)
var barrier sync.WaitGroup
barrier.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d is running...\n", id)
barrier.Done()
barrier.Wait()
fmt.Printf("Goroutine %d has passed the barrier\n", id)
}(i)
}
barrier.Wait()
fmt.Println("All goroutines have passed the barrier")
wg.Wait()
}
In this example, we use a sync.WaitGroup
called barrier
to implement the barrier. Each goroutine calls barrier.Done()
when it reaches the barrier, and then barrier.Wait()
to wait for all other goroutines to reach the barrier before proceeding.
Sync Pool
The sync.Pool
type is a synchronization primitive that can be used to manage a pool of reusable objects. This can be useful for improving performance in situations where you need to create and destroy many similar objects.
package main
import (
"fmt"
"sync"
)
func main() {
var pool = &sync.Pool{
New: func() interface{} {
return &myStruct{
data: make([]byte, 1024),
}
},
}
var wg sync.WaitGroup
wg.Add(1000)
for i := 0; i < 1000; i++ {
go func() {
defer wg.Done()
obj := pool.Get().(*myStruct)
defer pool.Put(obj)
// Use the object
_ = obj.data
}()
}
wg.Wait()
}
type myStruct struct {
data []byte
}
In this example, we create a sync.Pool
that manages a pool of myStruct
objects. When a goroutine needs to use one of these objects, it calls pool.Get()
to retrieve an object from the pool, and then pool.Put()
to return the object to the pool when it's done using it.