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)
}
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)
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
}()
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
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
}
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
}
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!
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)
}
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)
Next, we send the value 29
onto our c
Channel
c <- 29
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)
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)
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)
}
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)
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)
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)
}
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)
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)
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
}
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)
}
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()
}
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
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
I create a variable with the identifier wg
and assign it the value of a new WaitGroup
var wg sync.WaitGroup
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)
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()
}()
}
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()
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
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()
}
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
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()
}()
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
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. 👋🏻