Go 协程(goroutine)很香,轻量、便宜、开就完了。
但很多同学一上来就是:
go func(){...}()
“我并发了,我牛了。”
结果线上一跑,CPU 飙升、内存乱飞、日志像瀑布,最后排查发现:
你只是把串行 bug 并发化了。
下面这些技巧,属于“看着朴素,关键时刻救命”的实战经验。
1)协程不是不要钱:别无限开 goroutine
常见事故现场
for _, task := range tasks {
go handle(task)
}
任务量一大,瞬间几万 goroutine,调度器都要喊“加班费”。
正确姿势:限流(worker pool)
sem := make(chan struct{}, 100) // 最多100个并发
for _, task := range tasks {
sem <- struct{}{}
go func(t Task) {
defer func() { <-sem }()
handle(t)
}(task)
}
比喻:开协程像开车上高架,不限流就是“全城同时上桥”,堵到你怀疑人生。
2)WaitGroup 用法别“手滑”
三个关键点
Add()要在起 goroutine 之前调用- goroutine 里
defer Done() - 传指针
*sync.WaitGroup
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
work(i)
}(i)
}
wg.Wait()
经典翻车:Add 放在 goroutine 里,有概率 Wait 先结束,直接“以为干完了”。
3)循环变量闭包坑:不是 Go 坑你,是你太信任它
错误示例(常见)
for _, v := range arr {
go func() {
fmt.Println(v) // 可能都打印同一个值
}()
}
正确示例
for _, v := range arr {
v := v
go func() {
fmt.Println(v)
}()
}
或作为参数传入。
比喻:你以为每个协程拿到自己的奶茶,结果大家抢的是同一杯。
4)用 channel 通信,不要共享内存硬刚
Go 设计哲学:
“不要通过共享内存通信,要通过通信共享内存。”
如果必须共享,至少加锁,别裸奔:
var mu sync.Mutex
mu.Lock()
counter++
mu.Unlock()
能用 channel 的场景,优先 channel,代码心智负担更小。
5)记得处理退出:别让 goroutine 变“僵尸线程”
goroutine 开了不收,长期运行就泄漏。
推荐:context 控制生命周期
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
return
default:
doSomething()
}
}
}()
// 某时刻退出
cancel()
比喻:协程像实习生,入职要有工牌(启动),离职也要办手续(退出)。
6)select + default 慎用:别写成“CPU 榨汁机”
for {
select {
case msg := <-ch:
handle(msg)
default:
// 啥也没有
}
}
这段会空转,CPU 吃满。
如果非要 default,记得 time.Sleep 或 ticker 节流。
7)错误收敛:并发任务不要“各死各的”
并发里错误处理最怕“日志里哭,主流程假装没看见”。
推荐用 errgroup
g, ctx := errgroup.WithContext(context.Background())
for _, job := range jobs {
job := job
g.Go(func() error {
return doJob(ctx, job)
})
}
if err := g.Wait(); err != nil {
// 统一处理错误
}
这比你手搓 WaitGroup + errChan + cancel 清爽太多。
8)panic 兜底:协程里崩了,主程序不一定知道
goroutine 内 panic 不 recover,可能悄悄死掉,业务就“少了个轮子”。
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panic: %v", r)
}
}()
riskyWork()
}()
9)定时任务别只会 time.After,注意资源释放
在循环里频繁 time.After 容易造成额外对象堆积。
更推荐 time.NewTicker(),用完 Stop()。
10)调试并发:先加“可观测性”,再谈玄学
- 打关键日志(任务ID、goroutine阶段)
- 开 race 检测:
go test -race - pprof 看 goroutine 泄漏、阻塞热点
- 看
runtime.NumGoroutine()监控趋势
一句话:并发问题不是靠“感觉”解决的,是靠证据链。
最后送你一份“协程保命清单”
上线前自查:
- 是否限制了并发数量
- 每个 goroutine 都有退出路径
- WaitGroup 用法是否规范
- 是否避免了循环变量闭包坑
- 错误是否可收敛、可观测
- 是否跑过
-race