atomic不是免费午餐,难道就没有免费的好选择吗?

摘要:很多初级甚至中级开发会滥用atomic,因为在他们的世界观里atomic比mutex轻量,性能总是优于锁的。 这话不能算错,但有个很重要的前提,那就是原子操作竞争不激烈的时候。 “竞争激烈”是指什么呢,指的是有很多线程在同一个资源上大量执行
很多初级甚至中级开发会滥用atomic,因为在他们的世界观里atomic比mutex轻量,性能总是优于锁的。 这话不能算错,但有个很重要的前提,那就是原子操作竞争不激烈的时候。 “竞争激烈”是指什么呢,指的是有很多线程在同一个资源上大量执行原子操作的情况。 落在这种情况下原子操作反而会成为性能拖油瓶。我们来看一个经典的原子计数器: func AddAtomic() uint64 { var count atomic.Uint64 var wg sync.WaitGroup for range 10 { wg.Add(1) go func() { for range 100000000 { count.Add(1) } wg.Done() }() } wg.Wait() return count.Load() } 代码模拟了10个线程频繁操作计数器的场景。论并发安全这段代码是既简洁又安全的。很多人可能还会觉得这段代码是很高效的,毕竟用了原子操作嘛。 不过别着急,测试性能之前我们再想想还有没有其他做法。考虑到这是一个单向递增的计数器,我们只需要保证每次的加操作最终都能完成,并且因为加法的交换律和结合律,这些操作的相对顺序也可以打乱。换句话说,我们可以不关心counter的中间状态,每个线程自己聚合所有的加操作,最后再一次性加给counter。代码就会变成下面这样: func AddAtomicLocal() uint64 { var count atomic.Uint64 var wg sync.WaitGroup for range 10 { wg.Add(1) go func() { cc := uint64(0) for range 100000000 { cc++ } count.Add(cc) wg.Done() }() } wg.Wait() return count.Load() } 现在每个线程维护自己的计数器,在运行结束统一操作counter。熟悉Java的读者应该能看出来这是LongAdder,唯一的区别是我们没用threadlocal。 两种方法累加的次数都是一样的,而且大部分人都认为原子操作很轻量,那么它们的性能理论上应该不会差太多,方法1稍微慢一点。我们写点性能测试看下: func BenchmarkAtomic(b *testing.B) { for b.Loop() { result := AddAtomic() if result != 1000000000 { b.Fatal("error") } } } func BenchmarkAtomicLocal(b *testing.B) { for b.Loop() { result := AddAtomicLocal() if result != 1000000000 { b.Fatal("error") } } } 下面是测试结果,分别用go1.24.5在Intel和Apple的机器上进行测试: 结果出人意料,在两款不同的芯片上,方案1都慢了接近300倍。看似“轻量”的原子操作竟然是性能杀手。 我们可以找个Linux系统用perf分析一下原因。在Linux上两者直接也有百倍的差距。 上面一个样本是方案1的,下面的则是方案2的: 我没有监听全部的性能事件,那样一来会让程序变得很慢,二来对我们重要的只有其中的几个事件,太多的信息会成为杂音。 我们先来看branches和branch-misses,前者是程序运行中一个执行多少分支判断,后者是cpu预执行分支失败的次数,简单地说misses越少程序性能越高。所以两个方案在分支总数差不多的情况下方案2比1的预测失败数量低20倍,所以方案2在这一点上胜出。 接着就是缓存命中率了,这一点无需过多解释,命中率越高性能越好。方案二同样比一高了10%。 然而这两点只能解释一个数量级的差异,但我们现在的差距是300倍。 仔细观察缓存读取次数,我们会惊奇地发现方案1的读取次数是10,029,023,298,100亿次。我们的测试程序也正好运行了累加器函数10次,也是100亿次操作。这是方案2的整整18000倍。 为什么会有这个结果呢?这就是原子操作的缺点之一了:x86和arm上的原子操作都是针对某块内存上的数据进行操作。这意味着不管是原子读还是原子写,都要直接操作内存。现代cpu不会自己直接接触内存,都需要数据先进入cpu的高速缓存才能进行操作。这就是为什么方案1会有如此之高的缓存读取次数。
阅读全文