Go 的 goroutine,用起来简单,管起来要命
起因
写 Java 的时候线程池是标配,核心数多少、队列多大、满了怎么拒绝,都得想一遍。转 Go 之后发现一个 go 关键字就能起并发,简单到让人不习惯。简单归简单,用久了才发现坑都在后面:goroutine 泄漏、无限制地起、数据竞争,一个没注意就是线上问题。
这篇记一下我实际遇到的和后来定下来的习惯。
起 goroutine 太容易了
for _, url := range urls {
go fetch(url)
}
这段代码看着没问题,循环里每个 URL 起一个 goroutine 去抓。问题是 fetch 返回了主函数就退出了,所有 goroutine 被直接干掉,一个都没跑完。而且这段代码有经典的循环变量坑——Go 1.22 之前,循环变量是所有迭代共享的,url 到最后全是最后一个值。Go 1.22 之后每个迭代有自己的变量了,但老项目里还是能见到。
要等它们跑完,得用 sync.WaitGroup:
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(url)
}
wg.Wait()
传参那一步别省,就算 Go 1.22 修了循环变量,显式传参还是更清楚。
无限制地起,迟早出事
上面那个循环如果 URL 有一万个,就起了一万个 goroutine。Go 的 goroutine 虽然轻量,一个几 KB 的栈,但一万个也不是没开销,更何况 fetch 里面可能还有 HTTP 连接、内存分配。
标准库没提供 goroutine 池,常用的做法是用带缓冲的 channel 当信号量:
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
sem <- struct{}{}
go func(u string) {
defer wg.Done()
defer func() { <-sem }()
fetch(u)
}(url)
}
wg.Wait()
一次最多十个在跑,超出的在 sem <- struct{}{} 那里等着。这个模式我用得最多,简单、不用引第三方库。缺点是排队期间 goroutine 已经起来了,只是卡在 channel 上,严格说还是起了一万个 goroutine,只是大部分在阻塞。要真限制数量,得用 worker pool 那种预先起固定数量 goroutine 从 channel 里取任务的模式。
goroutine 泄漏,最难查
goroutine 卡在 channel 收发上,永远不会退出,这就是泄漏。泄漏多了内存涨、调度变慢,但不会报错,只能靠 pprof 看 goroutine 数量。
最常见的场景是发出去的请求没人接:
func process() {
ch := make(chan int)
go func() {
ch <- 1 // 永远阻塞,没人读
}()
// 函数直接返回了
}
ch 是无缓冲的,那个 goroutine 往里面写的时候一直等着,但函数已经返回,没人再来读,它就永远卡在那。
解决办法是给 channel 加缓冲,或者用 select 加 ctx.Done():
select {
case ch <- 1:
case <-ctx.Done():
return
}
带超时的场景也一样,调下游服务的时候用 context.WithTimeout,超时了 goroutine 能收到信号退出,不会一直挂着等。
数据竞争,编译器不管
var counter int
for i := 0; i < 1000; i++ {
go func() {
counter++ // 竞争
}()
}
这个 counter++ 不是原子操作,读、加、写三步,多个 goroutine 同时干就会丢更新。编译不报错,跑起来结果不对,而且不一定每次都错,可能跑十次对九次,最烦这种。
查的话用 go run -race 或者 go test -race,能检测出来。我现在的习惯是,只要涉及并发读写,先跑一遍 -race,过了再往下写。
修的话两种:用 sync.Mutex 保护,或者用 atomic 包。计数器这种简单场景 atomic.AddInt64 就够,复杂逻辑上锁。
channel 和 mutex 怎么选
Go 社区有句话叫“不要通过共享内存来通信,要通过通信来共享内存”,听着挺玄。我自己的判断标准很简单:
goroutine 之间传数据,用 channel。比如一个生产者一个消费者,channel 天然合适。
多个 goroutine 要改同一份状态,用 mutex。比如一个 map 被好几个 goroutine 读写,用 channel 反而绕,上锁直接。
别为了“符合 Go 的风格”硬把 mutex 换成 channel,写出来又长又难懂。两种都是工具,看场景用。
context 要一路传下去
起 goroutine 的时候带 context.Context,这是个习惯问题。不带的话,上层想取消、想设超时,底下那些 goroutine 根本收不到信号,只能等它们自己跑完。
go func(ctx context.Context) {
select {
case <-ctx.Done():
return
case result := <-doWork():
handle(result)
}
}(ctx)
context 作为第一个参数传,这是 Go 的约定,别塞到结构体里。取消信号能一路传到底,goroutine 才知道什么时候该收手。
小结
goroutine 起起来容易,管起来要命。几个习惯我现在固定下来了:起之前想好谁等它、用 WaitGroup 或者 channel 兜住;量大的场景限并发;channel 操作配 ctx.Done() 防泄漏;并发读写先跑 -race;context 一路传。
先记这些。
- 点赞
- 收藏
- 关注作者
评论(0)