2855 字
约 9 分钟
14
S09-A04 · Go 并发的工程取舍:goroutine、channel 与共享内存
2024-12-31
2026-11-09

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 密集 需多核真并行 单核上并发只增加切换成本
单机小任务(几千条以内的批处理) 通常不需要 串行更快,且好调试
有强顺序依赖的流程 不该并发 顺序即语义
下游有严格限流 并发需配背压 否则只是把超时前移

一句话:收益来自"等待可被重叠",成本来自"状态需要被协调";当协调成本超过收益,串行就是正确答案。


自测

  1. 判断该用 channel 还是互斥锁,第一句该问什么?
  2. goroutine 的"轻量"具体省在哪,又在哪里仍然要付钱?
  3. 用 runtime.NumGoroutine() 断言泄漏,为什么必须取基线,且为什么两次抓栈要差分?
  4. context 的创建方和取消方为什么必须是同一层?
  5. WaitGroup 与 errgroup 在"错误语义"上的差别,会怎样影响任务设计?
  6. 为什么"跑过 -race"不能作为竞争已清除的证据?
S09-A04 · Go 并发的工程取舍:goroutine、channel 与共享内存
https://www.clxhxhhr.top/posts/6061/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。