DEV Community

Cover image for Concurrency in Go - Using Channels and Handling Race Conditions
Martin Cartledge
Martin Cartledge

Posted on • Originally published at martincartledge.io

Concurrency in Go - Using Channels and Handling Race Conditions

This is the twelfth entry of my weekly series Learning Go. Last week I talked about using Goroutines and WaitGroups to make use of concurrent patterns in Go. This week I will continue to expand on the topic of concurrency by showing you how to use a few more useful features of Go: Channels and Mutex. I will also walk you through how you can identify and fix potential race conditions in your code. Let's get to it!

Channels

a "pipe" that allows you to send and receive values in your concurrent Goroutines

Essentially, Channels can be thought of as messengers that you can send to various Goroutines to deliver or retrieve values

  • Channels block
  • They are synchronized
  • They have to pass or receive the value at the same time

Channels have two important pieces of syntax <- and ->

When you want to send data onto a Channel you use this syntax channel <-

When you want to retrieve data from a Channel you use this syntax <- channel

Let me show you a quick example to see Channels in action

package main

import (
    "fmt"
)

func main() {
    c := make(chan int)

    go func() {
        c <- 29
    }()

    fmt.Println(<-c)
}
Enter fullscreen mode Exit fullscreen mode

Inside of our func main we use the short declaration operator to create a new variable with the identifier c. We use the make keyword to create a new chan which will be of type int

c := make(chan int)
Enter fullscreen mode Exit fullscreen mode

Next, we create an anonymous Goroutine. Inside of this Goroutine, using the c <- syntax we are sending the value 29 onto our c channel. We use the () to immediately run this Goroutine

go func() {
    c <- 29
}()
Enter fullscreen mode Exit fullscreen mode

We use the <- c syntax to get the value off of our channel and print the value of c. When we go, it should be no surprise that we get the value 29. Cool! We have successfully sent data to a channel and received a value from a channel.

fmt.Println(<-c)
// 29
Enter fullscreen mode Exit fullscreen mode

What if we want to limit the amount of data that can be sent onto a Channel we create? Go makes this possible by using a feature called Channel Buffering

By default, Channels are unbuffered, meaning that there is no limit to the amount of data that can be sent and stored on a Channel. As you can imagine, this could get out of hand fairly quickly. Whenever possible, it is always a good idea to use Channel Buffering.

Let me show you an example:

package main

import (
    "fmt"
)

func main() {
    chat := make(chan string, 5)

    chat <- "Hey!"
    chat <- "How's it going?"
    chat <- "Doing well"
    chat <- "I started watching Dark"
    chat <- "It's so good"

    fmt.Println(<- chat)
    fmt.Println(<- chat)
    fmt.Println(<- chat)
    fmt.Println(<- chat)
    fmt.Println(<- chat)

    // Hey!
    // How's it going?
    // Doing well
    // I started watching Dark
    // It's so good
}
Enter fullscreen mode Exit fullscreen mode

This should right be a surprise right? As you can see we have 5 messages sent to chat and have successfully used fmt.Println() to print out each entry. But what happens if we try to add another entry to chat?

But what happens if I try to add another string value to our Channel? Let's take a look:

package main

import (
    "fmt"
)

func main() {
    chat := make(chan string, 5)

    chat <- "Hey!"
    chat <- "How's it going?"
    chat <- "Doing well"
    chat <- "I started watching Dark"
    chat <- "It's so good"
    // added message
    chat <- "No spoilers please!"

    fmt.Println(<- chat)
    fmt.Println(<- chat)
    fmt.Println(<- chat)
    fmt.Println(<- chat)
    fmt.Println(<- chat)
    // added log
    fmt.Println(<- chat)

    // Hey!
    // How's it going?
    // Doing well
    // I started watching Dark
    // It's so good
}
Enter fullscreen mode Exit fullscreen mode

This is the same code, except for this time we are trying to add No spoilers please! onto our chat Channel. Go does not like this. When we try to run our application, Go gives us this error:

fatal error: all goroutines are asleep - deadlock!
Enter fullscreen mode Exit fullscreen mode

Although errors are commonly looked to as nuisances, Go thoughtfully introduces them to help you prevent unwanted side effects in your application.

Directional Channels

When you start to use Channels, you quickly realize how helpful creating Channels that can only receive values or only send values. Go bakes in this feature into the chan type, let me show you a few examples:

package main

import (
    "fmt"
)

func main() {
    c := make(chan <- int, 1)
    c <- 29
    fmt.Println(<-c)

    fmt.Printf("%T\n", c)
}
Enter fullscreen mode Exit fullscreen mode

Inside of func main we create a new variable with the identifier c which is of type chan which will contain values of type int

Notice we make c a buffered channel by passing the value 1 as the second argument to make. In this case, our buffered channel can only contain one value.

c := make(chan <- int, 1)
Enter fullscreen mode Exit fullscreen mode

Next, we send the value 29 onto our c Channel

c <- 29
Enter fullscreen mode Exit fullscreen mode

This seems pretty straight forward, doesn't it? When fmt.Println(<-c) is executed we should expect the value 29 to be printed, right? Nope. This is Directional Channels doing their job. Remember when we created our c Channel? We explicitly told Go that this c Channel would only be a sending Channel when we used this syntax chan <- int

fmt.Println(<-c)
Enter fullscreen mode Exit fullscreen mode

When we attempt to run this code, Go stops us and gives us a very informative error message

invalid operation: <-c (receive from send-only type chan<- int)
Enter fullscreen mode Exit fullscreen mode

Let me show you another example of using a Directional Channel

package main

import (
    "fmt"
)

func main() {
    c := make(<-chan int, 1)
    c <- 29
    fmt.Println(c)
}
Enter fullscreen mode Exit fullscreen mode

In this example we create a new variable with the identifier c which is of type chan which will contain values of type int; however, in this case, we are creating a Directional Channel that can only receive values

c := make(<-chan int, 1)
Enter fullscreen mode Exit fullscreen mode

Now, when we try to send the value 29 onto the c Channel, we get this error message

c <- 29
invalid operation: c <- 29 (send to receive-only type <-chan int)
Enter fullscreen mode Exit fullscreen mode

Directional Channels can play an important role in organizing your code. Now, you can explicitly create a Sending Channel and a Receiving Channel. If you add the peace of mind of making both of these Channels buffered, then you will gain a lot of predictability in your code.

Let me show you a more in-depth example of using buffered, directional channels

package main

import (
    "fmt"
)

func main() {
    c := make(chan int, 1)

    send(c)

    receive(c)

}

func send(c chan<- int) {
    c <- 29
}

func receive(c <-chan int) {
    fmt.Println(<-c)
}
Enter fullscreen mode Exit fullscreen mode

In this example we use a buffered channel and we create two functions: one that sends a value onto our channel and one that receives a value from our channel

First things first. We create a new variable with the identifier c which is of type chan which will contain values of type int. the 1 argument in our make function makes c a buffered channel

c := make(chan int, 1)
Enter fullscreen mode Exit fullscreen mode

Next, we call a function with the identifier send and we pass our c channel as the only argument, we do the same for a function with the identifier receive

send(c)

receive(c)
Enter fullscreen mode Exit fullscreen mode

We create a function with the identifier send that has a single argument that is a *sending channel* (channel that you can only send values to). This channel will contain values of type int. Inside of this function we send the value 29 onto our c channel

func send(c chan<- int) {
    c <- 29
}
Enter fullscreen mode Exit fullscreen mode

We create a function with the identifier receive that has a single argument that is a *receiving channel* (channel that you can only receive values from). This channel will contain values of type int. Inside of this function we receive the value 29 onto our c channel and print that value using the fmt package

func receive(c <-chan int) {
    fmt.Println(<-c)
}
Enter fullscreen mode Exit fullscreen mode

Mutex and Race conditions

So, we have learned about sending and receiving data across Channels, but what happens if multiple Goroutines need to access a shared piece of state? The reliability of our state can be compromised very easily, we do not want that.

Let me show you an example of when multiple Goroutines using a shared piece of state can provide unwanted results

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    counter := 0

    const gs = 5
    var wg sync.WaitGroup
    wg.Add(gs)

    for i := 0; i < gs; i++ {
        go func() {
            v := counter
            runtime.Gosched()
            v++
            counter = v
            fmt.Println("count:", counter)
            // count: 1
            // count: 1
            // count: 1
            // count: 1
            // count: 1
            wg.Done()
        }()
    }
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

Note: For the sake of this example, I am going to be using the runtime package to make use of the Gosched() method. This method will allow me to fire off a new Goroutine.

I want to make sure that I am importing the packages I am going to make use of; therefore, I import fmt, runtime, and the sync package

Inside of func main, we declare a new variable with the identifier counter that is assigned to the value 0

counter := 0
Enter fullscreen mode Exit fullscreen mode

Next, I create a variable with the identifier gs (this stands for Go Schedule, I will cover this shortly) that is assigned to the value 5

const gs = 5
Enter fullscreen mode Exit fullscreen mode

I create a variable with the identifier wg and assign it the value of a new WaitGroup

var wg sync.WaitGroup
Enter fullscreen mode Exit fullscreen mode

I am going to use the value of gs to iterate; therefore, I want to make sure that I Add 5 WaitGroups

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

Next, we create a for loop that will use the value of gs in the condition statement. Inside of this for loop we create an anonymous Goroutine and immediately invoke the Goroutine.

Inside of our anonymous Goroutine we declare a new variable with the identifier v and assigned the value of counter

We use the runtime package to invoke the Gosched() method, thus firing a new Goroutine

On the next line, we increment our v variable and assign the counter variable to the v variable

We use the fmt package to print out the value of counter and use the wg variable to call the Done() method which will let the Go runtime know that our WaitGroup is complete

for i := 0; i < gs; i++ {
    go func() {
        v := counter
        runtime.Gosched()
        v++
        counter = v
        fmt.Println("count:", counter)
        wg.Done()
    }()
}
Enter fullscreen mode Exit fullscreen mode

We repeat this process 5 times total

Outside of the for loop, we call the Wait() method that we access from the wg variable. The Wait() method prevents our main function from exiting

wg.Wait()
Enter fullscreen mode Exit fullscreen mode

Once our WaitGroups are all complete our main function exits

Did you notice what our for loop prints for the value of counter? Currently, the value is 1 each iteration. Why is that?

// count: 1
// count: 1
// count: 1
// count: 1
// count: 1
Enter fullscreen mode Exit fullscreen mode

Currently, we are immediately invoking 5 Goroutines and not telling the Go runtime what to do with them; therefore, they are running, accessing, and updating our counter state at random. There is no way to predict the order of these Goroutines. There is a way to fix this though!

The concept of only wanting a single Goroutine to access a piece of state at a time, thus avoiding conflicts is called Mutual Exclusion. The traditional name for the data structure that shares this methodology is called a Mutex

The sync package, provided by the Go standard library, offers us sync.Mutex with two important methods: Lock and Unlock

Let me show you an example of using Mutex

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {

    counter := 0

    const gs = 5
    var wg sync.WaitGroup
    wg.Add(gs)

    var mu sync.Mutex

    for i := 0; i < gs; i++ {
        go func() {
            mu.Lock()
            v := counter
            runtime.Gosched()
            v++
            counter = v
            fmt.Println("count:", counter)
            // count: 1
            // count: 2
            // count: 3
            // count: 4
            // count: 5
            mu.Unlock()
            wg.Done()
        }()
    }
    wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

The code above is the same except for in a few places. Let me illuminate the differences in our code while using a Mutex

In order for us to use a Mutex, we create a new variable with the identifier mu that has a value of a Mutex which we get from the sync package

var mu sync.Mutex
Enter fullscreen mode Exit fullscreen mode

On the first line inside of our anonymous Goroutine we use our mu variable to call the Lock() method. This method ensures exclusive access to our state. Once we are done updating our state, we call the Unlock method which is also supplied from our mu variable

go func() {
    mu.Lock()
    v := counter
    runtime.Gosched()
    v++
    counter = v
    fmt.Println("count:", counter)
    mu.Unlock()
    wg.Done()
}()
Enter fullscreen mode Exit fullscreen mode

Now we can see that our counter logs look a lot more like what we would expect

// count: 1
// count: 2
// count: 3
// count: 4
// count: 5
Enter fullscreen mode Exit fullscreen mode

In Summary

Pretty cool huh? We now understand what a Channel, Buffered Channel, and a Directional Channel is and how to use them effectively. We also learned about Mutexes and how we can make great use of them in our Go programs to ensure that we never contaminate our state and create any race conditions.

This concludes my Learning Go series. I hope you have enjoyed reading! Although this is the end of this series, you can expect many more posts on Go in the future! In the meantime, consider subscribing to my newsletter where I announce new posts and helpful tools and tips in the software industry. I occasionally throw in a picture or two of my Golden Retrievers as well. 👋🏻

Top comments (0)