🚀 Mastering Goroutines Synchronization with Barriers in Go! 🚀
Imagine a scenario where three Goroutines are collaborating to print the sum of two numbers. Two of these Goroutines (Producers) continuously generate numbers, while the third Goroutine (Consumer) adds the generated numbers and prints the result. In each cycle, the producer Goroutines will pause for a random time interval before generating a random number.
In this example, the three Goroutines must synchronize their tasks to ensure that the Consumer Goroutine performs the additions only after the Producer Goroutines have finished generating the numbers. There are several methods to achieve this. In this article, I will use Barrier synchronization. Let's understand and implement the Barrier before returning to our example.
What is Barrier Synchronization?
A barrier is a synchronization mechanism which enables threads(goroutines) to wait at a particular point until all the goroutines have reached that point. This makes sure that none of the goroutines proceed past that barrier until every one of them has arrived. It is often used to manage phases of computation or manage dependencies between the tasks.
How Barrier Synchronization works?
Synchrornization using barrier involves the following steps:
Initialization: A barrier object is created and initialized with the number of goroutines that must reach it.
Wait Operation: Each goroutine calls a wait function on the barrier. This function blocks the calling goroutine until the specified number of goroutines have called it.
Release: Once the specified number of calls to the wait function is reached, all waiting goroutines are released simultaneously.
In the diagram above, the three Goroutines are synchronized using a barrier. The execution timeline is as follows:
At time T1, Goroutine G1 reaches the barrier point. Since G2 and G3 have not yet arrived, G1 waits.
At time T2, Goroutine G2 reaches the barrier point. G1 is already waiting at the barrier, but G3 has not yet arrived, so G2 also enters the waiting state.
At time T3, Goroutine G3 reaches the barrier point. With all Goroutines now at the barrier, the barrier is lifted, and all three Goroutines continue their execution.
Implementing Barrier Synchronization
Golang does not offer built-in support for barrier synchronization, but it's quite simple to create one from scratch using condition variables. Let's implement it step by step.
Step 1 - Define the Barrier struct
type Barrier struct {
size int
waitCount int
cond *sync.Cond
}In this struct:
sizespecifies the number of calls to the Wait method required to release the barrier.waitCountindicates the number of Goroutines currently waiting at the barrier.condis a condition variable used to suspend Goroutine execution and broadcast to all waiting Goroutines when the barrier should be released.
Step 2 - Implement the constructor method
func NewBarrier(size int) *Barrier {
return &Barrier{size: size, cond: sync.NewCond(&sync.Mutex{})}
}The NewBarrier method returns an Initialized Barrier of a given size.
step 3 - Implement Wait method
func (b *Barrier) Wait() {
b.cond.L.Lock()
b.waitCount++
if b.waitCount == b.size {
b.waitCount = 0
b.cond.Broadcast()
} else {
b.cond.Wait()
}
b.cond.L.Unlock()
}The wait method uses the condition variable defined in the Barrier struct. It begins by incrementing the waitCount by one. If the current waitCount equals the size of the barrier, it means all the Goroutines have reached the barrier, and the barrier can be released. This is accomplished by resetting the waitCount to zero and broadcasting a signal to wake up all the sleeping Goroutines, allowing them to continue execution. If the current waitCount does not equal the barrier size, the calling Goroutine is suspended.
That's it! We've implemented our own version of a Barrier. Now, let's use it to implement the example use case presented earlier.
Solving the prior example using Barrier synchronization
Now that we understand how Barrier works, we can use it as an synchronization mechanism to synchronize between producers and consumers. In our example, the consumer should only add the two numbers after both the producers generate them.
We can use a Barrier of size three to synchornize between these three goroutines. The consumer goroutine(Main gorutine) will wait on a barrier before executing the addition and printing the result. The producer goroutines will call the barrier after the numbers are generated. Once all the goroutine reaches the barrier, the consumer goroutine will start it’s execution and producer goroutines can start generating the number for the next round of execution.
The final implementation will look like this.
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
type Barrier struct {
size int
waitCount int
cond *sync.Cond
}
func NewBarrier(size int) *Barrier {
return &Barrier{size: size, cond: sync.NewCond(&sync.Mutex{})}
}
func (b *Barrier) Wait() {
b.cond.L.Lock()
b.waitCount++
if b.waitCount == b.size {
b.waitCount = 0
b.cond.Broadcast()
} else {
b.cond.Wait()
}
b.cond.L.Unlock()
}
func numberProducer(producerName string, barrier *Barrier, num *int, maxIterations int) {
iterCount := 1
for iterCount <= maxIterations {
randomNumber := rand.Intn(100) + 1
fmt.Println("Iteration:", iterCount, producerName, " generated number: ", randomNumber)
time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
*num = randomNumber
fmt.Println("Iteration:", iterCount, producerName, " is waiting on barrier")
barrier.Wait()
iterCount++
}
}
func main() {
barrier := NewBarrier(3)
var num1, num2 int
maxIterations := 2
go numberProducer("Producer1", barrier, &num1, maxIterations)
go numberProducer("Producer2", barrier, &num2, maxIterations)
iterCount := 1
for iterCount <= maxIterations {
fmt.Println("Iteration:", iterCount, "Main Goroutine(consumer) is waiting on barrier")
barrier.Wait()
result := num1 + num2
fmt.Println("Iteration:", iterCount, "Addition of numbers generated by two producers is ", result)
iterCount++
}
}
When this program is executed, the following output is printed
Iteration: 1 Main Goroutine(consumer) is waiting on barrier
Iteration: 1 Producer1 generated number: 96
Iteration: 1 Producer2 generated number: 94
Iteration: 1 Producer2 is waiting on barrier
Iteration: 1 Producer1 is waiting on barrier
Iteration: 1 Addition of numbers generated by two producers is 190
Iteration: 2 Producer1 generated number: 23
Iteration: 2 Main Goroutine(consumer) is waiting on barrier
Iteration: 2 Producer2 generated number: 93
Iteration: 2 Producer2 is waiting on barrier
Iteration: 2 Producer1 is waiting on barrier
Iteration: 2 Addition of numbers generated by two producers is 116By following this approach, we successfully synchronized our Goroutines using a Barrier, ensuring the consumer Goroutine(main) processes data only after both producers have completed their tasks.


