会话桶科技

8 个 goroutine 各写各的计数器,没有锁,比单线程慢 13 倍

八个 goroutine,每个只写自己那一格,互不重叠,没有锁,没有 channel,-race 跑出来干干净净。它比一个 goroutine 从头干到尾慢十三倍


先把结论摆在最前面,省得你往下翻:

总累加次数 8000 万

单线程,1 个变量        166ms
8 个 goroutine 分片    2.264s     ← 慢 13.7 倍

八个 goroutine,每个只写自己那一格,互不重叠,没有锁,没有 channel,-race 跑出来干干净净。它比一个 goroutine 从头干到尾慢十三倍

这段代码我贴在下面,你先看一眼,看看能不能挑出毛病。


一、那段“正确的优化”

事情的起点是一个再普通不过的需求:网关要统计请求数。

最早的写法就是一个全局计数器:

var reqCount int64

func handle(w http.ResponseWriter, r *http.Request) {
    atomic.AddInt64(&reqCount, 1)
    // ...
}

压测压到高并发的时候,这个计数器成了热点。原因很好理解:所有核都在抢同一个 int64,抢的核越多,越慢。

这里得先澄清一个很容易混的词,不然后面全串味:

atomic 是无锁(lock-free)的。 它没有互斥量,不会让 goroutine 挂起,不进调度器。这一点没有争议。

但无锁不等于无争用。 要原子地改一个变量,执行的那个核必须先独占这个变量所在的缓存行——独占了才敢改,不然两个核同时改就乱了。多个核同时想独占同一行,硬件就得排队:amd64 上 atomic.AddInt64 编译出来是 LOCK XADDQ,arm64 上是 LDADDAL(或老架构的 LDXR/STXR 循环重试)。LOCK 这个前缀锁的不是操作系统的锁,锁的是缓存行。

所以下文所有的“争用”,指的都是这个硬件层的、抢缓存行独占权的争用,跟 sync.Mutex 一点关系没有。这句话是后面整篇的伏笔。

解法是教科书级的,任何一本讲高并发的书都会写:分片。既然大家抢同一个变量会打架,那就一人发一个,各写各的,读的时候求和。

const shards = 8

type Metrics struct {
    reqCount [shards]int64
}

func (m *Metrics) Inc(shard int) {
    atomic.AddInt64(&m.reqCount[shard], 1)
}

func (m *Metrics) Total() int64 {
    var sum int64
    for i := range m.reqCount {
        sum += atomic.LoadInt64(&m.reqCount[i])
    }
    return sum
}

这段代码放进 code review,不会有任何人拦。它不是“能用”,它是对的——分片消除热点争用是标准手法,Go 运行时自己到处在用这个思路(sync.Pool 给每个 P 一份本地缓存,就是这个套路)。

然后压测结果出来了:QPS 不但没涨,还掉了一大截。


二、我当时排查的顺序(这段全是弯路)

第一反应:分片数选得不对。

8 个分片,机器 14 核,是不是分片太少了还在抢?调到 16、32、64。没用,一点没变。往下调到 4、2,也没用。

第二反应:goroutine 调度问题。

是不是 goroutine 全被排到了同一个 P 上,压根没并行?打印 GOMAXPROCS,14,正常。加了埋点确认八个 goroutine 确实在同时跑。不是调度。

第三反应:atomic 本身慢,换个写法。

atomic.AddInt64 换成 sync.Mutex 保护的普通加法。更慢了(这个倒是符合预期)。换成 sync/atomicInt64 类型,一样慢。

第四反应:是不是有 race 我没看见?

go run -race干净。 一条警告都没有。

这一步之后我开始觉得不对劲了。因为按我原本的猜测——总得有什么东西在竞争吧——那 race detector 至少该有点反应。它没有,因为确实没有数据竞争:每个 goroutine 只碰自己那个下标,而且用的是原子操作。从 Go 的内存模型看,这段代码完美无瑕。

第五反应:上 pprof。

CPU profile 出来,时间几乎全堆在 atomic.AddInt64 那一行。

这个结果毫无信息量。我知道时间花在那儿,问题是那一行代码没有任何毛病。profile 能告诉你“哪里慢”,但当“哪里”是一条本身完全正确的指令时,它就到头了。

到这一步,所有软件层面的怀疑对象都排查完了:不是逻辑错、不是锁、不是调度、不是 race、不是 GC。代码是对的,profile 指着一行对的代码说它慢。


三、剥到最小复现

我把业务全剥掉,只留骨架:N 个 goroutine,每个对一个 int64 做原子累加,跑一千万次。

然后我做了一件事——不改代码,只改这几个 int64 在内存里隔多远

用一块手动对齐到 4096 边界的裸内存,按指定间距摆 8 个 int64,八个 goroutine 各认一个。除了间距,其他一模一样:

func (a *arena) slot(i int, stride uintptr) *int64 {
    return (*int64)(unsafe.Pointer(a.base + uintptr(i)*stride))
}

结果是这样的:

【8 个 goroutine 各写各的分片,唯一变量=分片间距】

分片间距      耗时       相对最快    结果校验
8 字节      2.407s    122.46x    ✓
16 字节     852ms      43.36x    ✓
32 字节     265ms      13.47x    ✓
64 字节     36ms        1.81x    ✓
128 字节    20ms        1.00x    ✓
256 字节    20ms        1.00x    ✓

间距-耗时柱状图,8字节2407ms/16字节852ms/32字节265ms/64字节36ms/128字节20ms/256字节20ms,拐点落在32到64之间,越过缓存行大小后惩罚立刻消失,不是渐变是断崖

同样的代码,同样的指令条数,同样的 goroutine 数,只因为八个变量在内存里挨得近,慢了 122 倍。

(最后那列“结果校验”是我特意加的:每轮结束都验算总和等于 8000 万,确认循环没被编译器优化掉、也没丢数据。全部 ✓。这条后面还要用。)

这时候方向就清楚了:问题不在我写的任何一行代码里。它在这些数据躺在哪儿。


四、根因:CPU 眼里没有“变量”,只有“行”

要讲清楚这件事,得下到缓存那一层。

第一步:CPU 不按字节读内存,按“行”批发

CPU 和内存之间隔着 L1/L2/L3 缓存。缓存跟内存交易的最小单位叫缓存行(cache line)——x86 上通常 64 字节,ARM64 上 128 字节。

你读一个 8 字节的 int64,硬件实际搬进缓存的是包含它的整整一行

这个设计没什么好指责的,跟虚拟内存按 4KB 的“页”来管是同一个思路:管理粒度太细,管理本身的开销就会超过收益。内存体系的每一层都在批发,没有一层做零售。

第二步:多核各有各的 L1,一个核改了,别人手上的副本就得作废

核心 1 和核心 2 可以各自缓存同一块内存的副本。核 1 一写,核 2 手上那份立刻成了过期数据,必须作废、重新去下一级取回来。这套机制叫缓存一致性协议(MESI 及其变种)。

到这里为止,一切都是必要且正确的。

第三步:问题在这里——作废的粒度是“整行”,不是“变量”

硬件不知道你的变量边界在哪。它不认识 reqCount[0]reqCount[1],它只认行号。

[8]int64 一共 64 字节,八个分片全部落在同一个缓存行里。于是:

核1 改 reqCount[0]
  → 核2 缓存里那一整行作废(连它压根不碰的 [1] 一起)
核2 要改 reqCount[1]
  → 缓存未命中,重新取回整行
  → 核1 那一行同时作废
核1 又要改 reqCount[0]
  → 缓存未命中,重新取……

缓存行ping-pong示意,核心1的L1和核心2的L1各缓存同一行的副本,核1只关心下标0核2只关心下标1,但作废粒度是整行,两个核互相作废让这一行来回弹

这一行缓存就在八个核之间来回弹,学名叫 cache line ping-pong。八个核谁也别想把它安安稳稳留在自己的 L1 里。

这就是“伪共享”(false sharing)名字的由来:在你的代码里,这八个变量毫无关系,八个 goroutine 零交集,共享根本不存在;但在硬件那一层,它们是同一个东西。

共享是假的,代价是真的。


五、防杠环节:怎么证明这真的是伪共享

写到这儿我知道会有人说:你这不就是 atomic 争用吗,跟缓存行有什么关系。

所以我补了一组四档对照,两个执行流做同样多的累加(各一千万次,总量固定):

单线程(1 个 goroutine 跑完全部)    35ms   ✓
真共享(2 个 goroutine 抢同一变量)   74ms   ✓
伪共享(相邻 2 变量,隔 8 字节)      70ms   ✓
无共享(2 变量隔 128 字节)          18ms   ✓

伪共享 / 无共享 = 3.87x
伪共享 / 真共享 = 0.94x
伪共享 / 单线程 = 2.00x

四档对照柱状图,单线程35ms/真共享74ms/伪共享70ms/无共享18ms,真共享与伪共享比值0.94几乎完全一样,底部列出三条被堵死的反驳:无共享组也是atomic/也是2个goroutine/每组都验算了总和

请重点看中间两行:

伪共享 70ms,真共享 74ms。比值 0.94——两者几乎完全一样。

“真共享”是两个 goroutine 死抢同一个变量,那是货真价实的争用。“伪共享”是两个互不相干的变量,只是恰好挨着。

硬件把它们当成了同一件事。

回到开头那个伏笔。争用从来就发生在硬件层,抢的从来就是缓存行的独占权——atomic 无锁,但要改一个变量,得先独占它所在的那一行。

所以分片到底干了什么?它把“八个核抢同一个变量”换成了“八个核抢同一个缓存行”。

而硬件根本不区分这两件事。 你以为把争用消除了,其实只是换了个抢法,一点没少。这也正是上面那两个数字为什么会几乎相等。

再看第一行和第三行:伪共享 70ms,单线程 35ms——多用一个核,慢了整整一倍。 这就是标题里那个 13.7 倍的小号版本。

而最后一行给出了对照的另一端:同样是两个 goroutine、同样是 atomic,只要把变量隔开 128 字节,18ms,相对单线程接近两倍的正常并行加速。

三条路堵死了:

  • “是 atomic 慢” —— 无共享那组用的也是 atomic,18ms。
  • “是并发开销” —— 无共享那组也是两个 goroutine,18ms。
  • “是编译器优化掉了快的那组” —— 每组都验算了最终总和,全部等于预期值。真跑了那么多次。

唯一的变量只剩一个:这两个 int64 在内存里隔多远。


六、改法,以及它的代价

改法本身很蠢:把每个分片撑大到独占一个缓存行。

const shards = 8

type Metrics struct {
    reqCount [shards]struct {
        v    int64
        _pad [120]byte   // 补到 128 字节
    }
}

func (m *Metrics) Inc(shard int) {
    atomic.AddInt64(&m.reqCount[shard].v, 1)
}

那 120 字节什么也不干,就是占地方,把下一个分片挤到下一个缓存行去。

两种内存布局对照,紧凑版8个int64共64字节全部落在同一个128字节缓存行的左半边,八个核抢同一行实测2.264秒;padding版每片补到128字节独占一行,共1024字节,实测46毫秒快50.95倍,代价是内存涨16倍

紧凑 [8]int64(64 字节)        2.327s   ✓
padding 到 128(1024 字节)      46ms    ✓

加了 960 字节空白,快了 50.95 倍

代价是内存:结构体从 64 字节变成 1024 字节,16 倍。

这是个非常直白的空间换时间。八个计数器多花不到 1KB,无所谓;但如果你要做的是每个连接一个分片、十万个连接,那就是 100MB 换 6.4MB,得算账了。

为什么是 128 不是 64

回头看间距扫描那张表,有个细节我一开始忽略了:64 字节那档还剩 1.81x 的惩罚,128 字节才彻底干净。

但在两个 goroutine 的对照实验里,隔开 64 字节就已经满血了。

这两个结果不矛盾,它指向的是这台机器的缓存层次。我查了系统报的值:

$ sysctl hw.cachelinesize
hw.cachelinesize: 128

而 Go 运行时内部(internal/cpu/)为每个架构维护着一张缓存行大小表:

cpu_x86.go       CacheLinePadSize = 64
cpu_arm64.go     CacheLinePadSize = 128
cpu_ppc64x.go    CacheLinePadSize = 128
cpu_s390x.go     CacheLinePadSize = 256
cpu_riscv64.go   CacheLinePadSize = 64
cpu_mips.go      CacheLinePadSize = 32

最合理的解释是:这台机器 L1 的行是 64 字节(所以两个核、隔 64 就够),而更外层缓存和一致性的粒度是 128(所以八个核同时上,隔 64 仍有残留争用,隔 128 才干净)。保守取大的那个值,是对的。

顺带说一句:一个语言的运行时专门为每种 CPU 架构记录这个常量——这本身就说明伪共享不是什么边角料奇技淫巧,它真实到必须写进语言的地基里。

标准库自己就在干这件事

如果你觉得手写 padding 太土,可以去看 $GOROOT/src/sync/pool.go 第 73 行:

type poolLocal struct {
	poolLocalInternal

	// Prevents false sharing on widespread platforms with
	// 128 mod (cache line size) = 0 .
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

注释直接写着 Prevents false sharing,padding 到 128 字节。

sync.Pool 是 Go 里被用得最狠的对象池之一,它给每个 P 一份本地缓存——这正是“分片”思路。而标准库的作者非常清楚,分片如果不 padding,等于白分。

你手写的那 120 字节空白,跟标准库是同一个动作。

还有别的路吗

  • 减少写的频率:goroutine 内部先在局部变量里累加,退出前或者每隔一段时间才写回共享分片一次。写得少了,ping-pong 自然就少。这往往比 padding 更划算,因为不额外吃内存。
  • 换数据结构:真的需要高频精确计数,考虑每个 goroutine 完全独立的局部计数 + 定期汇总,而不是共享一个数组。
  • 别过早分片:如果计数器本来就不是热点,一个 atomic.AddInt64 挺好。这次事故的起点,就是一个不必要的优化。

七、可迁移的那一句

这次踩坑之后,我记住的是这句:

有一整类性能问题,原因不在你写了什么,而在你的数据躺在哪儿。

再收紧一点,是这个:

并行的前提是“真的不共享”,而“不共享”的判定标准不在你的代码里,它在硬件的批发单位上。

你在代码里画的那条边界——这是 [0],那是 [1],两个 goroutine 各管各的——在编译器眼里成立,在 Go 内存模型眼里成立,在 race detector 眼里也成立。但它在缓存那一层不成立,因为那一层根本不认识“变量”这个概念。

这也是为什么这个坑能干干净净地通过 code review:review 检查的是逻辑,而这个 bug 不在逻辑里。

最后留一个可以立刻自查的:如果你的代码里有下面这类东西,都值得量一量——

  • 按 goroutine / 按 worker / 按分片切开的计数器、统计量数组
  • 一个结构体里,多个字段被不同 goroutine 高频写入
  • []struct{...} 数组里每个元素被一个独立 goroutine 持有并频繁更新

它们有个共同长相:逻辑上完全独立,物理上紧挨着。


附:实验环境与复现

文中所有数字都是实跑出来的,不是抄来的经验值。环境:

Go 1.26.5   darwin/arm64
Apple M3 Max,14 核(GOMAXPROCS=14)
sysctl hw.cachelinesize = 128

三点说明,免得数字被误读:

  1. 绝对数值跟机器强相关。 换一台 x86、换核数、换 Go 版本,倍数都会变。但“挨着就慢、隔开就快、拐点落在缓存行大小上”这个结构是稳定的——想验证的话,跑一遍上面的间距扫描,看看你机器上的拐点在哪。
  2. 每一组都做了结果校验,最终总和必须等于预期累加次数,确保没有被编译器优化掉、没有丢更新。
  3. 间距扫描用的是手动对齐到 4096 边界的裸内存,用 stride 控制间距,保证“间距”是唯一变量,排除内存分配器的对齐差异干扰。