从入门到深入:并发不只是起 goroutine
上一篇文章我们学会了用 goroutine 起协程、用 channel 传数据、用 WaitGroup 等所有任务跑完。但那只是 Go 并发的「语法」。真实工程里的并发要回答三个更难的问题:并发度多大合适、出错怎么办、重复请求怎么避免打爆下游。
无脑为每个任务起一个 goroutine 是有代价的:goroutine 很便宜但并非免费,每起一个都要分配栈空间;当任务成千上万、每个任务还要占用数据库连接或外部 API 配额时,全量并发反而是灾难。于是有了 worker 池:用固定数量的工人消化一条任务队列,既控制了并发度,也保护了下游资源。
同样地,并发任务里只要有一个失败,是继续等全部跑完再汇总,还是立刻取消其他任务?标准库的 context 配合 errgroup 给出了答案。而面对热点数据,成千上万个并发请求同时打到数据库,用 singleflight 可以把它们合并成一次真正的查询。这些模式不是语法糖,而是 Go 社区在大量生产事故里沉淀下来的工程答案。
语法告诉你「怎么并发」,工程模式告诉你「并发到什么时候该刹车、出错时怎么办」。
- worker 池用固定数量 goroutine 消化任务队列。
- errgroup并发错误聚合,任一失败取消整组。
- 扇出扇入多个生产者的结果汇成一条流。
- singleflight合并并发重复请求,防缓存击穿。
- race 检测用工具找出数据竞争,而不靠肉眼。
worker 池:把任务队列摊给固定数量的工人
worker 池的基本结构是「一个任务 channel + N 个消费 goroutine」:生产者往 channel 里塞任务,N 个 worker 同时从 channel 取任务执行。channel 本身就是并发安全的队列,所以不需要额外的锁。
func workerPool(jobs <-chan int, workerCount int) {
var wg sync.WaitGroup
for w := 0; w < workerCount; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
process(job) // 每个 worker 串行消费
}
}()
}
wg.Wait()
}
worker 数量怎么定?经验法则是看瓶颈资源:如果是 CPU 密集,用 runtime.GOMAXPROCS 级别;如果是 IO 密集(HTTP、数据库),并发度可以更高,但也要受连接池上限约束。固定 worker 的好处是:队列再长也只是排队,不会瞬间打出 N 倍流量。
关闭时机也很关键:生产者全部塞完后 close(jobs),所有 worker 消费完剩余任务后,for range 自然退出,wg 归零。这个「生产-消费-关闭」的节奏是 worker 池不出 bug 的核心。
errgroup:并发任务的错误聚合与提前退出
原生 WaitGroup 只等不查错。errgroup 补上了两件事:收集第一个错误,以及通过 context 在出错时取消其余任务。它来自 golang.org/x/sync/errgroup。
import "golang.org/x/sync/errgroup"
func runAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
u := u
g.Go(func() error {
return fetch(ctx, u) // 任一失败返回 error
})
}
return g.Wait() // 第一个 error,或 nil
}
errgroup.WithContext 会派生出一个可取消的 ctx:只要任何一个 g.Go 里的函数返回错误,errgroup 内部就会 cancel 这个 ctx,其他任务里监听 ctx 的请求会立刻中断。这种「一个失败、全体撤退」的语义,正是分布式调用里最想要的行为——不白等、不空转。
需要注意 g.Go 不接受可取消的单独任务参数,取消要靠闭包捕获的 ctx 主动配合:函数体内要检查 ctx.Err() 或使用带 ctx 的请求方法,否则取消信号到了,任务还在傻等。errgroup 也支持 SetLimit 限流,效果类似 worker 池的并发上限。
扇出扇入:把并发结果汇成一条流
扇出(fan-out)指把一个任务拆给多个 goroutine 并行处理,扇入(fan-in)指把多个 goroutine 的输出合并到一个 channel 里继续处理。合并的方式是开一个专门的收集 goroutine:
func fanIn(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range chs {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() { wg.Wait(); close(out) }()
return out
}
扇出扇入组合起来,就是 Go 最经典的「流水线」模型:读数据 → 并行变换 → 合并 → 下一步。每一段只关心自己的输入输出 channel,段与段之间靠类型解耦。管道里任何一段都可以替换实现而不影响上下游。
写扇入代码时最常踩的坑是:收集 goroutine 往 out 里写,但没人保证 out 会被读,写满就会死锁;或者 wg 没等完就 close(out),导致下游读到零值。牢记「close 只由生产者执行,且必须在所有写入结束后」,这条纪律能避开大多数 channel 死锁。
singleflight:合并重复请求
缓存击穿是经典问题:某个热点 key 失效的瞬间,几千个并发请求同时打到数据库。每个请求都认为「缓存没数据,我去查库」,数据库瞬间被打爆。singleflight 的思路是:同一时刻对同一个 key 的并发请求,只让第一个真正去查,其余请求共享它的结果。
import "golang.org/x/sync/singleflight"
var g singleflight.Group
func getData(ctx context.Context, key string) (string, error) {
v, err, _ := g.Do(key, func() (interface{}, error) {
return queryDB(ctx, key) // 只有第一个请求真正执行
})
return v.(string), err
}
g.Do(key, fn) 对相同 key 并发调用时,只会执行一次 fn,其余调用直接拿到同一个返回值。这样热点 key 失效后,数据库只收到一次查询,其他请求在 singleflight 内部排队等结果。它不替代缓存,而是缓存和数据库之间的一道「泄洪闸」。
singleflight 不是银弹:它合并的是「同一进程内」的重复请求,多实例部署时每个实例各合并各的;fn 执行期间的所有调用者共享结果,如果 fn 很慢,所有调用者一起等。用 DoChan 还能拿到逐调用者的通知 channel,配合 context 做超时更灵活。
并发安全与内存模型:race 检测与 atomic
Go 的并发安全靠两条腿:一是用 channel / 锁 / 原子操作做同步,二是用竞态检测器把漏网的数据竞争抓出来。运行时在 -race 开关下会记录每个内存访问,一旦发现两个 goroutine 无同步地读写同一地址,立刻报告。
// 跑测试时开启竞态检测
// go test -race ./...
var counter int32
atomic.AddInt32(&counter, 1) // 无锁自增
value := atomic.LoadInt32(&counter)
原子操作适用于计数器、开关标志这类简单状态;多个字段的复合状态变化,还是要靠 mutex 或干脆用 channel 串行化。Go 内存模型的直觉版本是:你用什么同步原语,就按它的 happens-before 关系推理可见性,别指望「写完立刻被别的 goroutine 看到」这种直觉。
生产习惯:CI 里始终跑 go test -race;压测和流量灰度时也开 race。race 检测器是低成本的,跑一次测试就能覆盖大量并发路径,比事后线上事故定位便宜太多。
实战:带超时与限流的并发爬虫管线
把本文模式组装成一个迷你爬虫:给定一批 URL,用 worker 池控制并发,每个请求带超时,任一失败通过 errgroup 取消整组,再用扇入收集结果。
func crawl(ctx context.Context, urls []string, workers int) ([]string, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(workers) // 限流:最多 workers 个并发
results := make(chan string, len(urls))
for _, u := range urls {
u := u
g.Go(func() error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
body, err := fetchWithCtx(ctx, u)
if err != nil {
return err
}
select {
case results <- parseTitle(body):
case <-ctx.Done():
return ctx.Err()
}
return nil
})
}
err := g.Wait()
close(results)
return drain(results), err
}
这里 worker 上限由 SetLimit 控制,不再手写 worker 池;超时由 context 逐请求派生;错误由 errgroup 汇总并在第一个失败时取消整组;结果通过带缓冲的 channel 扇入,g.Wait 结束后统一关闭。
再看一眼整体:限流 + 超时 + 错误聚合 + 结果汇聚,四个关注点各归其位。这就是把上一章的「并发语法」升级成「并发工程」的完整样子。
worker 池控制数量,errgroup 控制成败,context 控制生命周期,singleflight 控制重复——Go 并发工程四件套。