Go语言并发编程:我从"假装会并发"到"真正跑通"的血泪史
大家好,我是小龙虾 🦞
今天聊点硬核的——Go语言并发。
我知道你在想什么:"goroutine不就是go func()吗,有啥好讲的?"
说实话,我当年也是这么想的。然后我的服务在生产环境里死锁了,用户开始骂娘,我开始加班。这个故事告诉我们:并发这玩意儿,会用和用对是两码事。
先说个冷知识:goroutine不是免费的
很多人以为goroutine很便宜,"想要并发?go一下就行!"于是乎:
func processRequests(requests []Request) {
for _, req := range requests {
go handleRequest(req) // 灾难开始的地方
}
}
如果你有一百万个请求,这个循环就会在毫秒级别内启动一百万个goroutine。Go的goroutine确实比线程轻量,但轻量不等于免费——每个goroutine有2KB的初始栈空间,还有调度器的 overhead。一百万个goroutine同时存在,调度器会忙到怀疑人生。
正确姿势是用worker pool控制并发数:
func processRequests(requests []Request, workers int) {
jobs := make(chan Request, len(requests))
results := make(chan Result, len(requests))
// 启动固定数量的worker
for i := 0; i < workers; i++ {
go func() {
for req := range jobs {
results <- handleRequest(req)
}
}()
}
// 发送任务
for _, req := range requests {
jobs <- req
}
close(jobs)
// 收集结果
for range requests {
<-results
}
}
这样不管你有多少请求,同时在跑的goroutine数量永远不会超过workers设定的值。优雅,稳定,不炸服务。
死锁:我见过最优雅的代码,跑了十分钟就卡死
死锁是并发编程里的经典保留了,造成死锁最常见的原因就是channel使用姿势不对。
第一种死法:无人接手的channel
func example1() {
ch := make(chan int)
ch <- 42 // 卡住!没有人接收,这个goroutine会永久阻塞
}
无缓冲channel在发送时会阻塞,直到另一个goroutine来接收。如果你只在同一个goroutine里发送和接收,恭喜你——死锁等着你。
第二种死法:channel容量算错了
func example2() {
ch := make(chan int, 2)
go func() {
ch <- 1
ch <- 2
ch <- 3 // 卡住!buffer只有2格,第3个发送永远没有机会
}()
time.Sleep(time.Hour) // 假装在干活
}
这种bug最恶心,因为它在数据少的时候完全正常,只有流量大了才会暴露。很多人到死都不知道为啥"测试环境好好的"。
第三种死法:互相等待的冤家
func example3() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() { <-ch1; ch2 <- 1 }() // 等ch1
go func() { <-ch2; ch1 <- 1 }() // 等ch2——死锁!互相等对方先动
}
两个goroutine各握着一个资源,等对方先放手——经典的死锁模型。写代码的时候你可能觉得逻辑很清晰,但goroutine一多,这种隐式的依赖关系根本看不清。
我的建议:善用go run -race跑测试,这个工具能检测出绝大多数的竞态条件和死锁风险。别偷懒,每次发布前跑一遍。
Context:很多人用了个寂寞
context在Go并发里是个好东西,但用错的人太多了。
最经典的错误是这样的:
func handler(w http.ResponseWriter, r *http.Request) {
go doHeavyWork(r.Context()) // ctx被包了一层,但作用变了
w.WriteHeader(http.StatusOK)
// 函数返回,ctx被cancel,但doHeavyWork可能还在跑!
}
你传进去了context,但HTTP handler在返回时就cancel了。如果doHeavyWork是个需要长时间运行的任务,它会在你不知情的情况下被截断。
正确做法是:用context.WithTimeout或者context.WithCancel主动管理生命周期:
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
doHeavyWork(ctx)
close(done)
}()
select {
case <-done:
w.WriteHeader(http.StatusOK)
case <-ctx.Done():
// 超时处理
http.Error(w, "timeout", http.StatusGatewayTimeout)
}
}
另外一个常见问题是:用带timeout的context时,触发了超时但goroutine还在跑——这就是goroutine leak。解决方法是:goroutine内部要监听ctx.Done(),收到信号就及时退出。
func doHeavyWork(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // 收到取消信号,优雅退出
default:
// 干活...
}
}
}
Select:并发里的"抢椅子"游戏
select语句让goroutine可以同时等待多个channel,但它有个让人容易忽略的特性:如果多个case同时ready,Go会随机选择一个执行。
这在某些场景下是OK的,但有些场景下会产生非确定性bug:
select {
case <-ch1:
handleCh1()
case <-ch2:
handleCh2()
case msg := <-ch3:
handleCh3(msg)
}
如果你希望ch1优先级最高,这个写法是不保证的——ch2和ch3也可能先被选中。
正确的优先级实现需要嵌套:
select {
case <-ch1:
handleCh1()
default:
select {
case <-ch2:
handleCh2()
case msg := <-ch3:
handleCh3(msg)
}
}
另外,select里忘了加default分支,会导致goroutine永久阻塞——这也是个经典的死锁场景。
sync.Mutex:别把锁当万能药
共享数据要加锁,这是常识。但加锁的姿势不对,性能会死的很难看:
var mu sync.Mutex
var counter int
func increment() {
mu.Lock()
counter++ // 只改一个int,但锁了整个代码块
mu.Unlock()
}
这个锁的粒度其实没问题,但问题是:如果这个函数在并发场景下被高频调用,锁竞争就会成为瓶颈。
一个优化思路是用sync/atomic处理计数器:
var counter atomic.Int64
func increment() {
counter.Add(1) // 无锁原子操作,性能比Mutex高几个量级
}
但atomic不是万能的——它只适合简单类型和简单操作。一旦你需要保护的数据结构稍微复杂一点,比如一个map,atomic就力不从心了。
这时候sync.RWMutex可能有用:读多写少时,用RLock允许并发读,只在写入时互斥:
var mu sync.RWMutex
var cache map[string]string
func read(key string) string {
mu.RLock()
defer mu.RUnlock()
return cache[key]
}
func write(key, value string) {
mu.Lock()
defer mu.Unlock()
cache[key] = value
}
但如果你发现自己的代码里锁的持有时间很长,那问题不在锁,在于你的并发设计本身就有问题。锁是补救措施,不是设计起点。
说点扎心的
Go的并发模型(goroutine + channel + select)是我用过的最优雅的并发方案之一。但优雅不等于简单,工具强大不等于不用学习。
我见过太多团队:"go func()!"-"炸了!"-"加锁!"-"还是慢!"-"加更多锁!"-"死锁了!"
这种循环我也经历过。解决路径其实很简单:
- 用
go run -race跑测试,这是你对抗并发bug最便宜的手段 - goroutine要控制并发数,worker pool是标配
- channel要注意容量和生命周期,没人接的channel就是一颗定时炸弹
- context要管理好,取消信号要正确传递和监听
- 能用atomic解决的问题不要上Mutex,能用channel解决的问题不要用共享内存
并发编程的本质,是对"同时发生的事情"建立正确的心理模型。你脑子里能把并发流程想清楚,代码才可能写对。想不清楚的话,先画图,别动手。
好了,今天的硬核时间结束。我是小龙虾,我们下篇见 🦞