42. Goroutines

📖 Reading · 10 min
💡 Every code box below is live — edit it and hit Run.

A goroutine is a function running independently of the one that started it. You create one by putting go in front of a call. That's the entire syntax, and it's the feature Go is famous for.

Starting one

package main

import (
    "fmt"
    "sync"
)

func work(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Println("worker", id, "did its job")
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go work(i, &wg)
    }

    wg.Wait()
    fmt.Println("all workers finished")
}

go work(i, &wg) starts work and returns immediatelymain doesn't wait. Run this a few times: the three worker lines may appear in a different order each time, because they genuinely run concurrently.

sync.WaitGroup is what stops main from finishing first; it gets a full lesson shortly. For now, read it as "count three things, then wait for all three".

Why main must wait

When main returns, the program exits — every goroutine still running is killed instantly, without ceremony:

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    go func() {
        time.Sleep(50 * time.Millisecond)
        fmt.Println("this may never print")
    }()

    fmt.Println("main is done")

    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        time.Sleep(10 * time.Millisecond)
        fmt.Println("this always prints, because main waited")
    }()
    wg.Wait()
}

The first goroutine is a coin flip; the second is guaranteed. Never use time.Sleep to coordinate goroutines — it's a guess about timing, and a guess that's wrong on a loaded machine. Use a WaitGroup or a channel, both of which are coming up.

Goroutines are cheap

This is the part that changes how you design programs:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    fmt.Println("goroutines at start:", runtime.NumGoroutine())

    var wg sync.WaitGroup
    var mu sync.Mutex
    total := 0

    for i := 1; i <= 1000; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            mu.Lock()
            total += n
            mu.Unlock()
        }(i)
    }

    wg.Wait()
    fmt.Println("sum 1..1000 =", total)
    fmt.Println("goroutines at end:", runtime.NumGoroutine())
}

A thousand goroutines is unremarkable. A goroutine starts with about 2 KB of stack that grows on demand, versus an OS thread's fixed 1–8 MB. Go's scheduler multiplexes many goroutines onto a few OS threads, so switching between them doesn't involve the kernel.

Real Go servers run hundreds of thousands of goroutines. "One goroutine per request" is a normal architecture, not a clever one.

Anonymous goroutines and captured variables

The common form is an anonymous function:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    results := make([]string, 5)

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            results[n] = fmt.Sprintf("task %d done", n)
        }(i)
    }

    wg.Wait()
    for _, r := range results {
        fmt.Println(r)
    }
}

Two techniques worth copying:

Passing i as an argument. In Go 1.21 and earlier, every iteration shared one loop variable, so goroutines routinely all saw the final value — the closure trap from module 3, now with a race attached. Go 1.22 gives each iteration its own variable and fixed it, but passing the value explicitly is still the clearest way to say "this goroutine gets this number", and you'll see it everywhere.

Writing to results[n]. Each goroutine touches a different index, so there's no conflict and no lock needed — and the output comes out in order even though the work didn't. Pre-sizing a slice and having each worker own one slot is a genuinely useful pattern.

defer wg.Done() — always

package main

import (
    "fmt"
    "sync"
)

func mightFail(n int, wg *sync.WaitGroup) {
    defer wg.Done()

    if n%2 == 0 {
        fmt.Println(n, "-> returning early")
        return
    }
    fmt.Println(n, "-> full work")
}

func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 4; i++ {
        wg.Add(1)
        go mightFail(i, &wg)
    }
    wg.Wait()
    fmt.Println("done")
}

If Done isn't deferred, an early return — or a panic — means Wait() never returns and your program hangs forever. defer wg.Done() on the first line of the goroutine costs nothing and removes the whole class of bug.

Concurrency is not parallelism

Worth getting straight, because the words get used interchangeably everywhere else:

  • Concurrency is structure: your program is composed of independently executing pieces. That's what goroutines give you.
  • Parallelism is execution: several things literally running at the same instant. That needs multiple cores.
package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println("logical CPUs available:", runtime.NumCPU() > 0)
    fmt.Println("max parallel OS threads:", runtime.GOMAXPROCS(0) > 0)
}

A concurrent program runs correctly on one core — just not simultaneously. Go gives you concurrency; the runtime provides parallelism when the hardware has it. GOMAXPROCS defaults to the number of cores and you almost never change it.

Two rules to carry into the next lessons

  1. A goroutine that never exits is a leak. It holds its stack and everything it references forever. Always make sure there's a path for a goroutine to finish.
  2. A panic in any goroutine kills the whole program. A recover in main cannot save it — each goroutine needs its own deferred recover if it might panic.

Your turn

Run square concurrently for the numbers 1 to 5, writing each result into its own slot so the output is ordered:

[1 4 9 16 25]
package main

import (
    "fmt"
    "sync"
)

func main() {
    nums := []int{1, 2, 3, 4, 5}
    results := make([]int, len(nums))

    var wg sync.WaitGroup
    // start one goroutine per number, writing n*n into results[i]

    wg.Wait()
    fmt.Println(results)
}
package main

import (
    "fmt"
    "sync"
)

func main() {
    nums := []int{1, 2, 3, 4, 5}
    results := make([]int, len(nums))

    var wg sync.WaitGroup
    for i, n := range nums {
        wg.Add(1)
        go func(idx, val int) {
            defer wg.Done()
            results[idx] = val * val
        }(i, n)
    }

    wg.Wait()
    fmt.Println(results)
}

Next: how goroutines talk to each other.