Goroutine 基础

Goroutine 是 Go 的轻量级线程,由 Go runtime 调度而非操作系统。创建一个 goroutine 只需 go 关键字。

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

goroutine 的初始栈只有 2KB(可动态增长到 1GB),而操作系统线程通常需要 1MB 以上的栈空间。这意味着单机上创建数十万个 goroutine 也不会造成太大压力。Go runtime 使用 GMP 模型(Goroutine、Machine、Processor)将大量 goroutine 复用到少量操作系统线程上, goroutine 的切换成本远低于线程上下文切换。

使用 goroutine 时需要注意主函数退出的问题:

func main() {
    go func() {
        time.Sleep(1 * time.Second)
        fmt.Println("done") // 很可能永远不会执行
    }()
    // main 退出时所有 goroutine 都会被直接终止
}

正确的做法是用同步机制(WaitGroup、Channel)等待 goroutine 完成:

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("worker", id)
    }(i)
}

wg.Wait()

经典陷阱:在循环变量上直接启动 goroutine。Go 1.22 之前,循环变量会被复用,所有 goroutine 可能拿到同一个值。老代码需要通过参数传递 go func(id int) 来规避,Go 1.22 起循环变量每次迭代都是新变量,这个问题已被修复。

Channel 通信

Channel 是 goroutine 之间通信的管道。Go 推崇通过通信共享内存的理念。

ch := make(chan string)

go func() {
    ch <- "hello"
}()

msg := <-ch
fmt.Println(msg)

无缓冲 channel 的发送和接收必须同时就绪,天然形成了同步点;带缓冲的 channel 则允许生产者和消费者解耦:

// 缓冲为 3,写入 3 个以内不会阻塞
ch := make(chan int, 3)
ch <- 1
ch <- 2
fmt.Println(len(ch), cap(ch)) // 2 3

关于 channel 有几条重要规则:

  • 向 nil channel 发送或接收会永久阻塞
  • 向已关闭的 channel 发送会 panic
  • 从已关闭的 channel 接收会立即返回零值,可通过 v, ok := <-ch 判断是否关闭
  • 关闭 channel 的责任应该由发送方承担,且不应重复关闭

一个优雅的关闭模式是用单独的 done channel 通知退出:

func worker(ch <-chan int, done <-chan struct{}) {
    for {
        select {
        case v := <-ch:
            fmt.Println("received:", v)
        case <-done:
            fmt.Println("worker exit")
            return
        }
    }
}

select 多路复用

当需要同时监听多个 channel 时,用 select。

select {
case msg := <-ch1:
    fmt.Println("ch1:", msg)
case msg := <-ch2:
    fmt.Println("ch2:", msg)
case <-time.After(5 * time.Second):
    fmt.Println("timeout")
}

select 有几个实用技巧:

  • default 分支让 select 变成非阻塞操作,常用于尝试性发送或接收
  • select{} 会永久阻塞
  • time.After 每次调用都会创建新的 timer,在高频循环中应使用 time.NewTimer 复用,避免内存压力
// 非阻塞发送
select {
case ch <- task:
    // 提交成功
default:
    // 队列已满,降级处理
    fmt.Println("queue full, drop task")
}

常见并发模式

worker pool 模式

用固定数量的 goroutine 处理任务队列,避免无限制地创建 goroutine 把下游打挂:

func workerPool(jobs <-chan Job, results chan<- Result, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }
    wg.Wait()
    close(results)
}

pipeline 模式

把处理流程拆成多个 stage,每个 stage 一组 goroutine,通过 channel 串联:

nums := make(chan int)
squared := make(chan int)

go func() {
    defer close(nums)
    for i := 1; i <= 5; i++ {
        nums <- i
    }
}()

go func() {
    defer close(squared)
    for n := range nums {
        squared <- n * n
    }
}()

for s := range squared {
    fmt.Println(s)
}

errgroup 并发编排

标准库 golang.org/x/sync/errgroup 提供了带错误传播和取消的并发控制,是手动管理 goroutine 的现代化替代:

g, ctx := errgroup.WithContext(ctx)

for _, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(ctx, url)
    })
}

if err := g.Wait(); err != nil {
    // 任意一个失败,ctx 会被取消,其余任务提前退出
    return err
}

Context 控制生命周期

context.Context 用于控制 goroutine 的生命周期,实现超时和取消传播。几乎所有接受 ctx 的函数都应该把它作为第一个参数,并持续向下传递。

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, "GET", "https://example.com", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
    // 超时或被取消
    log.Fatal(err)
}
defer resp.Body.Close()
func handler(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err() // context.DeadlineExceeded 或 context.Canceled
    case result := <-doWork(ctx):
        return result
    }
}

使用 Context 的几条最佳实践:

  • cancel 必须 defer 调用,否则会造成 context 泄漏
  • 不要把 Context 存在结构体里,显式传参更清晰
  • 不要传递 nil context,不确定时用 context.TODO()
  • ctx.Value 只用来传递请求级别的元数据(traceID 等),不要当参数传递的万能容器

并发安全与竞态检测

多个 goroutine 访问共享数据时必须保证同步。除了 mutex,sync/atomicsync.Map 在特定场景下性能更好:

var (
    mu    sync.Mutex
    count int
)

func inc() {
    mu.Lock()
    count++
    mu.Unlock()
}

// 只读多写少的场景可用 sync.Map
var sm sync.Map
sm.Store("key", "value")
v, ok := sm.Load("key")

养成用 go test -racego run -race 的习惯,race detector 能在运行时捕获大多数数据竞争,成本只是 210 倍的运行时开销和 510 倍的内存开销。

总结

Go 的并发模型简洁但强大。理解 Channel 和 Context 的设计思想,掌握 worker pool、pipeline、errgroup 这些常见并发模式,配合 race detector 兜底,能让你写出高性能且安全的并发程序。核心心法只有一条:通过通信共享内存,而不是通过共享内存实现通信