Concurrency in Go: Goroutines, Channels and WaitGroups
One of Go's defining features is its built-in support for concurrency. Unlike traditional multithreading models in languages like Java or C++ that rely on heavy OS threads and complex locking mechanisms, Go utilizes **goroutines** and **channels** based on Tony Hoare's Communicating Sequential Processes (CSP) model.
This comprehensive guide covers spawning lightweight goroutines, synchronizing execution with `sync.WaitGroup`, communicating safely across channels, and preventing common concurrency pitfalls like deadlocks.
1. Goroutines: Lightweight Threads of Execution
A **goroutine** is a lightweight thread managed by the Go runtime. Initiated simply by prepending the `go` keyword to a function call, goroutines consume as little as 2KB of stack memory (compared to ~1MB for standard OS threads), allowing hundreds of thousands of concurrent tasks to run simultaneously.
package main
import (
"fmt"
"time"
)
func printMessage(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(500 * time.Millisecond)
}
}
func main() {
// Launching a background goroutine
go printMessage("Async Goroutine")
// Main function execution
printMessage("Main Function")
}
2. Synchronizing Execution with `sync.WaitGroup`
In Go, if the `main` function exits, all active background goroutines are terminated immediately, regardless of whether they finished executing. To ensure the main thread waits for background tasks to complete, we use `sync.WaitGroup`.
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Decrement counter when worker finishes
fmt.Printf("Worker %d starting...\n", id)
time.Sleep(1 * time.Second)
fmt.Printf("Worker %d done.\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // Increment counter for each goroutine
go worker(i, &wg)
}
wg.Wait() // Block until counter returns to 0
fmt.Println("All workers completed successfully.");
}
3. Communicating via Channels
"Do not communicate by sharing memory; instead, share memory by communicating." **Channels** provide a typed conduit through which goroutines can send and receive values.
- Unbuffered Channels: Synchronization point where send and receive blocks until both parties are ready.
- Buffered Channels: Allows sending a predefined number of values without blocking until the buffer is full.
package main
import "fmt"
func computeSum(numbers []int, ch chan int) {
sum := 0
for _, n := range numbers {
sum += n
}
ch <- sum // Send sum into channel
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
ch := make(chan int)
go computeSum(nums[:5], ch)
go computeSum(nums[5:], ch)
sum1 := <-ch
sum2 := <-ch
fmt.Printf("Total Sum: %d\n", sum1 + sum2);
}
Conclusion
Mastering Go's concurrency model—leveraging lightweight **goroutines**, safe synchronization via **WaitGroups**, and data transfer through **channels**—enables developers to build exceptionally fast, resilient concurrent systems.