If you’re just starting to program in Go, you’ve probably heard about goroutines. But what are they and how do they work? In this article, we’ll explore this fundamental concept of the language in a simple and practical way.
Starting with the Basics
Let’s start with a simple example:
package main
import (
"fmt"
)
func goroutine() {
fmt.Println("Hello world from goroutine!!")
}
func main() {
fmt.Println("Hello world from main function!!!!!")
go goroutine()
}
The keyword go (which gives the language its name) is used to start a goroutine for a given function. This means that the function will run CONCURRENTLY with the main function. But when we run this program, we notice something curious:
> Hello world from main function!!!!!
Hey, where’s the goroutine output? Why don’t we see “Hello world from goroutine!!”?
The Synchronization Problem
Every time we create a goroutine in Go, the Go runtime needs to call the scheduler (basically an orchestrator for goroutines). This takes some time until it allocates CPU resources for the created goroutine, among other tasks. The problem is that, until all this happens, the main function has already finished executing!
WaitGroups to the Rescue!
To solve this, we need a SYNCHRONIZATION mechanism that tells the main function to WAIT for the goroutine we created to finish executing before ending the program’s execution. This is where waitGroup come in (literally waiting groups) in Go.
Think of it as a counter (it’s almost literally that):
- It starts at 0
- While it’s greater than 0, we tell the main function to wait before continuing its execution and ending the program
- When it returns to 0, the program can finish
Let’s see how to implement it:
package main
import (
"fmt"
"sync"
)
func goroutine(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Hello world from goroutine!!")
}
func main() {
var wg sync.WaitGroup
fmt.Println("Hello world from main function!!!!!")
wg.Add(1)
go goroutine(&wg)
wg.Wait()
}
Understanding WaitGroup Operations
Ok, let’s understand what each waitGroup operation does:
- wg.Add(1) adds 1 to the counter, to signal that we’re creating a goroutine
- wg.Done() decreases the counter by 1, signaling that the goroutine has finished
- wg.Wait() asks the main function to wait until this counter reaches zero
When we call wg.Wait(), we’re explicitly telling the main function: “Hey, wait until all goroutines finish!”. Only after the counter reaches zero (meaning all goroutines have called wg.Done()), can the main function continue its execution.
Why Use Pointers?
Now we’re using the waitGroup type from Go’s sync library, which provides us with various synchronization tools. Notice that now the goroutine requires an extra parameter, which is a POINTER to a waitGroup variable to indicate that it finishes execution (we use the defer keyword to signal this only at the END of its execution).
Why do we pass by reference using & and the goroutine takes a pointer as an argument? Well, if we didn’t pass by reference, the goroutine would create a COPY of the waitGroup and wouldn’t use the same one as the main function, and we’d start the counter from zero! BOTH the main function and goroutine need to rely on the SAME counter, which is in a single memory location, not in various instances of it. By passing by reference, we use the SAME reference, the SAME memory address!
Watch Out for Deadlocks!
If we don’t call wg.Add(1) before creating the goroutine, we’ll have a serious problem. The counter would become negative when calling wg.Done() (remember it starts at zero), causing a DEADLOCK. Go’s runtime will give us an error in this case. Try removing/adding some calls of wg of this program, playing around with it, to see what happens!
Creating Multiple Goroutines
Now let’s make it a bit more complicated — how about creating several goroutines at once?
package main
import (
"fmt"
"sync"
)
func goroutine(num int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("goroutine ", num)
}
func main() {
var wg sync.WaitGroup
fmt.Println("Hello world from main function!!!!!")
for i := 1; i < 6; i++ {
wg.Add(1)
go goroutine(i, &wg)
}
wg.Wait()
}
The Non-Deterministic Behavior
Something interesting happens when we run this code several times. The order of the goroutines changes! For example:
> Hello world from main function!!!!!
> goroutine 5
> goroutine 3
> goroutine 2
> goroutine 1
> goroutine 4
This happens because we have NON-DETERMINISTIC behavior from Go’s scheduler. The CPU resources, allocated by the operating system, are always fluctuating, and the scheduler allocates these resources differently for each goroutine, seeking maximum efficiency.
Controlling Order with Channels
What if we want the goroutines to execute in sequence? For this, we’ll use channels — a special Go feature that allows passing values between goroutines.
package main
import (
"fmt"
"sync"
)
func goroutine(num int, wg *sync.WaitGroup, ch chan bool) {
defer wg.Done()
fmt.Println("waiting for permission to start …")
<-ch
fmt.Println("goroutine ", num)
ch <- true
}
func main() {
var wg sync.WaitGroup
ch := make(chan bool, 1)
ch <- true
fmt.Println("Hello world from main function!!!!!")
for i := 1; i < 6; i++ {
wg.Add(1)
go goroutine(i, &wg, ch)
ch <- true
fmt.Println("waiting for previous goroutine to finish …")
<-ch
}
wg.Wait()
}
Understanding Channels in Detail
Let’s analyze each part of the code with channels:
In the main function, we first create our channel:
ch := make(chan bool, 1)
ch <- true
Notice that we created a BUFFERED channel using the argument 1 in make(chan bool, 1). This is crucial because it indicates that the channel will store only ONE bool variable at a time. If we didn’t do this, it would store multiple bool variables (like an array) and we wouldn’t have the desired behavior — actually, the program would error out!
After creating the channel, we already pass the value true inside the main function itself using ch <- true. Note that we don’t use the = or := signs here, because channels are variables that behave differently from others.
In the for loop, we have special logic:
for i := 1; i < 6; i++ {
wg.Add(1)
go goroutine(i, &wg, ch)
ch <- true
fmt.Println("waiting for previous goroutine to finish …")
<-ch
}
We continue using waitGroup to synchronize the goroutines with the main function, but now the channels guarantee execution in order. For each goroutine:
1. We start it with go goroutine(i, &wg, ch)
2. We notify that it has permission to execute with ch <- true
3. We wait for the previous goroutine to finish execution with <-ch (notice we changed the order of operators, because instead of passing a value to the channel, we’re receiving!)
Inside the goroutine itself, we have similar logic:
func goroutine(num int, wg *sync.WaitGroup, ch chan bool) {
defer wg.Done()
fmt.Println("waiting for permission to start …")
<-ch
fmt.Println("goroutine ", num)
ch <- true
}
The goroutine now:
1. Receives an additional parameter ch chan bool
2. Uses defer wg.Done() to signal completion to the waitGroup
3. Waits to receive permission from the previous goroutine with <-ch (this line BLOCKS execution until receiving the true value!)
4. Executes its main code
5. Writes true to the channel with ch <- true, signaling to the main function that it can proceed and create the next goroutine
With this, we get the sequential output:
Hello world from main function!!!!!
waiting for permission to start …
goroutine 1
waiting for previous goroutine to finish …
waiting for permission to start …
goroutine 2
waiting for previous goroutine to finish …
waiting for permission to start …
goroutine 3
waiting for previous goroutine to finish …
waiting for permission to start …
goroutine 4
waiting for previous goroutine to finish …
waiting for permission to start …
goroutine 5
waiting for previous goroutine to finish …
Conclusion
Goroutines are a powerful Go feature that enables concurrency in a simple and elegant way. With wait groups and channels, we can precisely control how our goroutines behave and interact with each other.
Remember the main points:
- Use go to create goroutines
- Use waitGroup to synchronize with the main function
- Pass waitGroup by reference
- Channels (chan <type>) help control flow between goroutines
- Go’s scheduler is non-deterministic by default
Hope you enjoyed this tutorial! Share your thoughts on the comments and let’s connect: https://github.com/araujo88