By Edward Odero | Apprentice at z01 Kisumu
Introduction
One of the things that makes Go stand out from other programming languages is how it handles concurrency. If you've ever wanted your program to do multiple things at the same time — like fetching data from an API while also writing to a file — Go makes this surprisingly easy with something called goroutines.
In this article, I'll walk you through what goroutines are, how they work, and how to use them alongside channels to build concurrent programs in Go.
What is Concurrency?
Concurrency means doing multiple things at the same time (or at least, making progress on multiple tasks at once). Think of it like a chef cooking multiple dishes simultaneously — they don't finish one dish before starting another; they manage them all in parallel.
In Go, goroutines are the building blocks of concurrency.
What is a Goroutine?
A goroutine is a lightweight thread managed by the Go runtime. Unlike threads in other languages, goroutines are incredibly cheap — you can run thousands (even millions) of them without breaking a sweat.
You start a goroutine simply by using the go keyword before a function call:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from a goroutine!")
}
func main() {
go sayHello() // This runs concurrently
time.Sleep(1 * time.Second) // Wait for the goroutine to finish
fmt.Println("Main function done")
}
Output:
Hello from a goroutine!
Main function done
Note: Notice the
time.Sleep? Without it, themainfunction might finish and exit before the goroutine gets a chance to run. We'll see a better way to handle this shortly.
The Problem: Goroutines Don't Wait
When the main function exits, all goroutines are killed — even if they haven't finished. This is why we need a way to synchronize goroutines.
Solution 1: sync.WaitGroup
A WaitGroup lets you wait for a collection of goroutines to finish:
package main
import (
"fmt"
"sync"
)
func printMessage(msg string, wg *sync.WaitGroup) {
defer wg.Done() // Signal that this goroutine is done
fmt.Println(msg)
}
func main() {
var wg sync.WaitGroup
messages := []string{"Hello", "from", "goroutines!"}
for _, msg := range messages {
wg.Add(1) // Tell WaitGroup to expect one more goroutine
go printMessage(msg, &wg)
}
wg.Wait() // Block until all goroutines are done
fmt.Println("All goroutines finished!")
}
Output (order may vary):
from
Hello
goroutines!
All goroutines finished!
Notice the order may vary! That's concurrency in action — goroutines don't always finish in the order they were started.
Channels: Goroutines Talking to Each Other
Goroutines are great, but what if you want them to share data safely? That's where channels come in.
A channel is like a pipe — one goroutine puts data in, another takes it out.
package main
import "fmt"
func sum(nums []int, ch chan int) {
total := 0
for _, n := range nums {
total += n
}
ch <- total // Send result to channel
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
ch := make(chan int)
// Split the work between two goroutines
go sum(nums[:5], ch)
go sum(nums[5:], ch)
part1, part2 := <-ch, <-ch // Receive both results
fmt.Println("Total:", part1+part2)
}
Output:
Total: 55
Here, two goroutines each sum half the slice, then send their results back through the channel. Clean, safe, and concurrent.
Buffered vs Unbuffered Channels
By default, channels are unbuffered — a sender blocks until a receiver is ready (and vice versa).
You can create a buffered channel that holds a limited number of values without a receiver being ready:
ch := make(chan int, 3) // Buffered channel with capacity 3
ch <- 1 // Doesn't block
ch <- 2 // Doesn't block
ch <- 3 // Doesn't block
// ch <- 4 // This would block! Buffer is full
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
The select Statement
The select statement lets a goroutine wait on multiple channels at once — like a switch statement, but for channels:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "one"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "two"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Received:", msg1)
case msg2 := <-ch2:
fmt.Println("Received:", msg2)
}
}
}
Output:
Received: one
Received: two
select picks whichever channel is ready first — perfect for handling timeouts or multiple data sources.
Key Takeaways
| Concept | What it Does |
|---|---|
go func() |
Starts a goroutine |
sync.WaitGroup |
Waits for goroutines to finish |
chan |
Creates a channel for safe communication |
| Buffered channels | Allow sending without an immediate receiver |
select |
Listens on multiple channels at once |
Wrapping Up
Goroutines and channels are at the heart of what makes Go so powerful for building concurrent and scalable programs. The syntax is clean, the mental model is simple, and the performance is excellent.
As a beginner learning Go at z01 Kisumu, this was one of those topics that really made me appreciate the language. The fact that you can spin up thousands of goroutines with just the go keyword is honestly mind-blowing compared to threading in other languages.
If you're just starting with Go, I encourage you to experiment with goroutines — try building a small program that fetches data concurrently or processes a list in parallel. You'll be amazed at how approachable it is!
Feel free to drop a comment if you have questions or want to share your own experience with Go concurrency.