Go Goroutine

目录

goroutine 是 Go 并发面试的核心,但这个系列已经拆成了好几篇:调度模型在 Go GMP 模型,channel 的语义和内存模型在 Go channel,锁在 Go Mutex,context 在 Go Context,还有 Go sync.Once。这篇补上系列里没细写的部分:goroutine 和线程的区别、panic 跨 goroutine、race 检测、WaitGroup、泄漏排查,最后给一张考点地图。

1. goroutine 和线程的区别

goroutine 是 Go 运行时管理的轻量级协程,跑在操作系统线程之上,多个 goroutine 复用少量线程。官方 FAQ 的说法是:把协程多路复用到一组线程上,某个 goroutine 阻塞时,运行时自动把同一条线程上的其他 goroutine 挪到别的可运行线程上。

和线程比,差距在三个地方:

  • 栈大小。goroutine 初始栈只有 2KB(Go 1.4 起,stackMin),分配在堆上,不够了自动增长;线程栈通常以 MB 计(Linux 上默认 8MB),创建线程要预留一大块虚拟内存。
  • 切换成本。goroutine 切换在用户态完成,不陷入内核;线程切换要经过内核调度器,涉及上下文保存、内核态用户态切换。
  • 数量级。官方 FAQ 说一个地址空间里创建几十万个 goroutine 是可行的,线程到这个数量早就把系统资源耗光了。

「goroutine 很轻量」的底层原因就是:栈小、切换在用户态完成,运行时自己管理内存和调度。

注意别把「轻量」理解成没有成本。goroutine 本身有调度器开销,创建多了 GC 扫描的栈数量也涨;并发度不是越高越好,worker pool 限流是常见做法。

2. panic 和 recover

规则只有两条,但很多人答错:

  • recover 只在 defer 函数里有效,直接调用 recover 返回 nil。
  • 每个 goroutine 的 panic 只能由它自己的 recover 处理。某个 goroutine 里 panic 没被 recover,整个进程直接崩溃,其他 goroutine 里的 recover 救不了它。

经典错误示范:在 main 里 recover,子 goroutine 里 panic,以为能接住。崩了。正确写法是把 recover 放在 goroutine 自己的 defer 里:

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

对外提供 goroutine 的库函数,标准做法是函数内部起 goroutine 并自带 recover,把 panic 转成 error 返回,别让调用方进程被拖垮。

另外区分 panic 和 fatal error:panic 能 recover,sync: unlock of unlocked mutex 这类 fatal error 是运行时直接崩溃,recover 接不住(详见 Go Mutex)。

3. 数据竞争与 race 检测

多个 goroutine 同时读写同一变量就是数据竞争,后果是读到脏数据,甚至崩。Go 的检测手段是 race detector:

go build -race ./...
go test -race ./...

带 -race 编译出的二进制运行时检测到竞争会直接报错退出。面试时提到「写代码要开 race 检测」是加分项。

触发竞争的主要场景:goroutine 闭包捕获了外层变量(循环变量)、共享 map 并发读写、读写共享字段没加锁。预防靠锁和原子操作,选型见 Go Mutex,这里只说单个数字的计数器场景用 sync/atomic 比锁轻量:

var counter atomic.Int64
counter.Add(1)

4. WaitGroup

等待一组 goroutine 结束。坑点就一个:Add 必须在 goroutine 启动之前调用,而且要在主 goroutine 里调,别在子 goroutine 里 Add,否则 Wait 可能先跑起来,计数还没加上就返回了。Done 用 defer 保证执行:

var wg sync.WaitGroup
for _, task := range tasks {
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        t.Run()
    }(task)
}
wg.Wait()

注意循环变量要作为参数传进闭包,直接用外层变量会全部读到最后一个值(Go 1.22 之前)。

Go 1.25 起有 wg.Go,把 Add、启动 goroutine、Done 合并成一个调用,内部就是 Add(1) + go func() + defer Done(),上面的手写模式可以替换成:

var wg sync.WaitGroup
for _, task := range tasks {
    wg.Go(func() { task.Run() })
}
wg.Wait()

省掉手动 Add/Done,也就绕开了计数配对错误的坑。一个细节:Go 的调用时机要求和 Add 一样,必须在 Wait 之前(或上一轮 Wait 返回之后),复用的规则没变。

5. goroutine 泄漏与排查

goroutine 启动后永远等不到结束条件,占着栈和内存不放,数量多了还会拖慢 GC。典型场景:

  • 往 channel 发数据,但没有接收方,发送者永久阻塞。
  • 从 channel 收数据,但没人再发送,也没 close。
  • select 里等一个永远不会来的 case,又没有 default。
  • time.Ticker 创建后没 Stop,定时器泄漏。
  • 死循环 goroutine 没有退出条件。

排查手段:

  • runtime.NumGoroutine() 打点看数量是否持续上涨。
  • pprof 的 goroutine profile:程序里 import net/http/pprof,然后 go tool pprof http://localhost:6060/debug/pprof/goroutine,能看到每个 goroutine 的堆栈和数量,卡在哪个 channel、哪行代码一目了然。

让 goroutine 可取消,标准做法是 context 或 done channel,配合 select。context 的取消信号会沿着调用链传递,子 context 也一起取消;用 context.WithTimeout 还能做到超时自动取消(详见 Go Context)。核心原则:每个 goroutine 都要想清楚它怎么结束,答不上来就是泄漏隐患。

6. 考点地图

考点 在哪篇
goroutine 和线程的区别、轻量原因 本文
GMP 调度、work stealing、handoff、抢占 Go GMP 模型
channel 关闭语义、hchan、select、内存模型 Go channel
panic 跨 goroutine、recover 规则 本文
数据竞争、race 检测、WaitGroup 本文
Mutex/RWMutex 底层实现、饥饿模式 Go Mutex
context 取消、超时、传值 Go Context
sync.Once Go sync.Once
goroutine 泄漏场景与排查 本文
编辑此页

目录