Golang 协程开发小技巧:让你的并发像外卖小哥一样快,但别乱闯红灯

0 阅读3分钟

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