Go并发编程的血腥教训:我是如何从”优雅”写成”事故现场”的

2026-03-14 9 0

说出来你可能不信,我第一次在生产环境用 Go 的 goroutine 时,差点把公司服务器送走。

那是一个风和日丽的下午产品经理神秘兮兮地过来说:"小王啊,咱们这个接口响应太慢了,得优化优化。"

我一想,这还不简单?Go 最擅长的就是并发,搞几个 goroutine 分分钟搞定。于是我唰唰唰写下了这段代码:

func ProcessItems(items []Item) {
    for _, item := range items {
        go process(item)
    }
}

上线之后,内存直接炸了。是的,你没看错,炸了。几千个 goroutine 同时启动,每个都在等 IO,内存直接飙到起飞。

后来我才知道,这玩意儿叫"启动风暴"。

01. 第一个教训:goroutine 不是免费午餐

很多人觉得 Go 并发牛×,就疯狂开 goroutine。但 goroutine 再轻量也是有代价的——每个 goroutine 有自己的栈空间,虽然初始只有 2KB,但会动态增长。

如果你同时启动几十万个 goroutine,恭喜你,你可以体验一把"内存瀑布"的酸爽。

正确姿势:用 worker pool

func WorkerPool(jobs <-chan Job, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                processJob(job)
            }
        }()
    }
    wg.Wait()
}

控制并发数量,永远是第一步。

02. 第二个教训:context 是你的救命稻草

我曾经遇到过最坑的情况是:用户取消请求了,我的 goroutine 还在那儿勤勤恳恳地干活,数据库查询刷刷的,钱也哗哗地烧。

后来学乖了,学会用 context:

func fetchData(ctx context.Context) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
        return doFetch()
    }
}

context 就是你 goroutine 的"终止开关"。上游一取消,下游全部收信号,该停停,别硬撑。

03. 第三个教训:共享内存 ≠ 并发安全

刚开始写 Go 的时候,我觉得 Mutex 天下第一。后来才发现,Mutex 用错了地方,比不用还可怕。

有一次我这么写:

type Cache struct {
    mu    sync.Mutex
    data  map[string]string
}

func (c *Cache) Get(key string) string {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.data[key]
}

看起来没问题吧?但如果你用的是读写锁的场景,用 Mutex 就亏大了。读多写少的场景,RWMutex 才是王道:

type Cache struct {
    mu    sync.RWMutex
    data  map[string]string
}

func (c *Cache) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

一个 RLock,多个读者同时进,写者乖乖等。这性能差距,能差出几条街。

04. 第四个教训:channel 不是银弹

很多人被"用 channel 通信而不是共享内存"洗了脑,动不动就 channel+goroutine。但 channel 用错了,deadlock 分分钟教你做人。

最经典的死锁场景:

ch := make(chan int)
ch <- 1 // 这一行就会死锁!因为没有其他 goroutine 接收

记住:channel 必须是成对出现的,一个 send 必须对应一个 receive。

还有一种情况叫"Goroutine 泄露"——channel 永远没人接收,goroutine 永远等着,内存慢慢耗尽。你得学会用 context 或者 close(channel) 来打破这种局面。

05. 第五个教训:panic 了最好 recover

goroutine 里如果 panic 了没 recover,整个进程都会挂掉。我就因为这个翻过车——一个 goroutine panic,线上服务直接重启。

func safeGo(fn func()) {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("goroutine recovered: %v", r)
        }
    }()
    fn()
}

这年头,在生产环境跑 goroutine,不加 recover,就跟开车不系安全带一样——不是每次都会出事,但出事就是大事。

06. 总结:并发编程的三板斧

踩了这么多坑,我现在写并发就三板斧:

  1. 控制并发数——用 worker pool 或信号量
  2. 做好取消传播——用 context,一处取消,处处生效
  3. 选对同步原语——Mutex vs RWMutex vs Channel,别乱用

Go 的并发确实优雅,但优雅的前提是——你得知道自己在干什么。

别像我当年一样,把"优雅"写成"事故现场"。共勉。

相关文章

我与视频网站的”爱恨情仇”:追剧追到怀疑人生
一个SQL引发的血案:论数据库隔离级别的选择
限流熔断:你不当回事,但线上会教你做人
RESTful API 设计的血腥真相:别让你的接口成为同事的噩梦
你的 API 为什么返回 200 却显示错误?谈谈 RESTful 最大的坑
分布式事务:CAP定理教我做人的那些年

发布评论