Skip to main content
Go intermediate Lesson 14 of 25

Goroutines in Go

Launch goroutines with the go keyword, understand goroutine lifecycle, coordinate with sync.WaitGroup, and tune concurrency with GOMAXPROCS.

Launching Goroutines

The go keyword is the simplest concurrency primitive in Go. It launches any function call as a goroutine — a lightweight unit of execution that runs concurrently with the rest of the program. Unlike spawning OS threads, goroutines are cheap to create (starting at ~2 KB of stack) and managed entirely by the Go runtime scheduler, so you can launch thousands of them without running out of memory or bogging down the OS.

func sayHello(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

func main() {
    go sayHello("Alice") // launched concurrently — does not block
    go sayHello("Bob")
    go sayHello("Charlie")

    time.Sleep(100 * time.Millisecond) // wait for goroutines to finish
    // Output order is non-deterministic — goroutines run in parallel
}

Using time.Sleep to wait for goroutines is a bad practice — it’s a guess, not a guarantee. Use sync.WaitGroup instead.

Anonymous Goroutines

You can launch a goroutine from an anonymous function (closure) defined inline. This is convenient, but closures capture variables by reference — a common source of bugs in loops. Always pass loop variables explicitly as arguments to avoid all goroutines sharing the same final value.

go func() {
    fmt.Println("from anonymous goroutine")
}()

// Correct: pass i as an argument — each goroutine gets its own copy
for i := 0; i < 3; i++ {
    go func(n int) {
        fmt.Println(n) // prints 0, 1, 2 in some order
    }(i)
}

// Bug: all goroutines capture the same variable i
// By the time they run, the loop has finished and i == 3
for i := 0; i < 3; i++ {
    go func() {
        fmt.Println(i) // likely prints 3, 3, 3
    }()
}

sync.WaitGroup

WaitGroup solves the core coordination problem: how does the main goroutine know when all worker goroutines are done? It works like a counter — you increment it before launching each goroutine, decrement it when each finishes, and block until the counter reaches zero. This gives you a precise, race-free way to wait for a dynamic number of concurrent tasks.

import "sync"

func main() {
    var wg sync.WaitGroup

    names := []string{"Alice", "Bob", "Charlie", "Diana"}

    for _, name := range names {
        wg.Add(1) // increment BEFORE launching the goroutine
        go func(n string) {
            defer wg.Done() // decrement when this goroutine exits
            // simulate work
            time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
            fmt.Printf("Processed: %s\n", n)
        }(name) // pass name as argument — avoids closure capture bug
    }

    wg.Wait() // blocks until all goroutines have called Done()
    fmt.Println("All done")
}

Rules for WaitGroup:

  • Call Add before launching the goroutine, not inside it
  • Call Done exactly once per goroutine — use defer wg.Done() at the start
  • Never copy a WaitGroup after first use

Goroutine Lifecycle

A goroutine lives until its function returns. The Go runtime will garbage collect a finished goroutine automatically. However, if a goroutine blocks permanently — waiting on a channel that never sends, or a lock that never releases — it is never reclaimed. This is a goroutine leak: memory and scheduler resources consumed forever. The fix is to always give goroutines a way out via context.Context or a done channel.

// Goroutine leak — goroutine blocks forever waiting on an unwritten channel
func leaky() {
    ch := make(chan int) // unbuffered, nobody ever sends to it
    go func() {
        val := <-ch // blocks forever — this goroutine leaks
        fmt.Println(val)
    }()
    // ch goes out of scope, but the goroutine is still alive
}

// Fix: give the goroutine a cancellation path via context
func nonLeaky(ctx context.Context) {
    ch := make(chan int)
    go func() {
        select {
        case val := <-ch:
            fmt.Println(val)
        case <-ctx.Done(): // exits cleanly when context is cancelled
            fmt.Println("goroutine cancelled")
            return
        }
    }()
}

runtime.GOMAXPROCS

By default, Go uses all available CPU cores to run goroutines in parallel. GOMAXPROCS controls exactly how many OS threads execute Go code simultaneously. In the vast majority of programs you should leave it at the default — the scheduler is highly optimised. Knowing it exists matters when profiling CPU-bound workloads or running inside containers where the core count may be miscounted.

import "runtime"

func main() {
    fmt.Println(runtime.NumCPU())        // number of logical CPUs, e.g. 8
    fmt.Println(runtime.GOMAXPROCS(0))   // 0 = query current value without changing it

    // Explicitly set to 4 OS threads (rarely needed)
    runtime.GOMAXPROCS(4)

    // In most programs, leave it at the default — the Go scheduler handles this well
}

Detecting Race Conditions

A race condition occurs when two goroutines access the same variable concurrently and at least one access is a write. The result is undefined — you might get wrong values, crashes, or seemingly correct output that breaks under load. Go ships a built-in race detector: compile with -race and any data race is reported with the exact goroutine stack traces involved.

// counter.go — has a data race
var counter int

func increment() {
    counter++ // NOT atomic — expands to: read, add 1, write (three separate ops)
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment() // concurrent reads and writes to counter
        }()
    }
    wg.Wait()
    fmt.Println(counter) // result is unpredictable — could be anything < 1000
}
go run -race main.go
# DATA RACE
# Write at 0x... by goroutine 7:
#   main.increment()
# Previous write at 0x... by goroutine 6:
#   main.increment()

Fix it with sync/atomic or a mutex:

import "sync/atomic"

var counter int64

// atomic.AddInt64 is a single indivisible CPU instruction — no race possible
func increment() {
    atomic.AddInt64(&counter, 1)
}

Practical Example — Parallel Web Fetcher

This example shows the core goroutine + WaitGroup pattern applied to a real task: fetching multiple URLs concurrently. Without goroutines, three 200ms requests would take ~600ms sequentially. With goroutines, all three run in parallel and the total time is ~200ms — the time of the slowest single request.

package main

import (
    "fmt"
    "net/http"
    "sync"
    "time"
)

type Result struct {
    URL        string
    StatusCode int
    Duration   time.Duration
    Err        error
}

func fetch(url string) Result {
    start := time.Now()
    resp, err := http.Get(url)
    if err != nil {
        return Result{URL: url, Err: err, Duration: time.Since(start)}
    }
    defer resp.Body.Close()
    return Result{
        URL:        url,
        StatusCode: resp.StatusCode,
        Duration:   time.Since(start),
    }
}

func fetchAll(urls []string) []Result {
    results := make([]Result, len(urls)) // pre-allocated — each goroutine writes its own index
    var wg sync.WaitGroup

    for i, url := range urls {
        wg.Add(1)
        go func(idx int, u string) {
            defer wg.Done()
            results[idx] = fetch(u) // safe: each goroutine writes a different index
        }(i, url)
    }

    wg.Wait() // block until all fetches complete
    return results
}

func main() {
    urls := []string{
        "https://go.dev",
        "https://pkg.go.dev",
        "https://golang.org",
    }

    results := fetchAll(urls)
    for _, r := range results {
        if r.Err != nil {
            fmt.Printf("ERROR %s: %v\n", r.URL, r.Err)
        } else {
            fmt.Printf("%d %s (%v)\n", r.StatusCode, r.URL, r.Duration)
        }
    }
}

Frequently Asked Questions

How are goroutines different from threads?
OS threads are heavy — each takes ~1–8 MB of stack and requires a kernel context switch. Goroutines start with a 2–8 KB stack that grows as needed, and the Go scheduler multiplexes many goroutines onto a small number of OS threads. You can run hundreds of thousands of goroutines where you could only run thousands of threads.
What happens when a goroutine panics?
An unrecovered panic in any goroutine crashes the entire program. You can recover from a panic using defer and recover() inside that goroutine, but you cannot recover from a panic in another goroutine.
How do I wait for goroutines to finish?
Use sync.WaitGroup. Call wg.Add(1) before launching, wg.Done() when the goroutine finishes (typically via defer), and wg.Wait() in the main goroutine to block until all are done.