Free — no signup required

Concurrency: Goroutines

3 min read

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.

This is one of 18 chapters

Every chapter is free — Kubernetes, Terraform, SRE, distributed systems, and more — with fast daily review built in.

Sign up free