Doing Many Things at Once, Easily
Imagine you need to check whether 200 servers are responding to health checks. If you do this one at a time, waiting for each server to respond (or time out) before moving to the next, the total time is 200 × (response time). If each check takes up to 5 seconds, that is potentially 1,000 seconds — over 16 minutes.
The solution is to fire all 200 checks simultaneously and collect results as they arrive. In most languages, this requires threads, thread pools, or async/await machinery that is complex to set up correctly. In Go, it requires one keyword.
Goroutines are Go's unit of concurrent execution. They are managed by the Go runtime, not the operating system. A goroutine starts with roughly 2KB of stack memory (compared to ~1MB for an OS thread) and the runtime multiplexes thousands of goroutines onto a small pool of OS threads automatically. You can run tens of thousands of goroutines on a single machine without exhausting memory.
Starting a goroutine is as simple as prefixing a function call with go:
package main
import (
"fmt"
"time"
)
func checkServer(hostname string) {
fmt.Printf("Checking %s...\n", hostname)
time.Sleep(500 * time.Millisecond) // Simulate network round-trip
fmt.Printf("%s: OK\n", hostname)
}
func main() {
servers := []string{"web01", "web02", "web03", "db01", "cache01"}
for _, server := range servers {
go checkServer(server) // Each check runs concurrently
}
// Wait for goroutines to finish
time.Sleep(2 * time.Second)
fmt.Println("All checks dispatched")
}
When you run this, the output from different servers appears interleaved — they are genuinely running at the same time. The total elapsed time is roughly 500ms, not 2,500ms.
The time.Sleep at the end is a crude synchronization mechanism used here for illustration only. In real code, you use channels or sync.WaitGroup to wait for goroutines properly (covered next).
sync.WaitGroup — the right way to wait
A WaitGroup is a counter. You increment it before starting each goroutine, decrement it when each goroutine finishes, and block in the main function until the counter reaches zero.
package main
import (
"fmt"
"sync"
"time"
)
func checkServer(hostname string, wg *sync.WaitGroup) {
defer wg.Done() // Decrement counter when this function returns
fmt.Printf("Checking %s...\n", hostname)
time.Sleep(500 * time.Millisecond)
fmt.Printf("%s: OK\n", hostname)
}
func main() {
servers := []string{"web01", "web02", "web03", "db01", "cache01"}
var wg sync.WaitGroup
for _, server := range servers {
wg.Add(1) // Increment before starting goroutine
go checkServer(server, &wg)
}
wg.Wait() // Block until all goroutines call wg.Done()
fmt.Println("All checks complete")
}
This is the idiomatic pattern for fan-out work in Go. Notice defer wg.Done() — the defer keyword ensures Done() is called even if the function panics or returns early due to an error.