你还在用"协程随便开"这种玄学调优?Go并发三板斧砍掉你一半的bug
上次有个哥们在技术群里说:"我的Go服务轻松扛10万并发,go func()随便写就行了。"我问他:"那你服务跑一天内存涨了吗?Goroutine数量监控了吗?context传了吗?"对面沉默了三十秒,然后悄悄退了群。
Go的并发模型是出了名的简洁——一个go关键字,三行代码,你就是并发大师。但正因为它太简单了,导致大量程序员产生了"我已经完全掌握了"的幻觉,直到线上内存爆了、goroutine炸了、channel死锁了,才哭着喊着找原因。
今天我们就来聊聊Go并发里三个最容易被忽视、但又最容易导致线上事故的点。我管这叫Go并发三板斧——不是因为它简单,而是因为它砍bug特别利索。
第一板斧:Goroutine调度——你以为的"并行"可能是个假象
很多人以为写go func()就等于"这段代码会同时执行"。Too young too simple sometimes naive。
Go的goroutine是协作式调度,不是抢占式——大部分情况下你不需要锁,因为goroutine会在safe point主动让出执行权。但问题是,这个safe point不是你决定的,是runtime决定的。
看这段经典的"协程泄漏"代码:
func processRequest(ctx context.Context) {
ch := make(chan Result)
go func() {
// 模拟慢查询,假设平均耗时5秒
result := db.Query("SELECT * FROM orders WHERE id = ?", 12345)
ch <- result
}()
select {
case <-ctx.Done():
// 客户端断连了,直接返回
return // ⚠️ 这里ch没人读,goroutine泄漏了!
case result := <-ch:
fmt.Println(result)
}
}
当客户端断连时,context被取消,函数直接return,但那个默默查数据库的goroutine还在跑,它的结果写不进已经没人读的channel——这个goroutine永远不会被回收。一个请求泄漏一个,线上QPS一上来,你的内存就是漏水的桶。
正确的做法是用buffered channel或者用context同时控制goroutine的生命周期:
func processRequest(ctx context.Context) {
ch := make(chan Result, 1) // 加个缓冲,让goroutine不会阻塞在send
go func() {
result := db.Query("SELECT * FROM orders WHERE id = ?", 12345)
select {
case ch <- result:
case <-ctx.Done():
// context取消时,goroutine知道自己该收手了
return
}
}()
select {
case <-ctx.Done():
return
case result := <-ch:
fmt.Println(result)
}
}
或者更彻底一点,用errgroup来管理并发任务:
func processRequest(ctx context.Context) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return fetchUser(ctx)
})
g.Go(func() error {
return fetchOrders(ctx)
})
g.Go(func() error {
return fetchRecommendations(ctx)
})
return g.Wait() // 任一任务失败,其他会被cancel掉
}
errgroup底层封装了context cancel逻辑,只要你传进去的ctx被cancel,所有goroutine都会收到退出信号——这就是Go并发哲学的精髓:通过context传递取消信号,而不是通过flag或者channel通知。
第二板斧:select——你可能写了个假的多路复用
select语句是Go里处理多channel通信的核心语法,看起来像switch,但行为完全不一样。多数人用它做"多路复用",但稍不留神就会写出有问题的代码。
第一个坑:忘了default分支。select不带default时,如果所有case都阻塞,goroutine就会卡住。某些场景下这是有意的,但大多数时候你是忘了考虑"没有数据到达"的情况。
select {
case msg := <-ch1:
handle(msg)
case msg := <-ch2:
handle(msg)
// 如果两个channel都没数据,这里就死等了
}
更隐蔽的坑是nil channel。向nil channel发送或接收会永久阻塞,这个特性本来是Go设计来动态启用/禁用case的,但很多人不知道,结果埋下死锁的雷:
func worker(ch <-chan Task, done chan<- struct{}) {
for {
select {
case task := <-ch:
process(task)
case <-done:
return
}
}
}
// 调用者:
ch := make(chan Task)
done := make(chan struct{})
worker(ch, done) // 这个goroutine在等ch,但ch没人发数据,它就卡住了
// 正确做法:用nil channel禁用case,或者用break label
select {
case task := <-ch:
process(task)
case <-done:
return
default:
// 加default防止阻塞
time.Sleep(time.Millisecond * 100)
}
第三个坑更绝——在for循环里select without default,导致每次循环只处理一个事件就退出循环,而不是持续监听:
// 错误写法
for {
select {
case msg := <-ch:
handle(msg)
// 处理完一个就出去了?不对,下次循环还在这里,但select重新评估
// 所以实际上行为是对的——但如果ch持续有数据,
// 这个循环会忙等,CPU打满
}
}
// 正确写法:加退出机制和default
for {
select {
case msg := <-ch:
handle(msg)
case <-quit:
return
default:
// 没有数据时让出CPU,不要忙等
time.Sleep(10 * time.Millisecond)
}
}
如果你用的是Go 1.21以上的版本,有个更优雅的写法:for ch != nil { select { ... } }——给ch赋值nil就等于从select里移除这个case,runtime会自动处理。这是Go的一个高级特性,知道的人不多,用好的人更少。
第三板斧:Context——你可能传了个寂寞
context是Go并发里最重要但也是最容易被用错的组件。很多人把context当成"万能参数"到处传,但从来不cancel它;或者传了个context.Background(),以为这样就安全了,实际上完全失去了trace和取消能力。
最常见的错误是在HTTP handler里不用请求的context:
// 错误:传了Background,数据库操作无法被客户端断开取消
func handler(w http.ResponseWriter, r *http.Request) {
result, err := db.Query(context.Background(), "SELECT ...") // ❌
// 正确:传 r.Context(),客户端断连时数据库查询会被取消
result, err := db.Query(r.Context(), "SELECT ...") // ✅
}
这个错误有多致命?当客户端超时断开时,你的数据库还在傻乎乎地查,浪费连接资源,如果是大查询还可能锁表。而正确传递context之后,HTTP框架会在客户端断连时自动cancel这个context,数据库驱动收到信号后会中止查询——这就是context cancel的级联效应。
第二个常见错误是在goroutine里用已经cancel的context:
func parent(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go child(ctx) // goroutine依赖ctx
// 如果这里提前cancel了,child里的所有操作都会收到取消信号
cancel()
}
func child(ctx context.Context) {
// 这个ctx已经在parent里被cancel了
// 所有用这个ctx的操作(db.Query、http.Get等)都会立即返回context canceled错误
db.Query(ctx, "...")
}
这里有个原则:context是树形传播的,父context cancel,所有子context都会收到。所以你在启动goroutine之前,一定要想清楚这个goroutine的生命周期应该跟谁绑定。如果绑错了,一旦父操作取消,子goroutine会毫无预警地被杀掉——你以为是"优雅退出",实际上是"意外阵亡"。
第三个错误是把context塞进结构体然后忘记它。context有明确的使用规范:它应该作为函数的第一个参数传来传去,不应该被存在结构体里。一旦你把context塞进struct,它就成了一个隐形的依赖,后期维护者根本不知道这个组件依赖cancellation信号。
// 错误:不建议在结构体里存context
type Worker struct {
ctx context.Context // ❌ 这样会让ctx的取消语义变得隐晦
client *http.Client
}
// 正确:context通过参数传递,不存在结构体里
type Worker struct {
client *http.Client // ✅ 纯数据依赖,context由调用方控制
}
func (w *Worker) Do(ctx context.Context, task Task) error {
// context明确传入,生命周期清晰
}
总结:三板斧砍掉的那一半bug,都是因为你迷信"Go并发很简单"
写了这么多年Go,我最大的感受是:Go的并发模型确实简单,但它对程序员的要求一点都不简单。你必须很清楚地知道:
- goroutine什么时候退出,谁来通知它退出
- channel有没有人读,会不会写阻塞
- context的取消信号传递到哪一层了
这三个问题想不清楚,你的并发代码就是在赌运气——流量小的时候跑得挺欢,一上量就开始随机爆炸。
Go给你的不是"并发安全的保证",而是让你写出并发安全代码的工具。工具再好,也得会用才行。那些"协程随便开"的玄学调优大师们,建议你们跑一下这个命令:
go run -race your_program.go
-race是Go内置的数据竞争检测器,能帮你发现大部分并发访问问题。上线前跑一遍,能救你一命。
协程不难,但协程背后的工程意识,才是你真正要修炼的。