Go Goroutines Tutorial

In this tutorial we learn concurrency in Go with Goroutines that run alongside other functions instead of sequentially.

We learn how to invoke goroutines, what race conditions are and how to detect and fix them.

What are Goroutines

Goroutines are functions that run concurrently with other functions, meaning they run at the same time as each other instead of one after another.

Goroutines are lightweight threads that is managed by the Go runtime.

How to invoke Goroutines

To run a function or method concurrently, we prefix the function call with the keyword go .

Example:
package main

import "fmt"

func main() {

    go consoleLog("Hello")
}

func consoleLog(msg string) {

    fmt.Println(msg)
}

When we run the example above, we don’t see any output where our ‘Hello’ message should be printed. So what happened?

When we invoked ‘go consoleLog()’, control immediately returned to the next line of code without waiting for the function to finish. That is, after all, the point of a Goroutine.

The next line of code was the end of the main() function, so the program finished and quit.

Let’s add a simple delay before the end of the main() function.

Example:
package main

import "fmt"
import "time"

func main() {

    go consoleLog("Hello")
    go consoleLog("World")

    time.Sleep(100 * time.Millisecond)
}

func consoleLog(msg string) {

    fmt.Println(msg)
}

The goroutine now has time to execute.

Race conditions

A race condition occurs when two or more groutines try to access the same resource. One goroutine may try to access a variable while another is mutating it.

Example:
package main

import "fmt"

func main() {

    fmt.Println(getNum())
}

func getNum() int {

    i := -1

    go func() {

        i = 5
    }()

    return i
}

In the example above, the getNum() function tries to set the value of i in a separate goroutine. Then, it returns i without knowing whether the goroutine has completed.

Now the race is on! The value of i is being set to 5 and the value is being returned.

If the value is set first, it will print 5, otherwise the value will print -1.

That’s why it’s called a race. The value that’s returned depends on which of the two operations finish first.

Detecting a race with the -race flag

We can detect a race by adding -race to our Go command.

Syntax:
 go run -race filename.go

If we use the example from the previous section, we will get the following output.

Output:
-1
==================
WARNING: DATA RACE
Write at 0x00c000066068 by goroutine 7:
  main.getNum.func1()
      C:/Users/KHQ/Desktop/LearningGo/main.go:16 +0x3f

Previous read at 0x00c000066068 by main goroutine:
  main.getNum()
      C:/Users/KHQ/Desktop/LearningGo/main.go:19 +0x8f
  main.main()
      C:/Users/KHQ/Desktop/LearningGo/main.go:7 +0x3a

Goroutine 7 (running) created at:
  main.getNum()
      C:/Users/KHQ/Desktop/LearningGo/main.go:14 +0x81
  main.main()
      C:/Users/KHQ/Desktop/LearningGo/main.go:7 +0x3a
==================
Found 1 data race(s)
exit status 66

Fix the race: Blocking with waitgroups

We can block read access until a write operation has been completed.

Example:
package main

import "fmt"
import "sync"

func main() {

    fmt.Println(getNum())
}

func getNum() int {

    i := -1

    // init a waitgroup object
    var wg sync.WaitGroup

    // .Add(1) means that there is
    // 1 task we need to wait for
    wg.Add(1)

    go func() {

        i = 5
        // call .Done() to indicate the 1
        // task we were waiting for is
        // complete
        wg.Done()

    }()

    // .Wait() blocks until .Done() is called
    // the same number of times as the amount
    // of tasks we specified in .Add()
    wg.Wait()
    return i
}

In the example above, we import the ‘sync’ package to have access to WaitGroup.

We use three methods from WaitGroup:

  1. We specify that there is a number of tasks to wait for in Add(). In the example above, it’s only 1.
  2. After we change the variable i, we can call Done() to indicate that the 1 task is complete.
  3. We use Wait() to block the function from returning i until the goroutine has finished changing the variable.

Blocking with waitgroups is the most straightforward method of fixing a race.

Fix the race: Blocking with channels

A channel will act like a pipe through which we send typed values from one goroutine to another.

Ownership of the data is passed to and from different goroutines, so it avoids sharing memory, preventing race conditions.

Example:
package main

import "fmt"

func main() {

    fmt.Println(getNum())
}

func getNum() int {

    var i int

    // create a channel to push
    // a boolean to once we're
    // done mutating i
    done := make(chan bool)

    go func() {

        i = 5

        // we changed i to 5 so
        // push the bool value true
        done <- true
    }()

    // the statement below will block
    // until something is pushed into
    // the 'done' channel
    <-done

    return i
}

In the example above, we make a new boolean channel and store it in the variable ‘done’.

  1. We use <- done to block the return of i until we’ve finished mutating it.
  2. To indicate that we’re done with the operation (changing i to 5), we push a value into the ‘done’ channel to unblock it.

As we’ve mentioned earlier, a channel is a pipe through which we can send and receive values. We do this with the channel operator ( <- ), so the syntax would be:

Syntax:
// read from channel_name
data := <- channel_name

// write to channel_name
channel_name <- data

Summary: Points to remember

  • Goroutines are functions that run at the same time as other functions instead of one after the other.
  • To invoke a goroutine, we use the keyword go before the function/method/lambda call.
  • A race condition is when two or more goroutines try to modify the same data.
  • We can test for race conditions with the -race flag in the command line when compiling and running the application.
  • We can attempt to fix race conditions by blocking with WaitGroup from the ‘sync’ package.
    • Add() specifies the amount of tasks to wait for.
    • Done() specifies a task has been completed.
    • Wait() blocks until Done() is called.
  • We can also attempt to fix race conditions by blocking with channels.
    • <-channel_name will block until a value is pushed into it.
    • Values are sent and received with the channel operator ( <- ).