V
Vel·ToolKit
Simple · Fast · Ready to use
EN
Chapter 14 of 20

Goroutines

The go keyword, scheduling, WaitGroup, context cancellation

Starting a goroutine

The go keyword runs a function concurrently in a new goroutine. A goroutine is a "user-space thread" scheduled by the Go runtime: its initial stack is just 2 KB and grows on demand, so a single process can run hundreds of thousands of them — far cheaper than OS threads.

package main

import (
    "fmt"
    "time"
)

func handle(id int) {
    fmt.Println("handling", id)
}

func main() {
    go func() {
        fmt.Println("running in another goroutine")
    }()
    go handle(42)

    // give the goroutines a moment to run; replaced by WaitGroup next
    time.Sleep(100 * time.Millisecond)
}

The GMP Scheduling Model in One Sentence

A G (goroutine) is executed by an M (OS thread) and held in a runnable queue owned by a P (logical processor). GOMAXPROCS controls the number of Ps and defaults to the CPU core count; when a G blocks in a system call, Go automatically hands the P to another M to keep running the remaining Gs, so other goroutines aren't held back.

package main

import (
    "fmt"
    "runtime"
)

func main() {
    fmt.Println(runtime.NumCPU(), runtime.GOMAXPROCS(0))
    runtime.GOMAXPROCS(4) // usually no need to set manually
}

Waiting for Completion: sync.WaitGroup

After main exits, all goroutines are forcibly terminated. To wait for subtasks, use a WaitGroup: call Add before launching, Done via defer, and Wait blocks until the counter reaches zero.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            fmt.Println("task", i)
        }(i)
    }
    wg.Wait()
}

errgroup: a Concurrency Group with Error Propagation

golang.org/x/sync/errgroup is a "can return error" upgrade of WaitGroup: if any goroutine fails, the whole group is automatically cancelled. Common for concurrent RPCs and bulk crawling.

package main

import (
    "context"
    "log"
    "net/http"

    "golang.org/x/sync/errgroup"
)

func fetchAll(ctx context.Context, urls []string) error {
    g, ctx := errgroup.WithContext(ctx)
    for _, url := range urls {
        url := url
        g.Go(func() error {
            req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                return err
            }
            resp.Body.Close()
            return nil
        })
    }
    return g.Wait()
}

func main() {
    err := fetchAll(context.Background(), []string{
        "https://example.com",
        "https://example.org",
    })
    if err != nil {
        log.Printf("group failed: %v", err)
    }
}

Cancelling a goroutine with context

Go has no "kill a goroutine" API. The standard way to stop a subtask is to pass in a context.Context and have the subtask watch ctx.Done() to exit. This cooperative cancellation keeps resource cleanup controllable.

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d stop: %v\n", id, ctx.Err())
            return
        case <-time.After(200 * time.Millisecond):
            fmt.Printf("worker %d tick\n", id)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()
    go worker(ctx, 1)
    <-ctx.Done()
    time.Sleep(50 * time.Millisecond) // let the worker print its last line
}

panic Does Not Cross goroutines

A panic inside a goroutine that isn't caught by recover crashes the entire process, not just that goroutine. In production, add a fallback recover at the entry of every long-running goroutine.

package main

import (
    "log"
    "runtime/debug"
    "time"
)

func safeGo(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
            }
        }()
        fn()
    }()
}

func main() {
    safeGo(func() {
        panic("boom in goroutine")
    })
    time.Sleep(100 * time.Millisecond)
}

Race Detection

Concurrent writes to a shared variable must be guarded by a lock or a channel. Always enable the race detector during development — it records every memory access at runtime and finds reads/writes that lack a happens-before relationship.

$ go run -race main.go
$ go test -race ./...
$ go build -race -o app   # a race-enabled binary can also run in a canary env

sync.Mutex / RWMutex

Mutex is a mutual-exclusion lock; RWMutex allows multiple concurrent readers but an exclusive writer. Reach for RWMutex only when reads dominate and the critical section isn't tiny; otherwise a plain Mutex is faster.

package main

import (
    "fmt"
    "sync"
)

type Counter struct {
    mu sync.RWMutex
    m  map[string]int
}

func NewCounter() *Counter { return &Counter{m: map[string]int{}} }

func (c *Counter) Get(k string) int {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.m[k]
}

func (c *Counter) Inc(k string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k]++
}

func main() {
    c := NewCounter()
    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            c.Inc("hits")
        }()
    }
    wg.Wait()
    fmt.Println(c.Get("hits")) // 100
}

sync.Once and atomic

sync.Once guarantees a piece of initialization code runs only once (even if triggered by multiple goroutines simultaneously); sync/atomic provides lock-free integer/pointer operations for hot paths like counters and status bits.

package main

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

var (
    once   sync.Once
    client *http.Client
)

func Client() *http.Client {
    once.Do(func() {
        client = &http.Client{Timeout: 5 * time.Second}
    })
    return client
}

func main() {
    fmt.Println(Client() == Client()) // true, the same instance

    var hit atomic.Int64
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            hit.Add(1)
        }()
    }
    wg.Wait()
    fmt.Println(hit.Load()) // 1000
}