Concurrency in Go - Using Goroutines and Wait Groups

Martin Cartledge - Jul 29 '20 - - Dev Community

This is the eleventh entry of my weekly series Learning Go. Last week I talked about Sorting Data in Go. This week I will be talking about how Concurrency works in Go. Before I really dive into the topic of Concurrency, I feel that I need to make some differences between Concurrency and Parallelism since they often times are confused with each other. I will also explain a few pieces of the Go language that allows us to use Concurrency. These pieces are Go Routines and Go Statements.

Concurrency vs. Parallelism

Concurrency

the ability for various parts of a program to run (executed) out-of-order, or in partial order without affecting the final result

  • Designed to handle more than one task at once
  • Able to make progress on more than one task at a time, but not simultaneously
  • Can work with 1-core CPU; however, the system decides when to start each task
  • Concurrency is made difficult by the subtleties required to implement correctly across shared variables

Parallelism

the ability to perform several computations at the same time (simultaneously)

  • Designed to do more than one task at once
  • Able to execute multiple tasks in a multi-core CPU
  • Must have multi-core CPU

Concurrency in Go

  • Shared values are passed around on Channels
  • Never shared on separate threads of execution
  • Does not communicate by sharing memory, share memory by communicating

Go Routines

  • Multiplexed
  • Used with functions or methods
  • Used with the go keyword

Go Statements

  • Starts with the execution of a function call as an independent concurrent thread of control or Go routine within the same address space
  • Must be a function or a method call
  • If the function has return values they are discarded when the function completes

Before we jump into how to use Concurrency in Go, I think we should discuss a few of the pillars around writing Concurrent code in Go. A few of these pillars are Goroutines and Channels

Goroutines

a lightweight thread of execution

So, what is a Goroutine and why should I care about them? Here are a few things to consider:

  • Goroutines are non-blocking (asynchronous)
  • Due to being asynchronous, multiple goroutines can run concurrently (multiple pieces ran at the same time without affecting the final result)
  • If you wish to wait for your Goroutine to finish before you continue, you can use a WaitGroup (we will cover this later in the post)

Let's take a look at an example of using a traditional function (blocking) with a few Goroutines (non-blocking) to better illustrate their place in our Go code

package main

import (
    "fmt"
    "time"
)

func countToFive(wasCalledBy string) {
    for i := 0; i < 5; i++ {
        fmt.Println(wasCalledBy, i)
    }
}

func countToThree(wasCalledBy string) {
    for i := 0; i < 3; i++ {
        fmt.Println(wasCalledBy, i)
    }
}

func main() {
    countToFive("direct - blocking")

    go countToFive("I am a goroutine!")

    go countToThree("I am another goroutine!")

    time.Sleep(time.Second)
    fmt.Println("exit program")
}
// direct - blocking 0
// direct - blocking 1
// direct - blocking 2
// direct - blocking 3
// direct - blocking 4
// using another goroutine! 0
// using a goroutine 0
// using a goroutine 1
// using a goroutine 2
// using a goroutine 3
// using a goroutine 4
// using another goroutine! 1
// using another goroutine! 2
Enter fullscreen mode Exit fullscreen mode

Let's walk through what is happening:

Quick note: we are importing the time package because we need to wait for a second in order to allow our goroutines to finish. Remember, they are not blocking (synchronous); therefore, we need to wait for them to finish their computations.

We import the time package that we will use in this example just to wait for our Goroutines to finish. I have found it is much more common to use a WaitGroup, we will discuss these later in the post

import (
    "fmt"
    "time"
)
Enter fullscreen mode Exit fullscreen mode

Next, we create two functions, countToFive and countToThree, both of these expect a single parameter wasCalledBy which is of type string.

func countToFive(wasCalledBy string) {
    for i := 0; i < 5; i++ {
        fmt.Println(wasCalledBy, i)
    }
}

func countToThree(wasCalledBy string) {
    for i := 0; i < 3; i++ {
        fmt.Println(wasCalledBy, i)
    }
}
Enter fullscreen mode Exit fullscreen mode

Calling our Goroutine with the wasCalledBy argument will help illustrate how Go executes these Goroutines

Inside of func main I call the countToFive function directly, without making use of a Goroutine

func main() {
    countToFive("direct - blocking")
Enter fullscreen mode Exit fullscreen mode

As the argument says, I am not using the go keyword and creating a Goroutine; therefore, this code will be synchronous and block our thread of execution

On the next line, I create a Goroutine. I do so very easily by calling the same function and placing the go keyword in front of the function identifier

go countToFive("I am a goroutine!")
Enter fullscreen mode Exit fullscreen mode

Next, I fire off another goroutine by placing the go keyword in front of the function identifier.

go countToThree("I am another goroutine!")
Enter fullscreen mode Exit fullscreen mode

In order to ensure that our Goroutines finish, we are using the time package in order for us to sleep for one second.

time.Sleep(time.Second)
Enter fullscreen mode Exit fullscreen mode

What do you expect to see in our logs? What order do you expect these Goroutines to run in?

The output might surprise you, however, I hope it will illuminate some of the power that Goroutines can give you.

// direct - blocking 0
// direct - blocking 1
// direct - blocking 2
// direct - blocking 3
// direct - blocking 4
// using another goroutine! 0
// using a goroutine 0
// using a goroutine 1
// using a goroutine 2
// using a goroutine 3
// using a goroutine 4
// using another goroutine! 1
// using another goroutine! 2
Enter fullscreen mode Exit fullscreen mode

The first 5 lines should not surprise you, we are calling a function without using a Goroutine; therefore, it runs in a synchronous (blocking) manner.

The next few lines should raise some eyebrows, however. Do you notice that our countToThree function logged an item before countToFive did?

This is the power of Goroutines. The Go runtime allows us to write code that can be executed in a concurrent way.

WaitGroups

Using WaitGroups to wait for multiple goroutines to finish is common practice when using Go. WaitGroup is a type which is a part of the sync package.

There are a few functions that come with WaitGroups that you will use often. The most important of these are Add and Done. Let me walk you through how to use these.

package main

import (
    "fmt"
    "sync"
)

func countRoutine(upTo int, wg *sync.WaitGroup) {
    for i := 0; i < upTo; i++ {
        fmt.Println("count routine: ", i)
    }
    wg.Done()
}

func count(upTo int) {
    for i := 0; i < upTo; i++ {
        fmt.Println("count: ", i)
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(1)

    go countRoutine(10, &wg)

    count(5)

    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

Let's walk through what is happening, line-by-line:

As I mentioned earlier, since the WaitGroup is called from the sync package, we need to make sure we import it

import (
    "fmt"
    "sync"
)
Enter fullscreen mode Exit fullscreen mode

Next, we create a function with the identifier countRoutine which has two parameters: upTo of type string and wg which is a pointer to sync.WaitGroup

Note: WaitGroups can only be passed to functions as a pointer

Inside of this function, we create a for loop and iterate until we reach the upTo value that we pass into the function. In order for WaitGroup to know that a goroutine is complete, we run the Done() function

func countRoutine(upTo int, wg *sync.WaitGroup) {
    for i := 0; i < upTo; i++ {
        fmt.Println("count routine: ", i)
    }
    wg.Done()
}
Enter fullscreen mode Exit fullscreen mode

We then create a function with the identifier count with a single parameter upTo of type int. We have the same for loop inside of this function, the only difference is we are not using a WaitGroup because this is not a Goroutine

func count(upTo int) {
    for i := 0; i < upTo; i++ {
        fmt.Println("count: ", i)
    }
}
Enter fullscreen mode Exit fullscreen mode

Inside of the main function, we create a variable using the var keyword and give this variable the identifier wg of type sync.WaitGroup

var wg sync.WaitGroup
Enter fullscreen mode Exit fullscreen mode

In order to tell the Go runtime about our WaitGroup we have to add one. We can do this easily by using the Add() function that takes an argument of type int that signifies how many WaitGroups you would like to add. For this example we only have one Goroutine, so we will just add one:

wg.Add(1)
Enter fullscreen mode Exit fullscreen mode

Next, we use the go keyword to launch countRoutine as a Goroutine and pass 10 as our upTo argument and a WaitGroup pointer (&wg) as our wg argument

go countRoutine(10, &wg)
Enter fullscreen mode Exit fullscreen mode

We call the count function which will be a synchronous, blocking function

count(5)
Enter fullscreen mode Exit fullscreen mode

This might be one of the most important pieces to remember. As you see we are calling a Wait() function on the last line inside of main. This function lets the Go runtime know that we have Goroutines that is not complete yet and to keep our program running.

wg.Wait()
Enter fullscreen mode Exit fullscreen mode

As I mentioned earlier, the way we let the Go runtime know that our Goroutine is complete is by calling the Done() function at the end of our Goroutine. Once we do this, the Go runtime knows it can exit the program.

wg.Done()
Enter fullscreen mode Exit fullscreen mode

In Summary

By using the power of Goroutines paired with the help of WaitGroups, we can write concurrent code in Go. Pretty cool, huh? I have broken up this topic into two pieces because I have a lot more to show you about writing concurrent Go code and the tools that Go gives us to use. Next week I will be talking about Channels, Mutex, and Race Conditions. See you then!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .