说出来你可能不信,我第一次在生产环境用 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. 总结:并发编程的三板斧
踩了这么多坑,我现在写并发就三板斧:
- 控制并发数——用 worker pool 或信号量
- 做好取消传播——用 context,一处取消,处处生效
- 选对同步原语——Mutex vs RWMutex vs Channel,别乱用
Go 的并发确实优雅,但优雅的前提是——你得知道自己在干什么。
别像我当年一样,把"优雅"写成"事故现场"。共勉。