很多初级甚至中级开发会滥用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的:

我没有监听全部的性能事件,那样一来会让程序变得很慢,二来对我们重要的只有其中的几个事件,太多的信息会成为杂音。

我们先来看branchesbranch-misses,前者是程序运行中一个执行多少分支判断,后者是cpu预执行分支失败的次数,简单地说misses越少程序性能越高。所以两个方案在分支总数差不多的情况下方案2比1的预测失败数量低20倍,所以方案2在这一点上胜出。

接着就是缓存命中率了,这一点无需过多解释,命中率越高性能越好。方案二同样比一高了10%。

然而这两点只能解释一个数量级的差异,但我们现在的差距是300倍。

仔细观察缓存读取次数,我们会惊奇地发现方案1的读取次数是10,029,023,298,100亿次。我们的测试程序也正好运行了累加器函数10次,也是100亿次操作。这是方案2的整整18000倍。

为什么会有这个结果呢?这就是原子操作的缺点之一了:x86和arm上的原子操作都是针对某块内存上的数据进行操作。这意味着不管是原子读还是原子写,都要直接操作内存。现代cpu不会自己直接接触内存,都需要数据先进入cpu的高速缓存才能进行操作。这就是为什么方案1会有如此之高的缓存读取次数。原子操作需要这样的代价,因为共享的资源随时会被修改,因此只能每次从内存中存取最新的数据。

而方案2的累加操作是在线程独立的本地局部变量上进行的,这些操作没必要走内存,可以直接在寄存器上完成。

寄存器和高速缓存的速度差异不同体系结构和厂家的产品大相径庭,但寄存器的速度一定大于等于高速缓存。因此更依赖寄存器的方案2自然会比缓存命中率更低且需要大量操作缓存的方案1快上两个数量级。

除此之外原子操作还带来了另外的副作用,这在perf的报告中没有显现——多个线程频繁修改同一个资源,会带来大量的更新cpu缓存的核间通信以及线程为了原子性可能会出现很多同步操作。核间通信本身有延迟,缓存状态更新后cpu遇到下次的原子读取/写入就得先更新缓存才能执行操作,一来一去慢的可不是一星半点。线程之间的同步则来自于原子操作带来的内存屏障,cpu为了保证能让屏障正常生效,需要让一些cpu核心上的指令等待另一些核心上的指令执行完成,不同的cpu实现这点的方法不同,但也会带来可观的延迟。

所以综合上面三个原因,看似轻量级的原子操作在“竞争激烈”的场景下出现了严重的性能问题。

不过尽管方案2很快,但它也有缺点,那就是counter不会及时更新。在这里我们可以忍受这一点,但也有的场景是无法接受这种延迟的。

总结

atomic不是免费午餐,是要支付使用代价的。除了潜在的性能问题,还会有难以察觉的并发问题。

所以为了追求性能,应该停止滥用atomic。可以适当地像方案2或者Java的LongAdder那样选择一些per-cpu的算法或者数据结构。

atomic不是免费午餐的更多相关文章

  1. 多线程爬坑之路-学习多线程需要来了解哪些东西?(concurrent并发包的数据结构和线程池,Locks锁,Atomic原子类)

    前言:刚学习了一段机器学习,最近需要重构一个java项目,又赶过来看java.大多是线程代码,没办法,那时候总觉得多线程是个很难的部分很少用到,所以一直没下决定去啃,那些年留下的坑,总是得自己跳进去填 ...

  2. JUC学习笔记--Atomic原子类

    J.U.C 框架学习顺序 http://blog.csdn.net/chen7253886/article/details/52769111 Atomic 原子操作类包 Atomic包 主要是在多线程 ...

  3. 原子类java.util.concurrent.atomic.*原理分析

    原子类java.util.concurrent.atomic.*原理分析 在并发编程下,原子操作类的应用可以说是无处不在的.为解决线程安全的读写提供了很大的便利. 原子类保证原子的两个关键的点就是:可 ...

  4. 【iOS atomic、nonatomic、assign、copy、retain、weak、strong】的定义和区别详解

    一.atomic与nonatomic 1.相同点 都是为对象添加get和set方法 2.不同点 atomic为get方法加了一把安全锁(及原子锁),使得方法get线程安全,执行效率慢 nonatomi ...

  5. OS中atomic的实现解析

    OS中atomic的实现解析 转自:http://my.oschina.net/majiage/blog/267409    摘要 atomic属性线程安全,会增加一定开销,但有些时候必须自定义ato ...

  6. Objective-c的@property(atomic,nonatomic,readonly,readwrite,assign,retain,copy,getter,setter) 属性特性

    assign:指定setter方法用简单的赋值,这是默认操作.你可以对标量类型(如int)使用这个属性.你可以想象一个float,它不是一个对象,所以它不能retain.copy. retain:指定 ...

  7. 为什么volatile不能保证原子性而Atomic可以?

    在上篇<非阻塞同步算法与CAS(Compare and Swap)无锁算法>中讲到在Java中long赋值不是原子操作,因为先写32位,再写后32位,分两步操作,而AtomicLong赋值 ...

  8. Objective-C 关键字:retain, assgin, copy, readonly,atomic,nonatomic

    声明式属性的使用:声明式属性叫编译期语法 @property(retain,nonatomic)Some *s; @property(参数一,参数二)Some *s; 参数1:retain:修饰引用( ...

  9. Java中的Atomic包

    Atomic包的作用 方便程序员在多线程环境下,无锁的进行原子操作 Atomic包核心 Atomic包里的类基本都是使用Unsafe实现的包装类,核心操作是CAS原子操作: 关于CAS compare ...

  10. 各种同步方法性能比较(synchronized,ReentrantLock,Atomic)

    synchronized: 在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized是很合适的.原因在于,编译程序通常会尽可能的进行优化synchronize,另外可读性非常好,不 ...

随机推荐

  1. 一种基于偏移流和纯字符串流来存储和读取字符串列表的方法【C#】

    字符串的存储长度是可变的,在C#中,BinaryWriter和BinaryReader在Write,ReadStirng的时候,都在单个流中字符串的二进制数组前面加了一个二进制数组的长度信息,方便读取 ...

  2. codeup之C语言10.10

    Description 给定字符串定义char *a = "I love China!",读入整数n,输出在进行了a = a + n这个赋值操作以后字符指针a对应的字符串. Inp ...

  3. Qt项目转Visual Studio项目

    有时觉得在Qt中调试代码不够方便,无法调试更深层次的参数,故需要将Qt项目转为Visual Studio项目. 方法:qmake -tp vc Hello.pro 以上命令会将名为Hello的Qt工程 ...

  4. CSharp中的文件操作

    在C#中,可以使用System.IO命名空间中的类来进行Windows文件操作.这些类提供了丰富的方法来处理文件和目录,包括创建.复制.删除.移动文件和目录,以及读取和写入文件等功能. 常用文件操作方 ...

  5. elasticsearch RestHighLevelClient 关于document的常用操作 ---------- 删除篇

    es删除操作大致分为:基于id的单条删除.基于id的批量删除.自定义条件的删除 基于id的单条删除:DeleteRequest 基于id的批量删除:BulkRequest中通过多个DeleteRequ ...

  6. 一种通用的arduino通信例程

    本教程将给出一种通用的,适用于arduino(uno和mega等)单片机的一个通信的教程,通过对例程的修改,可以做成任意的符合需求的代码,下面请看. // 函数定义 String detectStri ...

  7. JVM 类加载过程与字节码执行深度解析

    在 Java 高级程序员面试中,类加载机制与字节码执行原理是 JVM 模块的核心考察点.本文从类加载生命周期.类加载器协作机制.字节码执行引擎及面试高频问题四个维度,结合 JVM 规范与 HotSpo ...

  8. 告别脆弱的 Playwright 测试:为什么基于 YAML 的测试是未来趋势

    专为 Claude Code 和 Playwright MCP 打造的 YAML 配置如何改变了我们的测试工作流程,让自动化测试变得人人可用 如果你曾经维护过大型 Playwright 测试套件,你一 ...

  9. SQL Server 链接服务器"XXXXXXX"的 OLE DB 访问接口 "SQLNCLI11" 返回了消息 "没有活动事务。"。

    一.确保互联双方服务器MS DTC服务已启动并正确配置 打开"服务"管理控制台(services.msc) 找到"Distributed Transaction Coor ...

  10. Python全栈应用开发利器Dash 3.x新版本介绍(1)

    更多Dash应用开发干货知识.案例,欢迎关注"玩转Dash"微信公众号 大家好我是费老师,Dash作为Python生态中强大且灵活的全栈应用开发框架,早在几个月前就发布了其3.0新 ...