06 — Goroutines

Go's concurrency building blocks: starting work with go, waiting for it with sync.WaitGroup, spotting data races, and protecting shared data with sync.Mutex. Every example below is live — edit it and press Run.

1. A goroutine

A goroutine is a function running concurrently with the rest of your program. You start one by writing go in front of a call. It returns immediately; the new goroutine runs alongside the caller.

If you've used POSIX threads, a goroutine fills a similar role but is far lighter — it's scheduled by the Go runtime onto a small pool of OS threads, so having thousands is normal. The catch: go f() does not wait. If main returns while goroutines are still running, the program exits and they just stop.

So we need a way to wait. The next section covers it properly; for now this example collects each goroutine's result into its own slot and prints them in order after they finish:

package main

import (
    "fmt"
    "sync"
)

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

    for i := range results {
        wg.Add(1)
        go func() {
            defer wg.Done()
            results[i] = fmt.Sprintf("worker %d done", i)
        }()
    }

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

Output:

worker 0 done
worker 1 done
worker 2 done

Why the order is stable here. Each goroutine writes to its own slot results[i], and we only print after Wait(). The goroutines still run in an unpredictable order — but since we read the results in index order, the output is the same every time. If you printed from inside the goroutines instead, the line order would vary from run to run.

2. Waiting with sync.WaitGroup

There's no handle to join like pthread_join. Instead a sync.WaitGroup counts outstanding goroutines:

After Wait() returns, every goroutine has finished, so their results are safe to use. Here we sum a slice concurrently — one goroutine per element, each adding into a shared total (protected by a mutex, which section 4 explains):

package main

import (
    "fmt"
    "sync"
)

func SumConcurrent(nums []int) int {
    var mu sync.Mutex
    var wg sync.WaitGroup
    total := 0
    for _, n := range nums {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            total += n
            mu.Unlock()
        }()
    }
    wg.Wait()
    return total
}

func main() {
    fmt.Println(SumConcurrent([]int{1, 2, 3, 4, 5}))
}

Output:

15

Newer shortcut: Go 1.25+ adds wg.Go(fn), which bundles Add(1), launching the goroutine, and Done() into one call. Try rewriting the loop above with it. The example in section 3 uses it.

3. Distinct slots need no lock

A lock is only needed when goroutines touch the same location. If each writes to a distinct slot, there's no conflict and no lock required. This ParallelMap pre-sizes the output and lets goroutine i write only out[i], using the wg.Go shortcut:

package main

import (
    "fmt"
    "sync"
)

func ParallelMap(in []int, f func(int) int) []int {
    out := make([]int, len(in))
    var wg sync.WaitGroup
    for i, v := range in {
        wg.Go(func() {
            out[i] = f(v)
        })
    }
    wg.Wait()
    return out
}

func main() {
    squares := ParallelMap([]int{1, 2, 3, 4}, func(x int) int { return x * x })
    fmt.Println(squares)
}

Output:

[1 4 9 16]

Order is preserved for free: index i always lands at out[i], regardless of which goroutine finishes first.

4. Data races, and the Mutex that fixes them

Goroutines share memory. If two of them write the same variable at once, the result is undefined — a data race. The classic shape is many goroutines doing total += n on a shared total with no protection: that's really "read, add, write," and interleaving two of them loses updates.

Go ships a detector for this. Running tests or programs with the -race flag reports races at runtime, with file and line numbers:

# a program with an unguarded shared write
go run -race .
==================
WARNING: DATA RACE
Write at 0x... by goroutine 7:
  main.main.func1()
...
==================

(That one isn't runnable here on purpose — a race has no single correct output, which is exactly the problem.) The fix is a sync.Mutex: a lock that lets only one goroutine into a section at a time. Lock() acquires it, Unlock() releases it, and pairing the unlock with defer makes it foolproof.

A clean pattern bundles the mutex with the data it protects and exposes safe methods — here a concurrency-safe counter hammered by 100 goroutines doing 100 increments each:

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

func main() {
    var c Counter
    var wg sync.WaitGroup
    for range 100 {
        wg.Go(func() {
            for range 100 {
                c.Inc()
            }
        })
    }
    wg.Wait()
    fmt.Println(c.Value())
}

Output:

10000

Because every Inc runs under the lock, all 10,000 increments are counted — remove the Lock/Unlock and run with -race to watch the detector complain and the total drop below 10000. Use pointer receivers on these methods: a Mutex must not be copied.

Recap

NeedTool
Start concurrent workgo f() (doesn't wait)
Wait for a groupsync.WaitGroup: Add/Done/Wait, or wg.Go
Find races-race flag
Protect shared datasync.Mutex with Lock/defer Unlock
No shared dataWrite distinct slots — no lock needed

Next: 07 — Channels, where goroutines pass data to each other instead of sharing it under a lock.