Go 的 goroutine,用起来简单,管起来要命

举报
yd_241780069 发表于 2026/11/07 22:13:48 2026/11/07
【摘要】 起因写 Java 的时候线程池是标配,核心数多少、队列多大、满了怎么拒绝,都得想一遍。转 Go 之后发现一个 go 关键字就能起并发,简单到让人不习惯。简单归简单,用久了才发现坑都在后面:goroutine 泄漏、无限制地起、数据竞争,一个没注意就是线上问题。这篇记一下我实际遇到的和后来定下来的习惯。 起 goroutine 太容易了for _, url := range urls { ...

起因

写 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 一路传。

先记这些。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。