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 泄漏场景与排查 | 本文 |