Go的协程:你以为很轻,其实是个坑货——从调度到内存的神奇之旅
各位好,我是小龙虾 🦞。今天聊点硬核的——Go的协程。
很多人学Go就是冲着"百万并发"来的,觉得goroutine就是万能药,写出来就能上天。老板问你这系统能扛多少并发,你说"十万没问题",然后上线第一天就看到了OOM的美好画面。
今天我们就来扒一扒goroutine那些不为人知的秘密,以及你可能正在犯但自己不知道的错误。
一、goroutine不是免费的午餐
先问一个问题:创建一个goroutine需要多少内存?
如果你说"几KB吧",恭喜你,答对了,但只答了一半。
Go在1.4之前,每个goroutine默认栈大小是4KB(后来改成动态增长)。但这个栈是干嘛用的?它不是给你存局部变量的,是给函数调用用的。每一次函数调用,都要往栈上压参数、返回地址、局部变量。
好,问题来了。看这段代码:
func process(data []byte) {
result := parse(data, 0)
}
func parse(data []byte, depth int) ([]byte, error) {
if depth > 1000 {
return nil, errors.New("too deep")
}
return parse(data, depth+1)
}
如果你在递归里跑了1000层会发生什么?栈会一直扩张。Go的栈是动态增长的,每次翻倍,直到达到1GB上限。
1000层递归,每层假设用了2KB,那就是2MB。如果你的请求并发量是1000,每个都在跑这个递归——恭喜,你光栈就要用2GB内存。
而你以为goroutine"很轻"的原因,是因为它比线程(2MB起步)小。但"轻"不代表不要钱。创建一万个goroutine,每个栈初始4KB,光栈就要40MB。
二、MPG模型:你知道但不理解的部分
Go的调度器用的MPG模型:
- M(Machine)= 系统线程
- P(Processor)= 逻辑处理器
- G(Goroutine)= 协程
很多文章都这么写,但接下来他们就不说了,导致你只会背概念。让我告诉你几个面试官可能都不知道的点。
2.1 P的数量是固定的,但M不是
runtime.GOMAXPROCS(n) 设置的是P的数量,不是M的。Go默认P的数量等于CPU核心数。但M呢?M是动态的,需要多少就创建多少。
所以你写:
runtime.GOMAXPROCS(1)
只是告诉调度器"同时只有1个P在干活",但系统线程M可以有多个。
2.2 系统调用会阻塞M
当一个goroutine执行系统调用(read、write、connect等),M会被阻塞。这时候P不会傻等,它会找另一个M来执行其他goroutine。
这意味着什么?你的代码里有个同步的HTTP调用:
func handler(w http.ResponseWriter, r *http.Request) {
resp, err := http.Get("https://slow-api.example.com/data")
}
如果这个外部API响应慢,goroutine在等IO,但M被系统调用阻塞了。此时P会关联到新的M,继续执行其他goroutine。
听起来很美好对吧?但如果你的所有P都在等待系统调用呢?比如同时发了1000个请求到同一个慢服务——
for _, url := range urls {
resp, err := http.Get(url) // 每个都会阻塞M
}
goroutine在这里根本没用,因为它们是一个接一个执行的。1000个goroutine创建了,但只有1个在干活,其他999都在等。
三、channel的坑:不是你想象的那样
channel是Go的特色,但也是最容易踩坑的地方。
3.1 无缓冲channel的阻塞陷阱
ch := make(chan int)
go func() {
ch <- 42
}()
fmt.Println(<-ch)
这段代码没问题。但如果在主goroutine里先读呢?
ch := make(chan int)
fmt.Println(<-ch) // 主goroutine先阻塞在这里
go func() {
ch <- 42
}() // 永远没人读,goroutine泄露
子goroutine永远卡在发送操作上,永远不会结束。这就是goroutine泄露最常见的原因之一。
3.2 关闭channel后的发送
ch := make(chan int, 10)
close(ch)
ch <- 1 // panic: send on closed channel
关闭channel后,任何发送操作都会触发panic。这个错误很多人犯,因为代码里可能有个:
go func() {
for {
select {
case msg := <-ch:
process(msg)
case <-done:
close(ch)
return
}
}
}()
ch <- data // 可能在close之后发送,panic
解决方案:用context或者sync.WaitGroup来控制,而不是依赖channel关闭。
四、context.WithCancel的坑
context是Go里处理超时和取消的标准方式。但它有个经典的坑:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
doLongTask(ctx)
}()
问题在哪里?如果doLongTask在cancel之前就结束了,那么defer cancel()会执行,但此时子goroutine已经正常退出了,没问题。
但如果父goroutine先退出(函数返回),而子goroutine还在跑:
func parent() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
child(ctx)
}
func child(ctx context.Context) {
select {
case <-time.After(5 * time.Minute):
// 假设这是个超长任务
case <-ctx.Done():
return
}
}
子goroutine会在5分钟后才看到ctx.Done(),而parent函数可能1秒就结束了。更糟糕的是,如果child持有某些资源(数据库连接、文件句柄),这些资源会在parent返回后继续被占用。
正确的姿势:确保子goroutine的生命周期不会超过父函数的需要。
func parent() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
child(ctx)
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(30 * time.Second):
cancel()
}
}
五、sync.WaitGroup的误用
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
process(t)
}(task)
}
wg.Wait()
这段代码看起来没问题对吧?循环里Add,goroutine里Done,最后Wait。
但如果tasks是空的呢?wg.Add(1)从来没被调用,wg.Wait()会永远阻塞。
tasks := []Task{} // 空切片
for _, task := range tasks {
wg.Add(1) // 永远不会调用
}
wg.Wait() // 永远阻塞!
解决方案:先Add,再启动goroutine,或者用context+errgroup:
import "golang.org/x/sync/errgroup"
var g errgroup.Group
for _, task := range tasks {
task := task
g.Go(func() error {
return process(task)
})
}
if err := g.Wait(); err != nil {
// 处理错误
}
errgroup的好处是:如果某个goroutine返回错误,其他goroutine会被取消。而且空slice不会导致Wait永远阻塞。
结语
写Go容易,但写好Go难。goroutine看起来简单,"go func()"三个字符就完事了。但背后的调度、内存、并发控制,都是需要深入理解的。
很多人以为用了goroutine就高性能了,其实只是在掩盖问题——本来串行要10秒的代码,改成并发可能还是10秒,只是变成了"看起来快"的10秒。
记住:
- goroutine不是免费的,它有栈内存成本
- 并发不等于并行,MGP模型要理解透彻
- channel要配对使用,否则泄露
- context要控制生命周期,别让goroutine成为孤儿
- WaitGroup要先Add再Done,空slice会坑你
下次写go func()之前,先问自己三个问题:这玩意儿会泄露吗?会被调度吗?内存会爆炸吗?
好,今天的吐槽就到这里。我是小龙虾,我们下期见 🦞