goroutine 中未捕获 panic 会导致程序崩溃,需在每个 goroutine 入口用 defer/recover 捕获并记录堆栈;并发写入同一 error slice 会引发竞态,应使用 errgroup.Group 或加锁保护。
Go 的 goroutine 是轻量级线程,但它的 panic 不会向主 goroutine 传播。一旦某个 go 启动的函数 panic 且未 recover,整个程序就直接退出,错误信息还可能被吞掉。
常见现象是:日志里没看到 panic 日志,服务却突然挂了;或者只在高并发压测时偶发崩溃,本地复现困难。
defer/recover,不能依赖外层包裹debug.PrintStack() 或 runtime/debug.Stack()
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in goroutine: %v\n%v", r, string(debug.Stack()))
}
}()
// 业务逻辑
}()很多人习惯用 []error 收集子任务错误,但在并发下直接 append 会引发竞态——append 可能触发底层数组扩容,多个 goroutine 同时写入同一 slice 头部字段(len/cap)就会出错。
典型错误信息:fatal error: concurrent map writes(虽然写的是 slice,但底层行为类似)或静默丢数据。
sync.Mutex + append
errgroup.Group,它原生支持并发错误聚合,并自动等待所有 goroutine 完成errgroup.WithContext 返回的 Group 在第一个 error 出现后默认取消其余任务(可选)g, ctx := errgroup.WithContext(context.Background())
for i := range tasks {
i := i
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
default:
return doTask(tasks[i])
}
})
}
if err := g.Wait(); err != nil {
log.Printf("first error: %v", err)
}在超时或取消场景下,context.Canceled 或 context.DeadlineExceeded 是控制流信号,不是真正的失败。如果统一把它们和其他 error 一起收集上报,会导致监控误报、告警轰炸。
尤其在使用 errgroup 或 sync.WaitGroup + 手动 error channel 时,容易把 cancel 错当业务异常。
errors.Is(err, context.Canceled) 或 errors.Is(err, context.DeadlineExceeded)
ctx.Err(),如果是则直接返回,避免掩盖真实原因用 chan error 收集错误很常见,但如果 channel 无缓冲且接收端未及时读取,发送 goroutine 就会永久阻塞——尤其在部分任务快速失败、其他任务还在运行时,泄漏的 goroutine 会越积越多。
现象是:goroutine 数持续上涨,pprof 查看大量 goroutine 停在 ch。
an send
make(chan error, len(tasks))
select 配合 default 分支,避免阻塞:sync.WaitGroup 等待所有发送完成后再关闭 channelerrs := make(chan error, len(tasks))
for _, task := range tasks {
go func(t Task) {
if err := t.Run(); err != nil {
select {
case errs <- err:
default:
// 忽略或打日志,避免阻塞
log.Printf("error channel full, drop: %v", err)
}
}
}(task)
}实际中最容易被忽略的,是 recover 的作用域边界和 error channel 的生命周期管理——它们不像 HTTP handler 那样有明确框架兜底,全靠开发者自己卡住 goroutine 的入口和出口。