1. 从一次崩溃说起:并发代码为什么总在深夜出事
凌晨两点的告警电话,相信做后端的朋友都不陌生。我印象最深的一次,是一个看起来毫无风险的批处理任务:一个循环里起 goroutine 拉取各分片数据,代码逻辑走查了三遍,编译通过、单测通过、预发跑通。上线第二天,某个分片返回了一份格式异常的数据,解析函数里一个数组越界,整个进程直接退出,所有正在处理的请求一起没了。后来我在复盘文档里写下了一句话:在 Go 里,goroutine 的 panic 不属于"局部错误",它属于"进程级事故"。也正是从那之后,我开始系统性地寻找能把并发控制收敛起来的方案,最后稳定在conc这个库上——一个被官方博客点名推荐过、主打"结构化并发"的轻量工具集。
conc解决的核心问题其实就两个,但这两个问题足够让大多数团队栽跟头:第一,任何子 goroutine 里的 panic,默认会带走整个进程,原生sync.WaitGroup和golang.org/x/sync/errgroup都不管这件事;第二,并发的"数量"和"错误"这两个维度,原生工具组合起来写非常啰嗦,要么手写信号量,要么自己维护 channel,代码一多就没人看得懂。conc用一套极简的 API 把这两件事同时收进笼子里:conc.WaitGroup、pool、iter、panics四个包,覆盖了日常 90% 的并发编排场景。
这篇内容适合谁看?如果你写过go func()并且被 panic 打崩过进程,如果你用errgroup但总觉得错误收集不够顺手,如果你面对一个十万条元素的切片想并行处理又怕起爆内存,那这篇基本能对上你的痒处。我会从设计动机讲到组件拆解,再给一套可以直接抄走的生产级骨架,最后把踩过的坑和我自己总结的池大小计算方法一并倒出来。全文的代码示例都基于 Go 1.20 以上,标准库特性可以直接用。
2. conc 的设计哲学:把 panic 和并发度都关进笼子
2.1 每个 goroutine 都必须是"受管"的
要理解conc为什么这么设计,得先想清楚 Go 的并发模型给人的错觉。go func(){}这个语法太轻了,轻到你会在任何地方随手写一个,但它背后创建的是一个完全独立于父协程的执行体:父协程无法感知它的 panic,无法感知它是否还在跑,甚至无法感知它是不是已经卡死。这不是 Go 的缺陷,而是设计取舍——运行时把控制权完全交给你,代价就是安全网也得你自己织。
conc的核心主张是"受管并发":只要是通过它启动的 goroutine,就必然在它的监控之下。具体做法是在每个 goroutine 入口套一层defer recover(),把 panic 的值和原始堆栈捕获下来暂存,等到Wait()的时候再在主协程里重新抛出。这一手看起来只是加了个 recover,但产生的效果是质变的:并发部分的崩溃变成了同步可预期、可捕获、可记录的错误路径,而不是随机时点的进程退出。你可以用defer在Wait()外层做兜底,把 panic 转成 500 响应或者任务失败标记,进程继续活着。
注意:这种"重抛"是刻意的设计,不是缺陷。它保证了 panic 不会被静默吞掉——很多手写 recover 的代码最后都变成了"出错了但日志里什么都没有",排查时极其痛苦。
我个人的判断标准很直接:并发代码里出现的任何go关键字,都必须是可解释的。要么它本身就是个长生命周期的后台协程(比如指标上报),要么它就应该被纳管。conc帮我把后者这条路变得足够短,短到没有理由不用。
2.2 四层 API 结构,正好对应四类需求
conc的包结构非常克制,就四块,每块解决一个层次的问题,几乎不存在功能重叠:
| 包 | 定位 | 典型场景 | 是否需要自己管并发度 |
|---|---|---|---|
conc(WaitGroup) | 最小的并发原语 | 几个固定 goroutine 等待汇合 | 否,手动控制 |
pool | 带并发上限的任务池 | 批量 IO、限流调用下游 | 是,显式设置 |
iter | 切片并行处理 | 数组转换、批量计算 | 内部处理,但需注意 |
panics | 手写 recover 的辅助 | 自定义并发逻辑兜底 | 不涉及 |
这个分层带来的好处是心智负担可控。你不需要一上来就理解信号量、不需要提前决定错误聚合策略,从最简单的WaitGroup开始写,发现需要限流就换成pool.WithMaxGoroutines,发现要收集错误就加.WithErrors(),发现要响应取消就再套.WithContext(ctx)。这是一条渐进式的演进路径,代码不会因为需求变复杂而推倒重来。
对比之下,原生方案的演进路径是断裂的:sync.WaitGroup到errgroup是换库,errgroup到带并发上限的版本还得自己包一层信号量或者用errgroup.SetLimit(这个 API 是后来才有的)。每次演进都要改一堆调用点。conc的链式构造器把这一层抹平了,pool.New().WithMaxGoroutines(8).WithErrors().WithContext(ctx)一行读完就知道这个池的全部行为,这种"配置即文档"的可读性,在多人协作的项目里价值很高。
3. 四个核心组件逐个拆开看
3.1 conc.WaitGroup:三行代码换来的安全性
最基础的用法和sync.WaitGroup几乎一样,区别在于没有Add和Done,只有Go:
var wg conc.WaitGroup for _, task := range tasks { task := task wg.Go(func() { process(task) }) } wg.Wait()Go内部自己做计数加减,你不可能忘记Done,也不会出现Add数量对不上的经典错误。更关键的是Wait()的语义:它会把所有子 goroutine 的 panic 收集起来(只保留第一个),在返回前重新抛出。如果你的场景不希望 panic 冒泡到外层,用WaitAndRecover(),它返回一个panics.Recovered类型,你可以从里面拿到 panic 的值和堆栈,自己决定怎么处理。
还有一个容易被忽略的改进:sync.WaitGroup有一条隐性约束——如果在Wait正在阻塞时有新的Add并发发生,行为是未定义的。这条规则在实际代码里经常被违反,尤其是"边跑边加任务"的流水线模式。conc.WaitGroup用内部锁把这块保护起来了,Wait期间继续Go是安全的(虽然语义上仍建议先提交后等待)。我在做分页拉取的时候特别依赖这个特性:每一页数据回来后再提交下一批任务,用原生WaitGroup写要额外加一层同步,用conc就非常自然。
提示:
conc.WaitGroup可以被复用,Wait()返回后仍能继续Go。但不要在多轮任务之间共享同一个实例,边界不清容易出诡异问题,每轮新建一个成本可以忽略。
3.2 pool:并发上限、错误聚合、上下文取消的三合一
pool是conc里最常用的包,也是最能体现"渐进演进"设计的地方。它的构造入口是pool.New(),返回一个*pool.Pool,然后通过链式方法派生出不同能力的变体:
// 最朴素:限制并发数,不收集错误 p := pool.New().WithMaxGoroutines(10) // 收集错误 p := pool.New().WithErrors() // 收集错误 + 限制并发 p := pool.New().WithErrors().WithMaxGoroutines(10) // 收集错误 + 带上下文 p := pool.New().WithErrors().WithContext(ctx) // 收集结果 p := pool.NewWithResults[*Resp]().WithMaxGoroutines(10)WithMaxGoroutines(n)底层用的是加权信号量,n就是权重上限。这里有个很多人不知道的默认值:如果不调用这个方法,默认上限是一个极大的数(接近math.MaxInt32),等于不限制。所以pool.New().WithErrors()单独使用时,你提交一万个任务就会起一万个 goroutine——它只帮你收集错误,不帮你限流。这个默认值我在早期踩过,一个批量导入任务把内存直接顶上了几个 G,后来才反应过来是池子没设上限。
三种变体的选择逻辑,我总结成一句话:只需要"都跑完"就用Pool;需要知道"哪些失败了"就用ErrorPool;需要"中途叫停"就用ContextPool。ContextPool的Go方法签名不一样,接收的是func(context.Context) error,而且任意一个任务返回错误或者 panic,都会主动 cancel 上下文,让其它还在跑的任务尽早退出——这个行为在批量调用下游接口时特别有价值,一旦发现下游整体不可用,没必要把剩下九千个请求全发完。
错误聚合方面,ErrorPool.Wait()返回的是把多个错误打包后的结果,用errors.Is/errors.As能穿透检查。如果只关心第一个错误,用WithFirstError(),它会短路并只保留最先返回的那个。我一般的做法是:批量任务的错误全收,单个任务链路的错误取首个,因为批量场景下你需要知道"坏了多少、坏在哪",而链路场景下后面