1. Go 并发基础:goroutine 与调度

Go 的并发模型很直接:用 goroutine 表示并发执行的任务,用 channel 和同步原语协调任务之间的关系。goroutine 的语法简单到只有一个 go 关键字,但真正写好并发代码,重点不在”怎么启动”,而在”谁负责结束、谁负责等待、谁负责收尾”。

并发与并行

并发(concurrency)是同时处理多件事的结构能力,并行(parallelism)是真的在同一时刻执行多件事的运行状态

并发:一个服务同时管理多个请求、多个任务、多个 I/O 等待
并行:多个 CPU 核心同时跑多个 goroutine

Go 鼓励先把程序组织成并发结构,再由运行时决定能不能并行执行。也就是说,goroutine 不是线程的轻量拼写,而是一种更便宜的任务抽象。

启动 goroutine

在函数调用前加 go,这个函数就会在新的 goroutine 中执行:

func say(msg string) {
    fmt.Println(msg)
}

func main() {
    go say("hello")
    say("world")
}

这里有一个重要细节:主 goroutine 结束,整个进程就结束。如果 main 返回得太快,后台 goroutine 可能还没来得及运行。

func main() {
    go func() {
        time.Sleep(100 * time.Millisecond)
        fmt.Println("done")
    }()

    // main 立即返回,"done" 可能永远不会打印
}

所以真实代码里不能靠 time.Sleep 等 goroutine,要用 sync.WaitGroup、channel 或 context 明确等待。

用 WaitGroup 等待结束

sync.WaitGroup 用来等待一组 goroutine 完成:

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("worker %d start\n", id)
            time.Sleep(100 * time.Millisecond)
            fmt.Printf("worker %d done\n", id)
        }(i)
    }

    wg.Wait()
    fmt.Println("all done")
}

三个关键点:

  1. Add,再启动 goroutine。如果在 goroutine 内部 Add,主 goroutine 可能先执行到 Wait,造成竞态。
  2. defer wg.Done() 放在 goroutine 开头,保证异常返回时也能计数归零。
  3. 不要复制 WaitGroup,它内部有状态,通常传指针或在闭包里引用外层变量。

goroutine 很便宜,但不是免费

goroutine 的初始栈很小,会按需增长;调度也由 Go runtime 管理,比系统线程轻量得多。但它仍然占用内存、调度成本、文件句柄、网络连接等资源。

// ❌ 错误:每个请求都无限制启动后台任务,流量一大就失控
func handler(w http.ResponseWriter, r *http.Request) {
    for _, item := range loadItems() {
        go process(item)
    }
}

并发数量必须被控制。常见方式有三种:

  1. worker pool:固定数量 goroutine 消费任务
  2. semaphore:用带 buffer 的 channel 限流
  3. errgroup:管理一组任务的生命周期和错误

调度模型:G、M、P

Go runtime 的调度模型常被概括为 G-M-P:

G = goroutine,要执行的任务
M = machine,操作系统线程
P = processor,调度上下文,负责把 G 分配给 M 执行

可以把它理解成:

goroutine 很多 → 放在运行队列里
P 挑选可运行的 goroutine
M 拿到 P 后真正执行 goroutine

GOMAXPROCS 决定同时有多少个 P,也就是最多有多少个 goroutine 可以并行执行 Go 代码。默认值通常等于可用 CPU 核心数。

fmt.Println(runtime.GOMAXPROCS(0)) // 查看当前值

大多数时候不需要手动调它。只有 CPU 密集型任务、容器 CPU 配额、性能压测这类场景,才值得专门关注。

阻塞不会总是阻塞线程

goroutine 在等待 channel、定时器、网络 I/O、锁时,runtime 会尽量把当前线程让出来,去执行其他可运行 goroutine。

go func() {
    resp, err := http.Get("https://example.com")
    _ = resp
    _ = err
}()

这个 goroutine 等网络响应时,并不意味着一个系统线程被白白占住。Go 的网络轮询器会帮忙等待 I/O 就绪,再把 goroutine 放回可运行队列。

但有些阻塞可能真的占住线程,比如某些 cgo 调用、长时间系统调用、CPU 密集循环。CPU 密集代码如果没有函数调用和抢占点,也可能影响调度公平性。

生命周期:启动容易,退出更重要

每个 goroutine 都应该能回答三个问题:

  1. 它什么时候启动?
  2. 它什么时候退出?
  3. 谁负责等待它退出?

下面是一个没有退出路径的 goroutine:

// ❌ 泄漏:如果 ch 永远没有数据,这个 goroutine 永远阻塞
go func() {
    value := <-ch
    fmt.Println(value)
}()

应该加上取消信号:

go func() {
    select {
    case value := <-ch:
        fmt.Println(value)
    case <-ctx.Done():
        return
    }
}()

经验法则:只要 goroutine 的生命周期可能超过当前函数,就要认真设计退出路径。HTTP 请求、后台 worker、定时任务、订阅消费都属于这种场景。

闭包变量捕获

循环里启动 goroutine 时,要把循环变量作为参数传进去:

for _, url := range urls {
    go func(url string) {
        fetch(url)
    }(url)
}

这样每个 goroutine 拿到的是自己的 url 副本。这个写法即使在较新的 Go 版本已经改善了 range 变量语义,仍然清晰、兼容、少歧义。

另外一个常见坑是闭包里直接写外层错误变量:

// ❌ 多个 goroutine 同时写 err,会产生数据竞争
var err error
for _, job := range jobs {
    go func(job Job) {
        err = process(job)
    }(job)
}

应该把结果通过 channel 返回,或者用互斥锁保护共享变量。

panic 不会跨 goroutine 捕获

recover 只能捕获同一个 goroutine 栈上的 panic:

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()

    go func() {
        panic("boom") // main 里的 recover 捕不到
    }()

    time.Sleep(time.Second)
}

如果 goroutine 里可能 panic,要在 goroutine 内部 recover:

go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("worker panic: %v", r)
        }
    }()

    runWorker()
}()

生产代码里更推荐让函数返回 error,再统一收集错误。panic 适合不可恢复的程序错误,不适合普通业务失败。

小结

goroutine 的语法很轻,工程约束要更重:

  • 启动 goroutine 前先想退出路径
  • 需要等待就用 WaitGroup、channel 或 errgroup
  • 需要取消就传 context
  • 需要限流就控制 goroutine 数量
  • 不要让多个 goroutine 无保护地读写同一份数据

Go 并发的难点不是”开多快”,而是”收得住”。