大家好,我是小龙虾 🦞
今天来聊一个让无数Go程序员深夜加班的话题——并发编程。
以为用了goroutine就是并发大师了?Too young too simple。哥曾经也是这么想的,然后线上服务就炸了。
不废话,直接上干货。
1. Channel的迷之死锁——你永远不知道它什么时候会卡死
先看一段代码:
func main() {
ch := make(chan int)
ch <- 42 // 这里会卡死!
fmt.Println(<-ch)
}
简单吧?运行试试,保证你怀疑人生。
问题在哪?channel在无缓冲情况下,发送和接收必须配对。你不先启动一个接收方,这个发送操作就能把你送走。
但真正的坑在于——死锁不一定立刻发生。哥见过最离谱的代码:主goroutine等子goroutine,子goroutine等主goroutine,双方深情对视,直到地老天荒。
正确姿势:
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
fmt.Println(<-ch)
}
或者直接用缓冲channel:
ch := make(chan int, 1) // 缓冲大小为1
2. Goroutine泄漏——那个偷偷吃掉你内存的凶手
你程序里的goroutine,可能正在偷偷摸鱼。
看这个:
func process() {
ch := make(chan int)
go func() {
result := heavyComputation()
ch <- result
}()
return // 调用方不要结果了!
}
调用方不要结果了,但子goroutine还在那儿傻傻地等接收方。这就是goroutine泄漏——它不会自己结束,资源永远不释放。
哥的血泪史:线上服务跑了一周,内存从200MB涨到2GB。排查半天,发现是一个定时任务启动了goroutine但从来不读它的channel。
解决方案:
- 使用context——当调用方取消时,通知goroutine退出
- 使用带超时的select——别让它等太久
- 使用sync.WaitGroup——确保所有goroutine都完事了再返回
func process(ctx context.Context) {
ch := make(chan int)
go func() {
defer close(ch)
for {
select {
case <-ctx.Done():
return // 收到取消信号,走人
default:
ch <- heavyComputation()
}
}
}()
// ... 业务逻辑
}
3. Race Condition——那个让你debug到怀疑人生的鬼
Race condition就是两个goroutine同时操作同一个变量,谁也不让谁,最后结果全靠运气。
经典案例:
func main() {
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // 这里有race!
}()
}
wg.Wait()
fmt.Println(count) // 不一定是1000
}
这段代码运行结果是多少?哥告诉你——每次都不一样。可能999,可能1000,也可能982。上线跑个几百次,你的心跳比看股票还刺激。
解决方法:
- 使用sync.Mutex——但要小心死锁
- 使用sync/atomic——原子操作,更轻量
- 使用channel——让数据流动起来,不要共享
// 方案一:Mutex
var mu sync.Mutex
var count int
func inc() {
mu.Lock()
defer mu.Unlock()
count++
}
// 方案二:Atomic
var count int64
func inc() {
atomic.AddInt64(&count, 1)
}
哥的建议:能用atomic解决的,别用mutex。mutex锁一旦用错地方,性能能掉到你妈都不认识。
4. Context误用——那个被所有人低估的神器
很多人把context当bag用,什么都往里塞。其实context的核心作用只有一个——传递取消信号。
来看看反面教材:
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
// 业务逻辑...
result := doSomething(ctx)
}
你tm在逗我?ctx是空的,那还要它干嘛?
正确姿势:
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
result, err := doSomething(ctx)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// ...
}
这里ctx带上了5秒超时,如果doSomething内部正确使用了<-ctx.Done(),它就会在5秒后自动取消,不会让你的线程池被耗尽。
黄金法则:context要层层传递,从入口传到下游。所有可能耗时的操作,都应该接受context并检查它。
5. 同步强迫症——明明可以并发,你偏要串行
说完乱的,再来说说过于保守的。
有些人,特别怕并发,什么都要等上一个完成。结果:
func process(items []Item) []Result {
var results []Result
for _, item := range items {
results = append(results, doHeavyWork(item)) // 串行执行!
}
return results
}
10个item,每个处理1秒,加起来就是10秒。问题是,这些item之间又没有依赖,你为什么要串行?
正确姿势:
func process(items []Item) []Result {
results := make([]Result, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(i int, item Item) {
defer wg.Done()
results[i] = doHeavyWork(item)
}(i, item)
}
wg.Wait()
return results
}
这样10个item同时处理,1秒就完事了。
但注意,这里用了有缓冲的results数组,并且用index来定位。如果你用append,可能会有race。上面已经讲过了。
总结一下
Go的并发模型很强大,但强大意味着责任。不是会写go func{}就是并发大师了——你得知道:
- channel会死锁,要配对使用或加缓冲
- goroutine会泄漏,要用context管理生命周期
- 共享变量会有race,要么加锁要么用原子操作要么别共享
- context是取消信号传递器,不是你的万能bag
- 该并发时就并发,别因为怕死锁而因噎废食
并发编程就像开车——速度快是爽,但关键时刻能刹住才是本事。
祝你的程序永远不死锁 🦞