Evendyne
Published in

Evendyne

Getting started with Go: Concurrency

Concurrency is one of the most important aspects of modern programming, that allows multiple tasks to be executed simultaneously. This article from Getting started with Go aims to provide you with fundamentals of concurrency handling and synchronization mechanisms, through the use of goroutines and channels.

Goroutines

Goroutines are lightweight threads that can be created easily and inexpensively. They are created using the go keyword, followed by a function call (let it be anonymous or named function):

package main

import (
"fmt"
"time"
)

func main() {
go func() {
fmt.Println("goroutine running")
time.Sleep(time.Second)
fmt.Println("goroutine finished")
}()

fmt.Println("main function running")
time.Sleep(time.Second * 2)
fmt.Println("main function finished")
}

Here we create a goroutine using the go keyword and a function literal. The goroutine will run in parallel with the rest of the code. The goroutine will finish after one second, and the main function will finish after two seconds. So the output will be:

main function running
goroutine running
goroutine finished
main function finished

Goroutines are designed to be used in conjunction with channels.

Channels

A channel is a communication mechanism that allows one goroutine to send values to another goroutine. Channels can be used to synchronize execution across goroutines, via communication. To send data through a channel, we can use the <- operator. For example:

channel <- data

This will send the data through the channel, which can then be received by another goroutine using the following syntax:

data := <-channel

As mentioned, channels can be used to synchronize goroutines, as well as pass data between them. For example, we can use a channel to wait for a goroutine to finish executing before continuing with the rest of the code.

package main

import "fmt"

func main() {
done := make(chan bool)

go func() {
fmt.Println("goroutine running")
time.Sleep(time.Second)
fmt.Println("goroutine finished")
done <- true
}()

fmt.Println("main function running")
<-done
fmt.Println("main function finished")
}

In the above example, we create a channel called done and start a goroutine that will sleep for one second and then send a message through the channel. In the main function, we receive from the done channel and block until the message is received. This ensures that the main function will wait for the goroutine to finish before continuing. Output is the following:

main function running
goroutine running
goroutine finished
main function finished

We saw an example for a blocking operation above, where execution waits for an external event to happen before it can be completed.

One other important mechanism we can use in Go is the select statement, which allows us to choose from multiple channels that are ready to receive or send data:

package main

import (
"fmt"
"time"
)

func main() {
c1 := make(chan string)
c2 := make(chan string)

go func() {
time.Sleep(time.Second * 1)
c1 <- "one"
}()

go func() {
time.Sleep(time.Second * 2)
c2 <- "two"
}()

for i := 0; i < 2; i++ {
select {
case msg1 := <-c1:
fmt.Println("received", msg1)
case msg2 := <-c2:
fmt.Println("received", msg2)
}
}
}

Here we have two goroutines that are running concurrently. Each goroutine sends a message through a different channel after a certain amount of time. Inmain, we use the select statement to choose which channel to receive from. The select statement will block until one of the channels is ready to receive, and then it will execute the corresponding case.

In this example, the first select statement will receive the message from c1 because it becomes ready first, and the second select statement will receive the message from c2. The output is the following:

received one
received two

Let’s see an important variation. We can use the default case in the select statement to specify non-blocking communication behavior. This behaviour means execution can complete immediately, without waiting for any external event to finish. If we modify the select statement in the example above to include a default case, it will execute the default case if none of the other cases are ready to receive. This allows us to perform multiple operations concurrently, without blocking on any of them.

 for {
select {
case msg1 := <-c1:
fmt.Println("received", msg1)
case msg2 := <-c2:
fmt.Println("received", msg2)
default:
time.Sleep(time.Second * 1)
fmt.Println("no message received")
}
}

This prints the following result to the output:

no message received
received one
no message received
received two
no message received
no message received
no message received
no message received
...

Mutexes

In Go, a mutex (short for “mutual exclusion”) is a type of synchronization mechanism that allows multiple goroutines to access shared data via memory without interference. It does this by providing a way for goroutines to lock the shared data when they are using it, and to unlock the data when they are finished. This ensures that only one goroutine can access the shared data at a time, preventing race conditions and other synchronization issues.

There are two types of mutexes in Go:

sync.Mutex: This is a mutual exclusion lock that is used to protect shared data from being accessed concurrently. It has two methods: Lock and Unlock. When a goroutine calls Lock, it blocks until it can acquire the lock. Once it has the lock, it can access the shared data. When it is finished, it calls Unlock to release the lock so that other goroutines can access the data.

sync.RWMutex: This is a mutual exclusion lock that allows multiple goroutines to read the shared data concurrently, but only allows one goroutine to write to the data at a time. It has three methods: Lock, Unlock, and RLock. When a goroutine calls Lock, it blocks until it can acquire the write lock. When a goroutine calls RLock, it blocks until it can acquire the read lock. Once a goroutine has the read lock, it can access the shared data concurrently with other goroutines that also have the read lock. However, if any goroutine has the write lock, all other goroutines must wait until it is released before they can acquire either the read or write lock.

Here is an example of how to use a sync.Mutex to protect a shared variable:

import "sync"

var mu sync.Mutex
var sharedData int

func updateSharedData(newValue int) {
mu.Lock()
sharedData = newValue
mu.Unlock()
}

func main() {
go updateSharedData(1)
go updateSharedData(2)
// ...
}

In the above example, updateSharedData function acquires the lock before updating the shared variable sharedData, and releases the lock when it is finished. This ensures that only one goroutine can access sharedData at a time, preventing race conditions.

Groups

WaitGroups and ErrGroups are two types of synchronization mechanisms that are often used when working with goroutines in Go.

WaitGroups allow you to wait for a group of goroutines to finish executing before continuing with the rest of the code. They are created using the sync package. Let’s see a short example of using a WaitGroup:

package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup

wg.Add(2) // we are waiting for 2 goroutines to finish

go func() {
defer wg.Done()
fmt.Println("goroutine 1")
}()

go func() {
defer wg.Done()
fmt.Println("goroutine 2")
}()

wg.Wait()
fmt.Println("all goroutines have finished")
}

Here we create a WaitGroup and specify how many goroutines we are waiting for by calling the Add method. Then, we start two goroutines and call the Done method when each one finishes. Finally, we call the Wait method to block until both goroutines have finished. The output of this program will be:

goroutine 1
goroutine 2
all goroutines have finished

ErrGroups are similar to WaitGroups, but they also allow you to collect and propagate errors that occur in the goroutines. They are created using the golang.org/x/sync/errgroup package. To use an ErrGroup, you can start your goroutines using the Go method and pass them a function that returns an error. Then, similar to WaitGroup, you can call the Wait method to block until all goroutines have finished and return any errors that occurred. Here is an example of using an ErrGroup:

package main

import (
"fmt"
"golang.org/x/sync/errgroup"
)

func main() {
var eg errgroup.Group

eg.Go(func() error {
return fmt.Errorln("error in goroutine 1")
})

eg.Go(func() error {
return fmt.Errorln("error in goroutine 2")
})

if err := eg.Wait(); err != nil {
fmt.Println(err)
}
}

In this example, we create an ErrGroup and start two goroutines that return errors. Then, we call the Wait method to block until both goroutines have finished and return any errors that occurred. This prints the following to the output:

error in goroutine 1
error in goroutine 2

There are some key differences between WaitGroups and ErrGroups that you should definitely be aware of. WaitGroups are simpler and only allow you to wait for a group of goroutines to finish, whereas ErrGroups allow you to collect and propagate errors as well. ErrGroups are also more flexible, as they allow you to start and cancel goroutines.

Wrapping Up

Overall, concurrency is a crucial mechanism in Go, that allows us to take advantage of multicore processors and write efficient, concurrent code. By using goroutines, channels and mutexes, you can easily utilize concurrency techniques and synchronize execution as you wish.

You can find more about Evendyne here.

--

--

Evendyne is a company that focuses on bringing the latest in technology and engineering insights to our readers. Our team of expert writers provide in-depth analysis and commentary on a wide range of topics. https://blog.evendyne.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store