channel 用得越多,代码越难读——这不是错觉。每一条 channel 都是一次所有权移交或一次隐式同步,读者要在脑子里维护"谁在等谁"。很多并发代码之所以难改,不是因为并发本身复杂,而是因为把不该用 channel 的地方也套上了 channel。
这篇不谈语法,只谈判断:什么协作方式配什么场景、channel 的四类误用、泄漏与失控从哪里来、以及什么时候并发本身就是错的。
1. 三种协作方式与各自的适用面
Go 里能用的协作方式就三种,选错的信号通常是"代码越写越长,但说不清在等什么"。
| 方式 | 适用面 | 不适用 |
|---|---|---|
| goroutine + channel | 流水线、所有权移交、一对一分发 | 共享状态的频繁读写、广播 |
| 共享内存 + mutex | 多协程读写同一份状态、计数、缓存 | 跨阶段的所有权转移 |
| errgroup / context 编排 | 一组任务同生共死、统一取消与超时 | 长期驻留的后台循环 |
// 编排:一组任务,任一失败即取消全部
g, ctx := errgroup.WithContext(ctx)
for _, shard := range shards {
shard := shard
g.Go(func() error { return process(ctx, shard) })
}
if err := g.Wait(); err != nil { return err }
判断顺序建议固定:先问任务是否同生共死,再问数据是否移交所有权,最后才问要不要加锁;反过来问,会得到一堆互相等待的 channel。
2. "用通信代替共享内存"的边界
这句话常被当成教条。它成立的前提是:数据确实在协程之间转移所有权,移交后原持有者不再碰它。
| 场景 | 适合 channel | 理由 |
|---|---|---|
| 流水线阶段间传递结果 | 是 | 所有权单向移交,天然串行 |
| 任务分发(一个生产者、多个消费者) | 是 | 分发即移交 |
| 状态查询(读当前配置/计数) | 否 | 查询不是移交,用锁或 atomic 更直接 |
| 多协程累加计数 | 否 | channel 只是把锁换了个位置,还更慢 |
| 广播(一个事件通知多个订阅者) | 否 | 单 channel 只能被一个接收者取走 |
| 缓存读写 | 否 | 生命周期长,移交语义不成立 |
// 反例:用 channel 做计数,等于用消息队列实现一个整数
counter := make(chan int, 1)
counter <- 0
go func() { counter <- <-counter + 1 }() // 读改写被拆成两次阻塞
// 正例:状态就是状态,用原子或互斥
var hits atomic.Int64
hits.Add(1)
判据:channel 传的是"以后归你",不是"现在是多少";后者用锁、atomic 或只读快照。
3. channel 的四类误用
| 误用 | 症状 | 后果 |
|---|---|---|
| 当无界队列用 | 生产快、消费慢,缓冲一直涨 | 内存随积压线性增长,最终 OOM |
| 用 channel 做广播 | 多个接收者只有一个拿到 | 逻辑上"通知了所有人",实际只有一个 |
| 关闭时机错 | 发送方已关闭仍有人写 | panic: send on closed channel |
| select 无 default 死等 | 两个分支都可能永远不就绪 | 协程永久阻塞,泄漏 |
// 关闭纪律:由唯一的发送方关闭,接收方永不关闭
go func() {
defer close(out) // 发送方负责关
for _, v := range items { out <- v }
}()
// 多路等待必须有出口:ctx 或 default,二选一
select {
case v := <-ch:
handle(v)
case <-ctx.Done():
return ctx.Err()
}
广播要么关一个信号 channel 唤醒全部,要么给每个订阅者一条独立 channel 传值。
4. 缓冲与非缓冲的语义差别
缓冲不是性能参数,是耦合方式:无缓冲要求双方时间上会合,有缓冲允许先跑一段,代价是积压不可见。
| 类型 | 语义 | 适用 | 风险 |
|---|---|---|---|
| 无缓冲 | 严格交接,双方同时就绪 | 需要背压、需要确认对方已接手 | 接收方慢即拖住发送方 |
| 缓冲 = 1 | 允许一次预取 | 单槽信号、双缓冲 | 容易误当成"解耦" |
| 缓冲 = N(固定小值) | 削峰但仍有上限 | 批处理、突发流量 | N 的依据常是拍的 |
| 缓冲 = 队列长度 | 事实上是无界队列 | 几乎不该出现 | 积压转为内存 |
// 有界:满了就阻塞,或者显式拒绝
ch := make(chan Job, 128)
select {
case ch <- job:
default:
return ErrOverloaded // 拒绝比无限缓冲安全
}
缓冲大小要按 P99 耗时 × 允许积压时长估算,而不是"给个 1024 试试";写不下依据,说明这里该用队列或限流。
5. goroutine 泄漏的三种成因与检测
goroutine 泄漏的定义很窄:它永远阻塞在一个再也不会有结果的等待上。成因只有三类。
| 成因 | 典型写法 | 检测信号 |
|---|---|---|
| 无出口的阻塞 | 发送到无人接收的 channel、等不会来的锁 | goroutine 数随请求单调上升 |
| 忘了 cancel | 建了带 cancel 的 ctx 却没调 | ctx 树增长、超时不生效 |
| 消费端提前退出 | 接收方 return,发送方卡住 | 泄漏与下游错误率同步 |
// 基线对比:绝对值没有意义,差分才有
base := runtime.NumGoroutine()
for i := 0; i < 1000; i++ { doRequest() }
time.Sleep(100 * time.Millisecond)
if n := runtime.NumGoroutine(); n > base+10 {
pprof.Lookup("goroutine").WriteTo(os.Stderr, 1) // 抓栈看阻塞点
}
检测靠 goroutine profile:?debug=1 看聚合计数,?debug=2 看每条栈;两次抓取差分,持续存在的才是泄漏候选。
6. context 的传播纪律
context 的问题几乎都出在"谁负责取消"没定清。纪律只有三条:创建方负责 cancel;只往下传不往上存;值只放请求域数据。
// 创建方 defer cancel,绝不把 cancel 传给别人
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
// 反例:把 ctx 存进结构体字段,生命周期就跟着对象走了
type Server struct{ ctx context.Context } // 无法随请求结束而结束
值只放请求域数据(trace id、租户、认证主体),不放参数、连接、logger——依赖塞进 ctx 会让签名失去表达力,测试也被迫构造 ctx。
7. sync 的选择与锁粒度
| 原语 | 用在什么上 | 常见误用 |
|---|---|---|
sync.Mutex |
保护不变量、临界区短 | 临界区内做 IO 或调用外部 |
sync.RWMutex |
读远多于写且读临界区不短 | 读多但临界区极短,读锁开销反而更高 |
sync.Once |
一次性初始化 | 用 Once 实现"只执行一次业务逻辑"(失败不可重试) |
sync.WaitGroup |
已知数量的等待 | Add 在 Wait 之后调用 |
atomic |
单个标量、标志位、计数 | 用它保护多字段的不变量 |
锁不是越细越好:拆锁会引入"两个锁之间的中间态",太粗则把无关操作串行化。判据是临界区里有没有与共享状态无关的工作。
// 假共享:两个独立计数落在同一 cache line,互相拖慢
type padded struct {
a atomic.Int64
_ [56]byte // 填充到 cache line 边界,按 CPU 架构核对
b atomic.Int64
}
8. 并发度控制与失败传播
无界并发的内存爆炸不是"goroutine 太重",而是每个 goroutine 背后的请求、缓冲、响应体都在堆上。并发度必须有上限,且上限要有依据。
// 信号量:限制同时在飞的数量
sem := make(chan struct{}, 32)
for _, id := range ids {
sem <- struct{}{}
go func(id string) {
defer func() { <-sem }()
_ = fetch(id)
}(id)
}
errgroup.SetLimit(n) 是同一件事的封装且自带取消与错误聚合,优先用。手写信号量时 defer 必须在 goroutine 内。
panic 不能跨 goroutine 捕获。任一新起的 goroutine 里 panic,若没有自己的 recover,整个进程会退出;recover 写在父 goroutine 的 defer 里没用。
go func() {
defer func() {
if r := recover(); r != nil {
errCh <- fmt.Errorf("worker panic: %v", r)
}
}()
work()
}()
失败传播同理:goroutine 里的 error 不会自己回到调用方,必须有 channel 或 errgroup 承接;否则错误被静默吞掉,表现为"任务少了几个但没人知道"。
9. 竞态检测与可观测
-race 是采样式的动态检测:它只能报告实际执行到的冲突访问。跑过不等于没有竞争,只等于这次没撞上。
| 手段 | 能看到 | 局限 |
|---|---|---|
go test -race |
运行期真实冲突 | 覆盖率依赖用例路径 |
-race + 压测 |
集成场景冲突 | 有开销,不能常开 |
| goroutine profile | 阻塞点与数量 | 不告诉你谁该负责 |
| block profile | 阻塞时长分布 | 需要显式开启采样率 |
| mutex profile | 锁争用 | 同上,采样有偏 |
// 开启阻塞与锁采样,再跑压测,才有数据
runtime.SetBlockProfileRate(1)
runtime.SetMutexProfileFraction(1)
// 之后访问 /debug/pprof/block 与 /debug/pprof/mutex
可观测的最小集合:goroutine 数量、block profile、每类操作的在飞数量。三个指标一起看,能区分"泄漏"(数量只涨不落)、"阻塞"(时长分布右偏)、"过载"(在飞数量顶到上限)。
10. 排错:五种"像是并发问题"的情况
| 现象 | 更可能的原因 | 处理 |
|---|---|---|
| 内存涨但协程数正常 | 结果被整批持有或缓冲过大 | 堆直方图找持有者 |
| 偶发超时,重试就好 | 下游慢 + 无超时,或重试叠乘 | 加超时、核对重试层数 |
| CPU 不高但吞吐上不去 | 锁争用或 channel 交接开销 | mutex/block profile |
| 没报 race 但结果错 | 逻辑顺序问题,不是数据竞争 | 查依赖顺序与 cancel 时机 |
| 关掉服务后进程不退 | 后台 goroutine 无退出条件 | 找到那个没有 ctx 的循环 |
11. 边界:这些情况不该并发
| 场景 | 结论 | 理由 |
|---|---|---|
| 任务极短(微秒级) | 串行更快 | 调度与同步开销超过收益 |
| 并发度个位数 | 串行或起几个 | 编排成本大于收益 |
| 逻辑本身有强顺序 | 不该并发 | 顺序即语义 |
| 单核 | 只增加切换 | 没有可重叠的等待 |
| 下游有严格限流 | 并发必须配背压 | 否则只是把超时前移 |
一句话:收益来自"等待可被重叠",成本来自"状态需要协调"。当协调成本超过收益,串行就是正确答案。
自测
- 判断该用 channel 还是 mutex,你第一句问什么?
- 为什么"状态查询"不适合用 channel?它把锁换成了什么?
- 关闭 channel 的责任应该落在谁身上?接收方关闭会怎样?
- goroutine 泄漏的三种成因分别怎么检测?为什么要做差分?
recover写在父 goroutine 里为什么无效?- 跑过
-race能否作为"没有竞争"的证据?为什么?