Learning Go: Goroutines, WaitGroups & Channels
Published on Monday, Apr 20, 2026
1 min read
Go approaches concurrency in a way that feels deceptively simple, yet extremely powerful. Instead of wrestling with threads, locks, and race conditions, Go gives you a cleaner abstraction: goroutines and channels.
This guide walks through three core primitives:
- Goroutines
- WaitGroups
- Channels
Goroutines — Lightweight Concurrency
A goroutine is a lightweight thread managed by the Go runtime.
Unlike OS threads:
- They start with ~2KB stack
- They grow dynamically
- You can create thousands or even millions of them
Threads vs Goroutines
| Feature | Threads | Goroutines |
|---|---|---|
| Managed by | OS | Go runtime |
| Memory | ~1MB | ~2KB |
| Cost | Expensive | Cheap |
| Scheduling | OS | Go scheduler |
Go uses an M:N scheduler: Many goroutines are multiplexed onto fewer OS threads.
Example
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from goroutine")
}
func main() {
go sayHello()
fmt.Println("Hello from main")
time.Sleep(1 * time.Second)
}
Key point: When main() exits, all goroutines terminate immediately.
WaitGroup — Coordination Primitive
When multiple goroutines are running, you often need to wait for all of them to complete.
sync.WaitGroup helps coordinate this.
Analogy
Think of a watchman:
- You tell him how many tasks are running
- Each task reports when done
- He lets you proceed only when all are complete
Example
package main
import (
"fmt"
"sync"
)
func readFile(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("Reading file", id)
}
func main() {
var wg sync.WaitGroup
totalFiles := 5
wg.Add(totalFiles)
for i := 1; i <= totalFiles; i++ {
go readFile(i, &wg)
}
wg.Wait()
fmt.Println("All files processed")
}
Common Mistakes
- Forgetting wg.Done()
- Calling wg.Add() inside goroutine
Channels — Communication Mechanism
Channels allow safe communication between goroutines.
Instead of sharing memory directly, Go encourages: “Share memory by communicating”
Syntax
ch <- value // send
value := <-ch // receive
Example
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 10; i++ {
ch <- i
}
close(ch)
}()
for val := range ch {
fmt.Println(val)
}
}
Behavior
- Channels are blocking by default
- Send blocks until receive
- Receive blocks until send
Buffered Channels
ch := make(chan int, 3)
Allows limited non-blocking sends.
Summary
- Goroutines are lightweight concurrent workers
- WaitGroups coordinate execution
- Channels enable safe communication
Go’s concurrency model emphasizes simplicity and correctness over complexity.
Next Steps
- Explore select statements
- Learn context for cancellation
- Build producer-consumer systems