<p style="text-align:center"><img src="https://platform-outputs.agnes-ai.space/images/t2i/task_NNObBrmnQTVKR4QA6jd5czCmNYGrn7Zv/output_13b3c18601f74e529316dcb048b03b01.png" alt="封面图" style="max-width:100%;height:auto" /></p>
sync.Mutex还是Channel?Go并发场景选型对比与goroutine泄漏实战
先说个真事:去年接手一个订单服务,前任同事把所有并发逻辑都用Channel串起来,一个简单的并发计数都要起goroutine走管道,结果QPS一压就锁死------无缓冲Channel没人收,生产者全挂起。反过来我也见过全程裸写Mutex,忘了unlock直接死锁的。
Go圈那句名言"Don't communicate by sharing memory; share memory by communicating"经常被读歪了。它不是说Channel永远对,而是让你按场景选。今天就把这两个方案的选型边界讲清楚。
场景一:共享状态保护------Mutex是正解
热点数据的并发读写,比如本地缓存的计数器、配置热更新,用Channel纯属脱裤子放屁。每次读写都经过一个专职goroutine转发,多一次调度、多一次内存拷贝,压测下来吞吐能差3~5倍。
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.RWMutex
counter := map[string]int{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter["order"]++
mu.Unlock()
}()
}
wg.Wait()
mu.RLock()
fmt.Println(counter["order"]) // 1000
mu.RUnlock()
}
跑一遍输出`1000`。关键点:临界区只做内存操作,别把IO塞进锁里;读多写少用`RWMutex`,我天天用这个组合。加上`-race`参数跑一遍(`go run -race main.go`),没报race就是干净的。
场景二:任务分发与结果汇聚------Channel才是主场
fan-out/fan-in模式、生产者消费者、流水线,这些场景Channel的语义优势碾压Mutex:数据归属权随传递转移,不用操心谁在改它。
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(w, jobs, results, wg)
}
for j := 1; j <= 10; j++ {
jobs <- j
}
close(jobs)
go func() {
wg.Wait()
close(results) // 谁发送谁关闭,铁律
}()
for r := range results {
fmt.Println(r)
}
}
输出1~10各自乘2的10个结果。这里有个底层原理得说透:Channel关闭后`range`才能退出,goroutine才有机会结束。**永远由发送方关闭,接收方只管收**。谁乱close谁背锅,重复close会直接panic。
场景三:泄漏现场------select + context保命
goroutine泄漏是生产环境最常见的慢性病:Channel没人收,发送方永远阻塞在`ch <-`上,goroutine堆着不释放,pprof一看goroutine数几万起步。goleak告警半夜响过的人都懂。
package main
import (
"context"
"fmt"
"time"
)
func fetch(ctx context.Context, ch chan<- string) {
select {
case <-time.After(2 * time.Second): // 模拟慢调用
ch <- "done"
case <-ctx.Done(): // 超时/取消时必须给goroutine留退路
fmt.Println("worker exited:", ctx.Err())
}
}
func main() {
ch := make(chan string, 1) // 带缓冲,即使没人收也能塞进去
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
go fetch(ctx, ch)
select {
case r := <-ch:
fmt.Println(r)
case <-ctx.Done():
fmt.Println("caller timeout:", ctx.Err())
}
time.Sleep(3 * time.Second) // 观察worker是否退出
}
输出先是`caller timeout: context deadline exceeded`,100ms后worker打印`worker exited: context deadline exceeded`后正常退出------没泄漏。两个要点:**结果Channel给1缓冲**,让慢goroutine发送后能走人;**select里必须监听ctx.Done()**。这是《Go Concurrency Patterns》里pipeline模式的标配写法。
底层补一刀
GMP调度模型下,goroutine阻塞在无缓冲Channel上会被gopark挂起,M去跑别的P;Mutex竞争激烈时会自旋几次再休眠。所以轻量临界区Mutex开销极低(纳秒级),而Channel一次收发涉及hchan锁+调度,微秒级。这不是谁快谁慢的问题,是**语义匹配**:数据所有权转移用Channel,状态保护用Mutex,混合场景两者一起上不丢人。
避坑清单
-
无缓冲Channel发送前先想清楚谁收、什么时候收,不确定就加缓冲
-
只由发送方close,多发送方场景用sync.Once或额外done Channel协调
-
所有常驻goroutine必须有退出路径:ctx.Done()或显式done信号
-
CI里强制`go test -race`,上线前goleak检查测试用例
-
定期`curl :6060/debug/pprof/goroutine?debug=1`看goroutine数曲线,异常增长先查阻塞的Channel
你的项目里有没有那种"感觉能并发但不敢动"的老代码?评论区贴出来一起看看怎么改。