原子操作:CPU的终极武器,你用对了吗?

那个线上故障让我差点被祭天——就因为一个简单的计数器,在高并发下居然算不准了。加锁?太重。volatile?不保证原子性。最后靠CAS救了场。可话说回来,原子操作这玩意儿,看起来就一行代码,里面弯弯绕绕多着呢。用不好,性能打骨折;用错了,数据乱七八糟。今天这篇文章,不扯概念,直接扒皮见骨。

从CPU指令集看本质:CAS到底是什么魔法?

先问个问题:你觉得 i++ 是一步还是三步?实际上,从CPU视角看,它是三步:读、改、写。多线程环境下,如果线程A读后还没来得及写,线程B也读了,那最终值就少了1。这就是典型的竞态条件

解决思路很简单:把这三步捏成一步,要么全做,要么全不做。这就是原子性的精髓。

现代CPU都是靠Compare-and-Swap (CAS) 指令来做到这一点的。你用Java的AtomicInteger,底层JIT编译完就是一条lock cmpxchg指令(x86平台上)。这个指令干的事——我拿个生活场景比划一下:

你和你室友同时想给房东发消息说涨房租太狠了。室友说:“咱在群里接龙,谁先发出去算谁的。”你打开微信,看到最新的消息是“加300块”,然后你迅速补一句“不行,最多加200”。点发送前,微信会检查:最新那条消息还是不是“加300块”?如果是,就把你的消息发出去,同时把最新消息变成“不行,最多加200”;如果中间谁插了一嘴,那你的发送就失败,你得刷新一下,看到新消息是“已阅”,然后重新组织语言,说“哦,那当我没说”。这个过程一直循环,直到你成功发出去。

看到了吗?CAS的核心就是“比较并交换”。它需要三个操作数:内存位置V、旧的预期值A、新值B。仅当V的值等于A时,才将V更新为B,否则不更新。无论成功与否,都会返回V处的旧值。整个过程由硬件保证原子性。

另外,你肯定听说过ABA问题。就像你出门倒垃圾,回来发现老婆把桌上的苹果换成了橘子——你看一眼就走了,以为苹果还在。CAS只看值,不看过程。如果某个线程把A改成B又改回A,另一个线程的CAS会悄悄成功,却不知道中间发生过变化。这在实际中可能带来灾难。解决方案?加个版本号,像AtomicStampedReference那样。

CPU缓存一致性协议MESI状态转换图
CPU缓存一致性协议MESI状态转换图

还有,上面说的lock前缀不只是锁了总线——那太慢。其实它是锁了缓存行,通过缓存一致性协议(MESI)来做的。一个核心的缓存行被锁时,其他核心就不能碰这块数据,直到操作完成。这个过程涉及大量硬件协商,不是免费的午餐。

性能到底几何?跟锁正面刚一下

都说原子操作比锁快,真的吗?我搭了个实验——32核机器,100个线程疯狂对一个计数器加1亿次。分别用synchronized、ReentrantLock、AtomicLong来测。结果不出意外,但有个点让我很意外。

吞吐量数据(单位:ops/ms)

无竞争下:synchronized(偏向锁)≈ 45000, ReentrantLock ≈ 44000, AtomicLong ≈ 190000。原子操作吊打锁,因为它没了上下文切换、线程挂起恢复的开销。

可一旦竞争激烈起来——比如100个线程几乎同时争抢——情况变了:synchronized(轻量级锁啥的)掉到 8000 左右,ReentrantLock也差不多,而AtomicLong居然也掉到 21000 左右。虽然还是最好,但比起无竞争时下降近10倍!为啥?因为CAS失败时会有自旋重试。重试就意味着CPU空转,白白燃烧指令周期。这要是自旋得太狠,吞吐量不降才怪。

多线程高并发下原子操作与锁性能对比柱状图
多线程高并发下原子操作与锁性能对比柱状图

对了,你还可能见过LongAdder。在我这场景下,它跑到 98000 ops/ms,简直逆天。它的诀窍是把一个热点拆成多个Cell,先分散累加,最后汇总,空间换时间,大大减少了CAS碰撞。所以,不是所有时候都该抱着AtomicLong不放。

实践中的三个血泪坑,扒出来给你看

实践中的三个血泪坑,扒出来给你看
实践中的三个血泪坑,扒出来给你看

说几个我实际栽过的跟头。别笑,你大概率也会遇上。

坑1:假共享(False Sharing)。我曾经把一堆AtomicLong放到一个数组里,每个线程只修改自己的那个。理论上线程间互不干扰,速度应该很快。结果性能奇差。用perf一看,缓存未命中率贼高。原因是CPU缓存是按缓存行加载的(通常64字节)。相邻的AtomicLong对象可能落在同一个缓存行里,一个核改了,会导致其他核的整个缓存行失效——哪怕它们改的并不是同一个变量。这就是伪共享。解决办法:填充。Java里用@Contended注解,或者手动在变量前后加一堆long占位符,把它撑满一个缓存行,比如Disruptor里就有大量这种做法。

坑2:ABA问题具体化。不是所有ABA都会出问题,但出了就很要命。我们有个无锁栈,用CAS操作头节点。线程A准备把头节点从X换成X.next,线程B呢,中途把X删了,又压入新节点,刚好新节点的地址跟之前的X一样(内存复用)。A的CAS成功,但X.next实际上已经是个无效节点了,栈就崩了。除了版本号,还可以用风险指针(Hazard Pointer)引用计数来避免内存过早释放。C++有shared_ptr配合原子操作,Java则麻烦些,可能需要自己控制对象生命周期。

坑3:过度原子化。有些人觉得原子操作快,就把所有共享变量换成Atomic*,甚至在一个方法里对好几个Atomic*依次操作。但是,原子操作只能保证单个操作的原子性,组合起来就不是原子的了!比如if(a.get() > 0 && b.get() < 100)这个判断,在你判断和实际使用之间,值可能已经变了。要么接受这种弱一致性,要么就得用锁来保护整个代码块。没有银弹。

还有,注意volatile和原子操作的关系。Atomic*自带volatile的可见性,但不代表你随便混用就能万事大吉。比如你用一个volatile的int做条件判断,再用Atomic*做计数,它们之间没有happens-before关系,可能读到陈旧值。谨慎设计内存语义。

写完这些,忽然想起一个前辈的话:“并发编程的难点不在于用这些工具,而在于知道何时该放下它们。”原子操作用的爽,可别上瘾。简单业务一把锁锁到底,可能比你绞尽脑汁无锁化快得多,还不出错。对吧?

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:原子操作:CPU的终极武器,你用对了吗?
文章链接:https://m.lfdjt.com/info_23_7883.html