Back to Blog

Gocurrency

How to get started with Golang's Concurrency, how it works, and why it does do the way it does? In this topic breakdown, I will have a look at concurrency and how Golang uniquely approaches this topic with examples.

Concurrency

Concurrency refers to the ability of a computer system to perform multiple tasks simultaneously. In modern software development, Concurrency is essential because it allows programs to handle multiple user requests, perform background tasks, and process data in parallel, resulting in faster and more efficient processing.

Go is well-suited for Concurrency because of its lightweight Goroutines and built-in Channel type. Goroutines are lightweight threads that can be created easily and have low overhead, allowing for the efficient creation of thousands or even millions of concurrent processes. Channels are built-in data structures that facilitate communication between Goroutines, enabling safe and efficient synchronization of data access.

ch := make(chan string, 10)

Above is a snippet of how to create a Channel that accepts the string primitive and is initialized with an initial buffer capacity of 10, if you omit or provide 0 the channel would be unbuffered.

In Go, Channels are used to communicate and synchronize data between Goroutines. When you create a Channel, you have the option to specify its buffer capacity. The buffer capacity determines how many values can be stored in the Channel before it blocks, meaning the sender has to wait for the receiver to read from the Channel before it can send another value.

If you specify a buffer capacity of zero or omit the buffer size when creating the Channel, the Channel becomes unbuffered. An unbuffered channel can only hold one value at a time. When a sender sends a value to an unbuffered channel, it blocks until a receiver reads the value from the Channel. Similarly, when a receiver reads from an unbuffered channel, it blocks until a sender sends a value to the Channel.

In other words, an unbuffered channel ensures that both the sender and the receiver are ready and available to communicate with each other at the time of communication. This ensures that the values are synchronized and exchanged in a safe and synchronized manner.

On the other hand, if you specify a buffer capacity greater than zero, the channel becomes buffered. A buffered channel can hold multiple values, up to its buffer capacity. When a sender sends a value to a buffered channel, it will not block as long as the buffer is not full. Similarly, when a receiver reads from a buffered channel, it will not block as long as the buffer is not empty. This can lead to increased performance and reduced contention in some cases, but it also introduces potential risks of data races and synchronization issues when multiple Goroutines are trying to access the same channel.

The basic idea behind Go Concurrency is that each Goroutine performs a small, well-defined task, and channels are used to coordinate their activities. This allows programs to be written in a way that maximizes parallelism and minimizes contention, resulting in faster and more efficient processing.

Goroutines & Channels in Go

A Goroutine is a lightweight thread of execution that is managed by the Go runtime. They are different from traditional threads in that they are designed to be concurrent, which means they allow multiple tasks to be executed simultaneously and independently. Goroutines are much lighter than traditional threads, as they require only a few kilobytes of memory compared to several megabytes of memory required by traditional threads. Goroutines are also more cost-effective than traditional threads, as creating and managing them is much cheaper. Furthermore, Goroutines communicate through channels, which are built-in data structures that enable safe and efficient synchronization of data access.

Goroutine in practice

package gocurrency
import (
"fmt"
"time" 
)

func BuildRace() {
car1 := "Ferrari"
car2 := "Lamborghini"

// Create a Goroutine for each car
go race(car1)
go race(car2)

// Wait for the race to finish
time.Sleep(5 * time.Second)

fmt.Println("Race over!")
}

func race(car string) {
for i := 0; i < 5; i++ {
    fmt.Println(car, "is racing...")
    time.Sleep(1 * time.Second)
}
}

In the example above we can see that spinning up a new Goroutine is as easy as adding the go keyword in front of a function. What happens is given that race(car1) and race(car2) are on different Goroutines, they run independently of one another, the sleep timer for 5 seconds is in place to wait for two Goroutines which now takes a combined time of 5 seconds to execute given that each iteration waits for 1 second and runs 5 times. If the two race calls were not on their own Goroutines, the code would have taken a whopping 10s to execute.

Note: the BuildRace function is a goroutine of its own.

Channels

A Channel is a built-in data structure that allows Goroutines to communicate and synchronize their activities. Channels provide a way to send and receive values between Goroutines in a safe and efficient way, without the need for locks or other synchronization primitives.

Channels can be used to communicate between Goroutines by sending and receiving values. The <- operator is used to send and receive values on a channel. For example, to send a value on a channel, you would write channel <- value. To receive a value from a Channel, you would write value := <-channel.

package gocurrency
import "fmt"

func SimpleChannel() {
// Create an unbuffered channel of type int
c := make(chan int)

// Start a Goroutine that sends a value on the channel
go func() {
    c <- 82
}()

// Receive the value from the channel
value := <-c

fmt.Println("Received value:", value)
}

In this example, we create a channel of type int using the make function. We then start a Goroutine that sends the value 82 on the channel using the c <- 82 syntax. Finally, we receive the value from the channel using the value := <-c syntax.

When you run this program, you'll see that the value 82 is received from the channel and printed to the console.

Benefits of Goroutines/Channels

  • Firstly, Goroutines are extremely lightweight, requiring only a few kilobytes of memory compared to the several megabytes required by traditional threads. This means that Go programs can create and manage a large number of Goroutines without incurring significant memory overhead, leading to improved performance and scalability.
  • Secondly, using Goroutines in Go also simplifies synchronization between concurrent activities. Unlike traditional thread synchronization mechanisms like locks and semaphores, Goroutines can be synchronized using channels, which are simpler and more intuitive to use. Channels help to avoid race conditions and deadlocks by ensuring that data is safely shared between Goroutines.
  • In addition, Channels also simplify debugging in Go programs by providing a clear and intuitive way to track data flow between Goroutines. By using Channels to communicate between Goroutines, developers can more easily understand and debug Concurrency issues in their programs.

Go Concurrency Patterns

Buffered Channels

package gocurrency
import (
  "fmt"
  "math/rand"
  "time"
  )

func BufferedChannel() {
// Create a buffered channel with a capacity of 2
cars := make(chan string, 2)

// Start two Goroutines that add cars to the channel
go addCar("Ferrari", cars)
go addCar("Lamborghini", cars)

// Wait for the cars to be added to the channel
time.Sleep(2 * time.Second)
close(cars)

// Start a Goroutine that simulates the race
go startRace(cars)

// Wait for the race to finish
time.Sleep(6 * time.Second)

fmt.Println("Race over!")
}

func addCar(name string, cars chan string) {
cars <- name
fmt.Println(name, "added to the race!")
}

func startRace(cars chan string) {
for {
    // Receive a car from the channel
    car, open := <-cars
    if !open {
        break
    }

    fmt.Println(car, "is racing...")

    // Simulate the race by waiting for a random duration
    time.Sleep(time.Duration(1+rand.Intn(5)) * time.Second)
    }

fmt.Println("All cars have finished the race!")
}

In the example above, we instantiate a channel with a buffer capacity of 2 and what this means is that the channel can receive two strings in the case above before requiring that a read operation is done on the channel to free up space. We write twice to the channel and then close the channel and this was done to let anything reading from the channel know when to stop attempting a read this is the only way fmt.Println("All cars have finished the race!") would be reached because it can only be reached if we break out of the for loop. We sleep for 6 seconds to allow for the read operation to fully occur.

Reading channels with for range

func startRaceWithRange(cars chan string) {
for car := range cars {
    fmt.Println(car, "is racing...")

    // Simulate the race by waiting for a random duration
    time.Sleep(time.Duration(1+rand.Intn(5)) * time.Second)
}

fmt.Println("All cars have finished the race!")
}

In the example, rather than receive the value and an open bool value, we let range handle retrieving the value and internally checking to see if the channel is open, since we close the channel, after the second value is read the result would look like

Lamborghini added to the race
Ferrari added to the race!
Ferrari is racing...
Lamborghini is racing...
All cars have finished the race!
Race over!!

Working with select statement

A select statement lets a Goroutine wait on multiple communication operations, it blocks operation until at least one of its cases can run, and it executes that case, if multiple cases can run at the same time, it chooses one at random.

package gocurrency
import (
  "fmt"
  "math/rand"
  "time"
  )

func SelectRace() {
// Create two channels for the cars
ferrari, lamborghini := make(chan string), make(chan string)

// Start two Goroutines that add cars to the channels
go addCarWithChan("Ferrari", ferrari)
go addCarWithChan("Lamborghini", lamborghini)

// Start a Goroutine that simulates the race
go startRaceWithSelect(ferrari, lamborghini)

// Wait for the race to finish
time.Sleep(5 * time.Second)

fmt.Println("Race over!")
}

func addCarWithChan(name string, c chan<- string) {
for i := 0; i < 5; i++ {
    time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
    c <- name
    fmt.Println(name, "added to the race!")
}
close(c)
}

func startRaceWithSelect(ferrari <-chan string, lamborghini <-chan string) {
for {
    select {
    case car, ok := <-ferrari:
        if !ok {
            fmt.Println("Ferrari channel closed!")
            ferrari = nil
            break
        }
        fmt.Println(car, "is racing...")

    case car, ok := <-lamborghini:
        if !ok {
            fmt.Println("Lamborghini channel closed!")
            lamborghini = nil
            break
        }
        fmt.Println(car, "is racing...")
    }

    if ferrari == nil && lamborghini == nil {
        break
    }
    time.Sleep(time.Second)
}

fmt.Println("All cars have finished the race!")
}

Let's break this down, we created two channels for the two different cars and modified the addCar function to include a channel. The addCarWithChan and the startRaceWithSelect function are in their own goroutines and would run concurrently until the channel is closed or the wait time is reached. An example of the result is as follows:

Ferrari is racing..
Ferrari added to the race!
Lamborghini is racing...
Lamborghini added to the race!
Ferrari is racing...
Ferrari added to the race!
Ferrari is racing...
Ferrari added to the race!
Race over!.

This might look a bit strange as how is Ferrari racing(5th line) without being added to the race first and what happened to the line "All cars have finished the race!". First off given that addCarWithChan function pushes the value to the channel before printing to screen means that startRaceWithSelect function which is also running concurrently can read that value even before the next line is printed, remember select is blocking which means that once it's picked up, everything in the case block must be executed before the next operation occurs. As for the line "All cars have finished the race!" the timer simply exhausted the 5 seconds before the entire operation could be completed.

WaitGroups

A WaitGroup is a synchronization mechanism that allows a program to wait for a collection of Goroutines to finish executing before proceeding to the next step in the program.

A WaitGroup maintains a counter that is incremented by each Goroutine that is launched and decremented by each Goroutine that finishes. The program waits for the counter to reach zero, indicating that all goroutines have been completed, before proceeding to the next step.

The WaitGroup type provides three methods:

  • Add(delta int): Adds delta, which can be a negative value, to the WaitGroup counter.
  • Done(): Decrements the WaitGroup counter by one.
  • Wait(): Blocks the program until the WaitGroup counter is zero.

    package gocurrency import ( "fmt" "sync" "time" ) func SimpleWaitGroup() { var wg sync.WaitGroup for i := 1; i <= 3; i++ { wg.Add(1) // increment WaitGroup counter go func(num int) { defer wg.Done() // decrement WaitGroup counter when done fmt.Printf("goroutine %d\n", num) }(i) time.Sleep(time.Duration(1 * time.Second)) } wg.Wait() // blocks until WaitGroup counter is zero fmt.Println("All goroutines have finished executing.") }

In this example, wg.Wait() blocks the function from proceeding to "All goroutines have finished executing." until all the added wait groups are done.

Mutexes

Mutexes are a powerful synchronization mechanism that can be used to protect shared resources in concurrent programs.

Imagine that you are organizing a car race with multiple cars that will be running simultaneously on a track. You need to ensure that the cars do not collide with each other and that they stay within their lanes. To achieve this, you can use a mutex to protect the shared resource, which in this case is the track. The mutex will allow only one car to access the track at a time, ensuring that no two cars collide with each other.

package gocurrency
import (
"fmt"
"sync"
)
var trackMutex sync.Mutex
var track [3] int

func Mutexes() {
var wg sync.WaitGroup

for i := 0; i < 3; i++ {
    wg.Add(1)
    go func(car int) {
        defer wg.Done()
        for j := 0; j < 5; j++ {
            trackMutex.Lock()
            track[car]++
            fmt.Printf("Car %d is on lap %d\n", car, track[car])
            trackMutex.Unlock()
        }
    }(i)
}

wg.Wait()
}

In this example, we have a shared resource, which is the track represented as an array of integers. Each element of the array represents the number of laps completed by a particular car. We also have a mutex, trackMutex, which we use to protect the shared resource.

The main function creates a WaitGroup and launches three Goroutines, each representing a car in the race. The Add() method is used to increment the WaitGroup counter by one before launching each Goroutine.

Inside each Goroutine, a loop is executed five times, representing five laps around the track. The Goroutine first acquires the lock on the mutex by calling Lock(), ensuring that only one Goroutine can access the shared resource at a time. It then updates the lap count for its corresponding car and prints a message indicating which car is on which lap. Finally, the Goroutine releases the lock on the mutex by calling Unlock(), allowing other Goroutines to access the shared resource.

The main function waits for all three Goroutines to finish executing by calling wg.Wait().

I hope that this blog will get you started with Golang's Concurrency, helped to explain how it works, and why it does the way it does. You can find the complete codebase here from Github.

Author

  • Portrait of Darrel Idiagbor
    Darrel Idiagbor
    Senior Fullstack Developer