Go 把并发写进了语法,代价却不在语法里。真正的成本出现在四个地方:调度开销被隐藏(go f() 一行就起,没人算它值不值)、所有权没说清(谁该关 channel、谁该取消 context)、错误被丢掉(go 出去的 func 返回值没人接)、泄漏没有报错(goroutine 卡住不会 crash,只会涨内存)。下面按决策顺序拆:模型选择 → 成本 → 泄漏 → 传播 → 安全 → 限流 → 错误 → 竞争 → 边界。
1. CSP 与共享内存的判据
| 场景 | 判据 | 该用 |
|---|---|---|
| 数据所有权转移(生产者交给消费者) | 交出后原持有者不再碰它 | channel |
| 状态被多方读改(计数器、缓存、连接池) | 生命周期跨越多个 goroutine,且需要随机访问 | 互斥锁或原子 |
| 一对一的通知/信号 | 只需要"发生了"这个事实 | 关闭 channel 或 sync.Cond |
"channel 用到底"的过度很常见:把共享 map 塞进一个 goroutine,外面用 channel 请求它——这等于手写了一把更慢的锁。
2. goroutine 成本的真实构成
| 组成 | 典型量级 | 含义 |
|---|---|---|
| 初始栈 | 约 2KB(Go 1.4 起,按版本核对) | 十万 goroutine 就是上百 MB 的栈底 |
| 栈增长 | 按需翻倍拷贝,上限由 debug.SetMaxStack 控制 |
深递归 + 大局部变量会放大 |
| 调度切换 | 用户态,微秒级以下 | 比线程轻,但不是零 |
"轻量不等于免费":每请求起一个在 IO 密集场景可接受,在 CPU 密集场景等于自造调度压力。
3. 泄漏的四种来源
| 来源 | 表现 | 修法 |
|---|---|---|
| 无缓冲 channel 发送阻塞 | 没人接收,发送方永久阻塞 | 带缓冲 + select 加 default 或 ctx.Done() |
| 读取未关闭的 channel | range ch 永不结束 |
明确唯一的关闭方;或用 ctx 退出 |
select 无 default 且无退出分支 |
所有 case 都不就绪 | 加 case <-ctx.Done(): return |
| 请求取消未传递 | 上游断开,下游仍在跑 | 把 ctx 一路传下去,不新建 context.Background() |
// 四种泄漏的紧凑写法
go func() { ch <- v }() // 1. 无人接收 → 泄漏
go func() { for v := range ch { use(v) } }() // 2. 无人 close → 泄漏
go func() { select { case <-a: case <-b: } }() // 3. 都不就绪 → 永久阻塞
go func() { http.Get(url) }() // 4. 忽略 ctx → 上游取消后仍在跑
检测手段:单测取 runtime.NumGoroutine() 做基线与增量断言;线上抓 pprof/goroutine 两次(?debug=2)做差分——两次都出现的栈才是泄漏。
4. context 的传播纪律
| 规则 | 具体做法 | 违反的后果 |
|---|---|---|
| 谁创建 | 入口(HTTP handler、main)创建,函数只接收 | 层内新建导致取消信号断链 |
| 谁取消 | 创建方 defer cancel() |
忘记 cancel 会让 ctx 及其子节点泄漏到超时 |
| 值只放请求域 | traceID、用户身份 | 放依赖对象等于把容器塞进 ctx,隐式耦合 |
| 不作为结构体字段 | 作为第一个参数显式传入 | 生命周期与对象不一致,难以判断取消点 |
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel() // 必须 defer,否则 timer 泄漏
out, err := svc.Do(ctx, in) // 一路往下传,不新建 Background
}
注意 context.WithValue 的 key 用自定义类型,避免字符串 key 冲突;值应当是不可变的请求级数据。
5. 并发安全的三层
| 层次 | 手段 | 适用 | 代价 |
|---|---|---|---|
| 不可变 | 构造后不再修改,替换整个对象 | 配置、路由表、映射快照 | 最低,读无需同步 |
| 互斥 | sync.Mutex / RWMutex |
复合结构的多字段一致性 | 争用下排队 |
| 原子 | sync/atomic / atomic.Pointer |
单个计数、标志、指针替换 | 低,但只保护一个值 |
sync 包的适用面要看清楚:Mutex 保护的是临界区语义,RWMutex 在读多写少时才有意义(写频繁时反而更慢);Once 适合单次初始化;sync.Map 只适合"写少、读多、key 集合稳定"的场景,普通读写用 map + RWMutex 通常更快。
常见错误:RWMutex 嵌套读锁会在等待写锁时死锁;复制含锁的结构体会被 go vet 报出。
6. sync.Pool 的收益边界
| 适合 | 不适合 |
|---|---|
| 短生命周期、创建成本高的临时对象(buffer、编码器) | 长生命周期对象(等价于手工缓存,还多一层) |
| 单次请求内用、用完即还 | 需要保证"池里有货"的场景(GC 会清空) |
| 大小相对稳定的对象 | 大小差异极大(大对象占着池不放) |
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func render(w io.Writer, v any) error {
b := bufPool.Get().(*bytes.Buffer)
defer func() { b.Reset(); bufPool.Put(b) }() // Reset 必须做,否则数据串味
if err := json.NewEncoder(b).Encode(v); err != nil { return err }
_, err := b.WriteTo(w)
return err
}
Pool 的对象随时可能被 GC 回收(Go 1.13 起多一轮 victim 缓冲),只能当"减少分配"用,不能当缓存;归还前不 Reset 是第二大坑。
7. 并行度控制的三种方式
| 方式 | 适用 | 背压表现 |
|---|---|---|
| worker pool(固定 N 个消费者 + 任务 channel) | 任务量大、单任务独立 | channel 满则生产方阻塞,天然背压 |
errgroup + SetLimit |
任务数已知、需要错误聚合 | 超限时 Go 阻塞等待 |
信号量(semaphore.Weighted) |
需要按权重限流(如按字节) | Acquire 阻塞直到有名额 |
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(runtime.GOMAXPROCS(0) * 2) // 按版本核对 SetLimit 的可用性
sem := semaphore.NewWeighted(int64(limit))
for _, u := range urls {
u := u
g.Go(func() error {
if err := sem.Acquire(ctx, 1); err != nil { return err }
defer sem.Release(1)
return fetch(ctx, u)
})
}
return g.Wait()
没有背压的并发等于把上游压力原样传给下游,最后是下游超时、上游堆积。
8. 错误的并发聚合
| 维度 | sync.WaitGroup |
errgroup.Group |
|---|---|---|
| 返回值 | 无 | 有,取第一个非 nil 错误 |
| 取消传播 | 需自己接 ctx |
WithContext 在首个错误时取消 |
| 限流 | 自己控 | SetLimit(x/sync 较新版本) |
| 全部错误 | 自己收集 | 需自建 errors.Join 或切片 |
首个错误适合"失败即无意义"的并行取数;全部错误适合校验类任务,因为用户想知道所有毛病,而不是第一个。选错的代价是要么丢信息,要么白跑完整个批次。
9. 超时与重试的组合陷阱
| 陷阱 | 机制 | 处理 |
|---|---|---|
| 总预算叠加 | 每层各自设超时,外层 1s,内层 3 次重试各 500ms | 只在外层设总预算,内层用剩余时间 |
| 重试放大并发 | 失败后立即重试,同一时刻请求数翻倍 | 退避 + 限流,重试也占用信号量名额 |
| 超时与取消混用 | 用 time.After 而不用 ctx,取消信号不传递 |
统一走 ctx,time.After 只在叶子节点 |
ctx, cancel := context.WithTimeout(parent, 2*time.Second) // 总预算只在这里
defer cancel()
for i := 0; i < 3; i++ {
if err := call(ctx, req); err == nil { return nil }
select {
case <-ctx.Done(): return ctx.Err() // 预算用完就不再重试
case <-time.After(backoff(i)): // 退避也要能被取消打断
}
}
10. 数据竞争的发现
| 手段 | 成本 | 放在哪 |
|---|---|---|
go test -race |
时间与内存开销升数倍(典型量级,按版本核对) | 单测与集成测试默认开启 |
| CI 中的 race 任务 | 拖慢流水线 | 单独 job,跑核心包与并发相关包 |
| 压测时开 race | 吞吐下降,可能掩盖真实瓶颈 | 只用于复现可疑场景 |
代码审查 + go vet |
低 | 每次提交 |
go test -race -count=1 ./internal/... # 单包/多包
go build -race -o app-race ./cmd/app # 带 race 的二进制,跑一遍真实流量
GORACE="halt_on_error=1" go test -race ./... # 首个竞争即中止
-race 只能发现被执行到的竞争;没跑到的路径仍有风险,别把"跑过 race"当成安全证明。
11. 性能与可读性的取舍
| 决策点 | 偏可读 | 偏性能 | 判断依据 |
|---|---|---|---|
| 是否并发 | 串行写清楚 | 分片并行 | 先量测单核是否已满足 SLO |
| 锁粒度 | 一把大锁 | 分段锁 | 争用率(pprof mutex) |
| 队列结构 | channel | 无锁队列/批量 | channel 的调度开销是否已成瓶颈 |
过早并发的典型症状:把循环改成 goroutine,引入锁与顺序问题,实际耗时没变——瓶颈在下游。
12. 排错:五种"看起来是并发问题"的情况
| 现象 | 常见原因 | 排查入口 |
|---|---|---|
| 程序越跑内存越高 | goroutine 泄漏 | pprof/goroutine 两次差分 |
| 偶发结果错乱 | 共享 map 或切片被并发写 | -race 复跑该用例 |
死锁,fatal error: all goroutines are asleep |
全部 goroutine 阻塞在同一 channel | SIGQUIT 拿全栈,找共同阻塞点 |
| QPS 上不去但 CPU 不高 | 限流、连接池或下游延迟 | pprof 看阻塞、看连接池指标 |
| 单请求延迟抖动 | GC 与 Pool 抖动、大量短命对象 |
GODEBUG=gctrace=1 + 分配火焰图 |
13. 边界:什么时候不该并发
| 场景 | 结论 | 理由 |
|---|---|---|
| IO 密集(网络、磁盘) | 值得并发 | 等待期间可以推进其他任务 |
| CPU 密集 | 需多核真并行 | 单核上并发只增加切换成本 |
| 单机小任务(几千条以内的批处理) | 通常不需要 | 串行更快,且好调试 |
| 有强顺序依赖的流程 | 不该并发 | 顺序即语义 |
| 下游有严格限流 | 并发需配背压 | 否则只是把超时前移 |
一句话:收益来自"等待可被重叠",成本来自"状态需要被协调";当协调成本超过收益,串行就是正确答案。
自测
- 判断该用 channel 还是互斥锁,第一句该问什么?
- goroutine 的"轻量"具体省在哪,又在哪里仍然要付钱?
- 用
runtime.NumGoroutine()断言泄漏,为什么必须取基线,且为什么两次抓栈要差分? context的创建方和取消方为什么必须是同一层?WaitGroup与errgroup在"错误语义"上的差别,会怎样影响任务设计?- 为什么"跑过
-race"不能作为竞争已清除的证据?