别再凭直觉选 Go 并发原语:benchmark 实测、源码根因与避坑清单

用一组自造 Go benchmark 把反直觉结论坐实:Channel 做单例慢 19–280 倍、atomic 碾压 Mutex、WaitGroup 万级退化。每个结论配标准库源码根因,末尾附场景化选型清单。

封面

你刚 review 完同事的一段代码。配置中心要从环境变量懒加载,他写了一个 buffered channel,启动时往里塞一个 *Config,后面每次 cfg := <-configCh 取出来用。你脑子里闪过"Go 风格、挺地道",就放行了。

我干过一模一样的事。直到有一天我闲着无聊,把"用 sync.Once 取单例"和"用 Channel 取单例"拉到计时器面前比了一下:前者每次 0.42 纳秒,后者 16 到 119 纳秒,差了 19 到 280 倍

这不是我引用的哪篇博客的数字,是我自己在一台 M4 Pro 上跑出来的。那篇流传很广的"Go 并发性能大比拼"说 Channel 做单例"慢 3 倍",它说少了,少了一个数量级往上。

我写这篇文章,不是为了再给你一份会过时的"性能排行榜"。排行榜看绝对值、机器会变、Go 版本会改。我想做的是更实在的:把你平时靠"感觉"选的并发原语,一个个拉到计时器面前,看它们到底开多快、为什么开这么快,再给你一张能直接照着选的清单。

先立一个贯穿全文的立场:并发原语没有高下,只有场景匹配。 下面前四节用 benchmark 实测与机制推演支撑这句话,最后一节再补一个容易被忽略的正确性陷阱——反直觉的结论,根子也都在场景上。

⚠️ 测量条件声明:全文 benchmark 统一在 Go 1.26.4 / darwin(arm64) / Apple M4 Pro(14 核)/ GOMAXPROCS=14 下测量,所有返回值均赋给包级 sink 防 DCE。数字的绝对值会随机器和版本漂移,但相对倍数和结论是稳定的。请记住倍数,别盯绝对值。

一、Channel 做单例:传闻慢 3 倍,实测慢一个到两个数量级

先拆一个最常见的误解。

误解:Channel 是 Go 的语言哲学,“用 Channel 实现单例"既优雅又地道,性能不会差到哪去。毕竟 <-configCh 写起来比 once.Do(...) 顺手,看着也"更 Go”。

为什么这个误解那么有市场?因为 Channel 做单例的那行代码实在太好看了:

var configCh = make(chan *Config, 1)
func init() { configCh <- loadConfig() }
func GetConfig() *Config { return <-configCh }

一个 channel、一行发送、一行接收,没有任何"锁"的字眼。但好看不等于便宜。这段代码的问题不在逻辑,在于它只能取一次:channel 容量是 1、init 只塞了一次,第二次调用 GetConfig() 时 channel 已经空了,单 goroutine 下会永久阻塞(直接 deadlock)。所以真实的可重复读取必须有后台生产者持续补货——这也正是下面 benchmark 的实现方式。你 review 时直觉上觉得"没锁就好",恰恰掉进了"无锁字眼 = 无开销"的错觉:channel 内部有自己的锁,而且一旦用错,比锁更隐蔽。

实测:我写了两组"反复获取已初始化单例"的基准(完整代码见 evidence/code/singleton)。第一组用 sync.Once,第二组用 buffered channel,后台起一个 goroutine 持续补货,主 goroutine 每次从 channel 接收。

获取方式 ns/op allocs/op 相对 Once
sync.Once(已初始化后反复获取) 0.42 0 1x
Channel(同 goroutine 发送+接收往返,2 次操作) 16.43 0 ≈39x
Channel(后台生产者补货 + 接收,1 次操作) 119.5 0 ≈284x

两类实现都不分配内存(allocs 都是 0),所以差距不是来自 GC 或堆分配,纯粹是同步路径的长短。注意我用两个 Channel 变体是有意的:第一行"发送+接收往返"测的是两个通道操作,所以 39 倍把单例的真实成本翻倍计了——把往返拆成单次接收,真实懒加载单例每次只做一次接收,等价约 19 倍;而真实生产里单例需要有人不断把值塞回 channel,后台生产者带来的调度开销让差距拉到 284 倍。三种口径都诚实,区别在于你的单例实现离哪种更近:最常用的"后台补货 + 单次接收"才是 280 倍量级的真相。

Once vs Channel 单例获取开销对比(0.42 / 16.4 / 119.5 ns,对数横轴)

根因sync.Once.Do 在首次调用之后,热路径只剩一次原子读,编译器甚至把它内联了:

// src/sync/once.go
func (o *Once) Do(f func()) {
    if !o.done.Load() {   // 一次原子读,已初始化后直接返回,无锁无拷贝
        o.doSlow(f)
    }
}

而 Channel 的每次接收,在运行时要走 chanrecv:加锁、从环形缓冲区拷贝元素、解锁。这段路径在 runtime/chan.go:524(对应 Go 1.26.4)的 chanrecv 里,核心开销是 c.lock 的获取和 typedmemmove 的数据拷贝,哪怕元素只是一个 int,你也拿到的是一份值的拷贝,不是指针:

// src/runtime/chan.go(节选)
func chanrecv(...) {
    lock(&c.lock)       // 取锁
    typedmemmove(...)   // 拷贝元素值,而非指针
}

根因就一句:Once 拿到指针直接返回,Channel 每次都要加锁再拷贝一份值。两者根本不在一个量级,所谓"慢 3 倍"严重低估了差距。

Once 原子读直返指针 vs Channel 加锁拷贝值 开销路径对比

这里也插一句,有人会想到 sync.Mutex 的饥饿模式(那个在 goroutine 等锁过久时把锁直接交给等待者的机制),它只在大并发争用下有意义,和"单例获取"这种无争用场景没关系,别拿错论据。

那个"慢 3 倍"的数字到底从哪来的?一种可能的解释是它测的是另一种场景:单例只初始化一次、之后只接收一次,且 channel 里预先塞好值、不补货。这种"首调"对比里,Once 要跑一次初始化函数、Channel 只做一次接收,两者差距被初始化本身的耗时稀释,于是显得只有几倍(想要确认,可以复跑它那个场景)。但真实生产里单例是"初始化一次、获取成千上万次",决定日常开销的是后面那成千上万次获取,而那一步,Once 是 0.4 纳秒,Channel 是十几到上百纳秒。倍数之所以差一个数量级,是因为比的"哪一步"根本不同。

边界(必须说清):这个结论只限"单例 / 一次性初始化"场景。Channel 在通信场景(goroutine 之间传值、做信号、做扇出扇入)依然是最优解,你不该因为它做单例慢,就觉得"Channel 慢"。慢的是"用 Channel 去干互斥初始化的活",不是 Channel 本身。把这句话刻在脑子里,整篇文章的误读就少一半。

二、计数与读多写少:atomic 不是"高级版 Mutex"

第二个误解更隐蔽,因为它半对,所以最容易被原样记住。

误解:高频计数用 atomic 比用 Mutex 快好几倍,所以能 atomic 就别用 Mutex;读多写少就无脑上 RWMutex

实测:我跑了计数基准(代码见 evidence/code/counter),分两种压力。先是单 goroutine 下各跑 1000 万次自增:

场景 atomic.AddInt64 Mutex 自增 倍数
单 goroutine(无争用) 1.86 ns/op 2.05 ns/op ≈1.1x
14 goroutine 并发争用同一计数器 19.5 ns/op 90.4 ns/op ≈4.6x

看第一行:没有争用时,atomic 只快 10%。那篇"atomic 快 3.7 倍"的文章,测的其实是争用场景,却没告诉你前提。倍数只在多个 goroutine 抢同一个计数器时才出现,这恰恰说明 atomic 的优势来自"无锁",而不是"更高级"。

全篇 benchmark 实测数据总览榜单

为什么一有争用,Mutex 就从"几乎和 atomic 一样"变成"慢 4 倍"?因为 Mutex 在拿不到锁时,不会死占 CPU:短临界区下它会先短暂自旋一小段,真的拿不到才把 goroutine 挂起(park),等锁释放再由调度器唤醒(unpark)。这套"自旋 + 挂起与唤醒"涉及运行时的信号量和调度队列,单次成本比一次原子加高一个量级。atomic 不一样:它就是在缓存行上做一次加一,抢不到就原地自旋重试,完全不碰调度器。所以当多个 goroutine 疯狂抢同一个计数器,Mutex 把大家频繁地挂起又唤醒,开销就上去了。这也解释了为什么 atomic 在争用下反而优势更大,它根本不进调度器。

顺着这个思路,我测了"读多写少"这个经典场景,结论更有意思。教科书都说"读多写少用 RWMutex",我用 b.RunParallel 让 14 个 goroutine 并发只读:

读临界区 Mutex 等价读锁 RWMutex.RLock 结果
纯读(只读一个字段) 70.1 ns/op 110.8 ns/op RWMutex 反而慢 1.6x
读带真实工作量(求和 1KB 切片) 130 ns/op 109.3 ns/op RWMutex 快 1.19x

根因RWMutex 的读锁不是"免费并发"。它要对内部的 readerCount 做一次原子加,在 14 核同时抢这个计数器的缓存行时,引发的缓存一致性流量,比 Mutex 一次性串行加锁还贵。只有当读临界区足够长、Mutex 串行化的代价盖过了缓存抖动,RWMutex 才反超。换句话说,“读多写少用 RWMutex"这句话得加定语:读临界区要有足够的工作量。只读一个 int 还想用 RWMutex 提速,是反效果。

那"读多写少且读极轻量"怎么办?atomic.Value 给出答案。我让它和 RWMutex 并发读对比(代码见 evidence/code/atomicvalue):

并发读方式 ns/op 相对
atomic.Value.Load(无锁) 0.60 1x
RWMutex.RLock + 读 64.2 ≈107x

atomic.Value 的读完全无锁,底层是 unsafe 指针交换,所以是碾压级。代价是它的写入是"整体替换"语义:你必须在写入前自己构造一份新的不可变快照,拷贝成本来自你的构造逻辑,而非 atomic.Value 本身。所以它适合配置、路由表这种"偶尔写、高频读"的对象,我自己的配置中心和特性开关就是这么存的。

落到代码上,最典型的就是 HTTP handler 里的请求计数。一个全局 var reqCount int64,每个请求进来 atomic.AddInt64(&reqCount, 1),这是 textbook 正确写法,无锁、快、安全。如果你改成 mu.Lock(); reqCount++; mu.Unlock(),绝大多数时候它跑得一样好,因为请求是串着来的、争用很低。但流量一高、多个 handler 并发自增,Mutex 版本就会显出那 4 倍差距。结论不是"永远用 atomic”,而是"高频争用的计数用 atomic,低频的随便"。反过来,atomic.Value 也不是万能:既然写入要你先构造新快照,高频写场景它反而吃亏,选型永远看读写比。

atomic 系性能对比:计数 atomic 19.5 vs Mutex 90.4;atomic.Value 读 0.60 vs RWMutex 64.2

对照(教科书 vs 生产)

场景 教科书建议 生产实测结论
高频计数 “用 atomic 更快” 仅争用时成立(4.6x),无争用几乎无差,别为"显得高级"而用
读多写少、读极轻 “上 RWMutex” 反而更慢,先想 atomic.Value
读多写少、读有工作量 “上 RWMutex” 成立,但优势仅约 1.2x,别期待"数倍"

三、WaitGroup 不是"并发等待"的标准答案

第三个场景是日常写得最多的:起一批 goroutine,等它们全跑完。这一节的重点不是推翻 WaitGroup——它千级以内完全够用——而是给你一个批量短任务该有的限流视角:benchmark 揭示的退化,根子不在 WaitGroup 用错了,而在你一瞬间造了多少并发执行单元。

误解sync.WaitGroup 是 Go 并发等待的标配,怎么用都不会错,瓶颈在业务不在它。

WaitGroup 误区澄清:无脑起 goroutine 像撒气球撑爆房间

实测:我让 WaitGroup 分别等 100 / 1000 / 10000 个"空工作" goroutine 回收(代码见 evidence/code/waitgroup),看每整批的开销:

worker 数 ns/op(整批) allocs/op 相对 100
100 21,052 101 1x
1,000 233,654 1,001 ≈11x
10,000 2,680,932 10,001 ≈127x

开销随 worker 数近似线性膨胀,而且每个 worker 都带一次分配(goroutine 栈 + 调度结构)。1000 个 worker 比 100 个慢约 11 倍,那篇"慢 14 倍"的文章方向是对的,只是它没给你测量条件,你既没法复现、也没法判断自己的机器上到底是多少。

再算一笔账:WaitGroup 本身只是个计数器,真正贵的是它等着的那些 goroutine。一个 goroutine 初始栈 2KB 起,一万个就是近 20MB 的栈内存;加上调度器要为每一个做就绪/运行态切换,GC 也要扫描它们的栈。所以"等一万个 goroutine"慢,一半是 WaitGroup 计数器的缓存行争用,另一半是你同一瞬间造了一万个并发执行单元。这也解释了为什么上一节的工程建议是限流:把并发压在几百,既绕开 WaitGroup 的退化区间,也避免调度器和内存分配器被瞬间打爆。

WaitGroup 退化曲线:100→21μs、1000→234μs、10000→2.68ms(对数纵轴)

根因:WaitGroup 的 Add/Done 操作的是同一个全局计数器,大量 goroutine 同时 Done 会在这条计数器的缓存行上剧烈争用。它不是"错了",是在"大批量、短任务"这个组合下性价比急剧下降,你起的每一个 goroutine 本身也有栈分配成本,worker 越多,调度器和内存分配器的压力越大。

那什么时候该换 errgroup?我顺手测了(代码见 evidence/code/errgroup),每批 10 个 goroutine 做空工作:

方式 ns/op allocs/op
WaitGroup 2,473 11
errgroup.Group 2,456 13

两者几乎持平:errgroup 只多了 2 次分配,耗时还在误差内。所以"换 errgroup"不是为了快,是为了它白送的两样东西:context 取消传播(一个 goroutine 出错,其余被取消)和首个错误的聚合。如果你的场景不需要这两样,裸 WaitGroup 完全够;如果需要"出错即停、收集错误",errgroup 的代价可以忽略不计。

所以这一节真正要带走的结论,不是"WaitGroup 不行",而是:当你在等成千上万个短任务,比起纠结 WaitGroup 还是 errgroup,更该想的是"要不要限流"。用一个固定容量的 worker pool(比如 ants 或自己写个带 buffered channel 的信号量),把并发数压在几百以内,既避开了 WaitGroup 的退化区间,又不会让调度器和内存分配器被瞬间打爆——这才是批量短任务该有的解法。

取舍(教科书 vs 生产)

场景 教科书建议 生产实测结论
批量等 N 个任务 “WaitGroup 就行” 千级以内没问题,万级注意线性退化
需要出错取消 / 收集错误 “自己用 channel 实现” 直接用 errgroup,开销可忽略
大量短任务高并发 “无脑起 goroutine” 考虑 worker pool 限流,而非无限起 goroutine

四、sync.Once 懒加载:一行代码,一个永久陷阱

最后一个不是性能,是一个会让你半夜被叫起来的坑。这一节是源码推演,不是 benchmark。

误解sync.Once 是懒加载最省心的一行代码,保证只执行一次,闭眼用。

实测 + 源码推演:看 sync.Once.doSlow 的实现(标准库源码,Go 1.26.4):

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if !o.done.Load() {
        defer o.done.Store(true)   // 注意:defer 在 f 之后、无论 f 是否 panic 都会执行
        f()
    }
}

sync.Once.doSlow 状态机:首次调用→加锁→done?→defer 置 done→f();panic 仍置 done

要害在那行 defer o.done.Store(true),它是 defer 的,不管 f 是正常返回还是 panic,都会执行。于是陷阱来了:

如果初始化函数 f 在第一次调用时 panic,done 依然会被置为 true。之后所有 Once.Do(f) 都直接返回,再也不会执行 f。你的懒加载单例,永久处于"初始化失败但被认为已初始化"的状态。

我刻意说明:这是标准库源码推演,不是性能 benchmark,它衡量的是"行为正确性",不是"快慢"。

根因Once 的"只执行一次"是"最多执行一次尝试",不是"保证成功一次"。生产里常见踩坑是:把可能失败的网络调用、数据库连接塞进 Once,第一次失败被 recover 或 panic,后续调用全部静默跳过,服务起来了但核心依赖是空的,日志里干干净净,故障现场无从查起。

这个 bug 最阴的地方在于它不报错。普通初始化失败你会看到 panic 或 error 满天飞,立刻有人处理;而 Once 陷阱是"第一次挂了,之后全世界都以为初始化好了"。你的 HTTP handler 照常返回 200,只是内部用的那个客户端是 nil,直到某个请求真的去调它,才在完全不相干的地方炸一个 nil pointer dereference。等排查的人顺着堆栈摸到那,早忘了是启动时那次初始化埋的雷。所以我的习惯是:任何进 Oncef,要么保证它不会失败(纯内存计算),要么把"可能失败"的初始化提到启动时显式 if err := init(); err != nil { fatal },别藏在懒加载里。

如果你确实需要"失败可重试"的初始化,要么在 f 内部自己处理错误并决定要不要把 done 回退,要么干脆别用 Once,改成一个带 error 返回、调用方显式处理的初始化函数。一个让 Once 在失败时回退的可重试写法长这样(注意:这种在 Do 内部重置 s.once 的写法,只在 Init 由单一 goroutine——比如进程启动时——调用时才安全;如果运行期会被多个 goroutine 并发调用,对 s.once 字段的并发读写本身就是数据竞争,需要额外加锁,或干脆改用带 error 返回的显式初始化函数):

func (s *Service) Init() error {
    s.once.Do(func() {
        if err := s.realInit(); err != nil {
            // 回退 done,让下次调用能重试
            s.once = sync.Once{}
            s.initErr = err
        }
    })
    return s.initErr
}

避坑对策(教科书 vs 实测)

场景 教科书建议 生产实测结论
懒加载配置 / 单例 “Once 最省心” f 别藏可能失败的 IO;要重试就自己回退 done
初始化可能 panic (几乎没人提) 一旦 panic,Once 永久失效,调用方拿不到任何错误

五、场景化选型清单

把上面四节收成一张表。下次选型,先问自己"我的场景是哪一个",再动手。

你的场景 推荐原语 边界 / 注意
一次性初始化(单例、全局配置) sync.Once 别用 Channel 做单例(慢 40–280 倍);f 别藏可能失败的 IO
高频计数(多 goroutine 抢) atomic.AddInt64 无争用时优势可忽略,别为了"显得高级"而用
读多写少、读极轻量(只读字段) atomic.Value RWMutex 在这种场景反而更慢
读多写少、读有工作量 RWMutex 优势仅约 1.2x,别期待"数倍"
批量等 N 个任务(千级内) sync.WaitGroup 万级 worker 注意线性退化
批量任务 + 出错取消 / 收集错误 errgroup.Group 开销与 WaitGroup 持平,换它是为能力不是为速度
goroutine 间传值 / 信号 / 扇出扇入 Channel 它的主场是通信,不是互斥初始化

这张表不是让你背的,是让你在写代码前停三秒的。我自己的习惯是:每次要起 goroutine 或加锁之前,先问"我在解决哪类并发问题":是隔离一次初始化、还是抢一个计数器、还是传数据、还是等一堆任务。问题类型定下来,原语基本就定了,剩下的只是边界。真正的坑从来不是"用错原语",而是"用了一个听起来高级、其实场景不匹配的原语",还因为没测过,一直以为它很快。

场景→原语 决策矩阵:7 行场景→推荐原语→边界

怎么记?一句话:要初始化,想 Once;要计数,想 atomic;要通信,想 Channel;要等一堆,想 WaitGroup/errgroup;要读多写少,先想 atomic.Value 再想 RWMutex。

没有最快的原语,只有最匹配场景的原语。下次起 goroutine 或加锁之前,先停三秒问自己"我在解决哪类并发问题"——问题类型定了,原语基本就定了,剩下的只是边界。你平时选型靠感觉还是靠测?评论区聊聊你踩过最贵的那个坑。

本文所有 benchmark 代码与原始输出已开源,实测倍数会随机器和 Go 版本漂移,结论比绝对值更值得记住。

结尾金句:没有最快的原语,只有最匹配场景的原语


附录:实验代码和原始数据

本文 6 组 benchmark 实验的代码和原始输出已开源:

GitHub:zhiyulab-evidence/go-sync-benchmark-showdown

  • code/singleton/:E1 Once vs Channel 单例获取开销对比(bench_test.go)
  • code/counter/:E2 atomic.AddInt64 vs Mutex 计数(无争用 / 争用场景)
  • code/waitgroup/:E3 WaitGroup 100/1000/10000 worker 退化曲线
  • code/rwmutex/:E4 RWMutex vs Mutex 并发读(纯读 / 带工作量)
  • code/atomicvalue/:E5 atomic.Value 无锁读 vs RWMutex 读
  • code/errgroup/:E6 errgroup vs WaitGroup 批量编排开销对比
  • output/:各实验原始 go test -bench -benchmem 输出(result.txt)

每个子目录都有 go.mod,直接 go test -bench=. -benchmem 即可复现。二进制产物不入库,跑实验前自己编译。

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →