Go 把并发做成了语言的一等公民,写起来确实顺手:go 一下就是并发,<- 一下就是通信。顺手的代价是出问题时不好查——泄漏的 goroutine、死锁的 channel、传不到下游的取消信号,都是线上事故的常客。把这套模型的底层约定讲清楚,写起来才踏实。
一、goroutine 不是线程
新手容易把 go f() 理解成”开一个线程”,其实差别不小。goroutine 是用户态的执行体,由 Go 运行时的调度器在少数几个操作系统线程(通常与 CPU 核数相关)上调度,这套机制常被称作 M:N 调度。和线程比,几个关键差异值得记住:
| 维度 | goroutine | 操作系统线程 |
|---|---|---|
| 创建成本 | 很低,可以轻松开很多 | 较高 |
| 栈 | 动态增长的分段栈 | 固定大小 |
| 调度 | 用户态,切换不陷入内核 | 内核调度 |
| 数量级 | 轻松到数万以上 | 通常几百就吃力 |
正因为便宜,Go 里常见的是”一个任务一个 goroutine”的朴素写法。但便宜不等于免费:每个 goroutine 至少要占一份栈,无节制地开同样会把内存吃穿。
goroutine 的初始栈很小(量级是 KB 级),按需扩容。如果某个 goroutine 里放了巨大的局部数组,栈会一直涨,这时候显式分配到堆上反而更可控。
二、CSP:通过通信共享内存
Go 的并发哲学常被概括成一句话:通过通信共享内存,而不是通过共享内存通信。它的理论来源是 CSP(Communicating Sequential Processes):每个 goroutine 是一个顺序执行的计算单元,彼此之间靠 channel 传递消息。
ch := make(chan int)
go func() {
ch <- 42 // 发送
}()
fmt.Println(<-ch) // 接收
这不意味着互斥锁就过时了。数据纯粹单向流动、生产消费关系清晰时用 channel;多个 goroutine 就地维护一份共享状态时,sync.Mutex 往往更直接。选型标准是”谁拥有这份数据”:所有权清晰走 channel,共享所有权走锁。
三、channel 的缓冲与阻塞语义
ch := make(chan int) // 无缓冲:发送方阻塞到有人接收
buf := make(chan int, 8) // 缓冲 8:满了才阻塞
无缓冲 channel 的收发是同步的,可以理解成一次交接;缓冲 channel 是个有界队列,缓冲区满时发送阻塞,空时接收阻塞。关闭用 close(ch),关闭后接收方会读到零值,配合 v, ok := <-ch 可以区分”收到了零值”和”channel 关了”。
几条容易踩的规则:
- 只应由发送方关闭 channel,接收方关闭会导致发送时 panic。
- 向已关闭的 channel 发送会 panic,从已关闭的读取只会拿到剩余数据然后是零值。
- 无缓冲 channel 用在”必须有人接住”的同步点上;缓冲 channel 用来削峰、解耦生产消费速率。
for v := range ch 会在 channel 关闭时退出循环,这是生产者消费者写法里最常用的收尾方式。
四、select:多路复用
select 让一个 goroutine 同时等好几个 channel:
select {
case v := <-results:
handle(v)
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
return errTimeout
default:
// 没有任何就绪时走这里,非阻塞
}
多个 case 同时就绪时 Go 会伪随机挑一个,不要依赖”谁先谁后”。default 分支把 select 变成非阻塞,轮询和兜底逻辑常用;但要注意忙等,必要时配合 time.Sleep 或退避。
五、几个常用并发模式
生产者消费者。 一个或多个生产者往 channel 写,一个或多个消费者读,用 sync.WaitGroup 等所有生产者结束后 close(ch),消费者 range 到自然退出。这是最稳的骨架,能覆盖一大半需求。
扇入扇出(fan-out / fan-in)。 一个输入 channel,起 N 个 worker 并行处理,再把结果汇到一个输出 channel:
out := make(chan Result)
for i := 0; i < n; i++ {
go func() {
for job := range jobs {
out <- process(job)
}
}()
}
并发度取多少没有万能数字,要看下游承受力和任务是 CPU 密集还是 IO 密集:CPU 密集贴着核数就够,IO 密集可以放大若干倍,但放大之后内存、文件句柄、下游连接数都会跟着涨,压测一轮比拍脑袋可靠。worker 数固定之后,再用带缓冲 channel 当信号量限制在途任务,就得到了一个并发度可控的 worker pool。
六、错误与 panic 怎么收
并发之后错误处理也变了样:主流程拿不到子 goroutine 的返回值,错误得自己送回来。常见做法是把结果和错误打包成一个结构体发进结果 channel,或者用 errgroup 这类把”等一组 goroutine 全部结束 + 收第一个错误”合并起来的工具。
panic 尤其要小心。goroutine 里的 panic 只会带崩整个进程,recover 只能兜住当前 goroutine,所以如果某个任务来自不可信输入,值得在 worker 里包一层 defer recover,把 panic 转成错误往上报,而不是让一个坏任务把服务打死。
七、context:把取消传下去
context 解决的是”请求结束了,下游还在跑”的问题。它携带三样东西:取消信号、截止时间、少量请求域的键值对。
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel() // 必须调用,泄漏检测会盯着这个
resp, err := doSomething(ctx)
约定写法是 ctx 作为第一个参数显式传递,不要塞进结构体里全局共享。下游实现要监听 ctx.Done(),在阻塞操作前就检查 ctx.Err(),IO 调用优先用接受 ctx 的版本。
两个常见误区:一是 defer cancel() 忘了写,WithCancel / WithTimeout 派生出的资源会挂到父 context 上直到父级取消;二是把 context 当普通数据包滥用,塞业务参数,那会让签名变味,键值对只放 trace id 这类横切信息。
八、race detector 与数据竞争
数据竞争是 Go 里最难查的 bug 之一,因为表现常常是偶发的脏读。工具链给了检测器:
go test -race ./...
go run -race main.go
检测器在运行时对内存访问插桩,发现无同步的并发读写就报出来。代价是运行变慢、内存变多,建议常开在 CI 里,本地调性能时再关掉。要注意它只能发现实际发生过的竞争,没跑到的路径照样漏,所以用例覆盖同样重要。
锁的使用上,sync.RWMutex 适合读多写少,sync.Map 适合键集合稳定、读远多于写的场景,其他时候普通 map 加锁更直白。原子操作 sync/atomic 用于计数器这类单值场景最合适。
九、goroutine 泄漏:怎么防
泄漏的典型形态是 goroutine 阻塞在没人读的 channel 上,或者等一个永远不来的信号,请求结束了它还挂着。排查手段:
- 用
pprof的 goroutine profile 看数量曲线和调用栈。 - 测试里起
goleak之类的检测,在用例结束时比对 goroutine 数量。 - 写的时候就问一句:这个 goroutine 的退出条件是什么?谁来触发?
防泄漏的核心是所有阻塞点都要有退出路径:要么 select 上挂 ctx.Done(),要么有超时,要么保证 channel 一定被关闭或被消费。写并发代码时把”退出”当成和”进入”同等重要的设计,比事后查泄漏便宜得多。代码评审时可以专门看这一项:新起的 goroutine,谁能终止它,走哪条路径终止。如果答不出来,那它就是下一次泄漏的候选。
十、写在后面
Go 的并发原语不多,channel、select、context、WaitGroup、Mutex,组合起来就能表达大部分模式。真正难的不是语法,是把数据的所有权、退出的时机、失败的传播这三件事在设计阶段就想清楚。
并发 bug 难复现,靠 print 调试基本无效,善用工具能省很多时间。-race 之外,pprof 的 goroutine profile 能直接看到几千个 goroutine 卡在哪个调用栈上,是查泄漏和死等的首选;GOMAXPROCS、GODEBUG 里的调度相关开关在排查吞吐异常时偶尔用得到。
写并发代码时还有一条实用经验:把同步点显式化。谁等谁、等多久、等不到怎么办,尽量落成代码里的 channel、WaitGroup、context,而不是注释里的约定。同步点藏在注释里,下一个人就只能靠猜。
判断一段并发代码写得好不好,有个朴素标准:能不能明确说出每条数据归谁管、每个 goroutine 什么时候退出、取消信号走到哪一层。三句话都能答上来,这段代码基本不会出大问题。
评论(2)
第一次见到这么系统的整理。
这个方案我试过,主要成本在维护上。
请 登录 后参与评论。