写点什么

为什么 i++ 不是原子操作?一个让无数并发程序崩溃的“常识”

作者:milanyangbo
  • 2025-07-29
    广东
  • 本文字数:1987 字

    阅读完需:约 7 分钟

原子性:不可分割的操作


private int count = 0;    public void test() {    List<Thread> ts = new ArrayList<>();    for (int i = 0; i < 100; i++) {        Thread t = new Thread(() -> {            for (int j = 0; j < 10000; j++) {                count += 1;            }        });        ts.add(t);    }            // ...start启动线程,join等待线程    assert count == 100 * 10000;}
复制代码


对于 Java 这样的高级语言,一条语句最终会被转换成多条处理器指令完成,例如上面代码中的 count += 1,至少需要三条处理器指令。1)指令 1:把变量 count 从内存加载到处理器的寄存器;2)指令 2:在寄存器中执行+1 操作;3)指令 3:将结果写入内存(缓存机制导致可能写入的是处理器缓存而不是内存)。那么假设有两个线程 A 和 B,同时执行 count+=1,可能存在如下情况:1)线程 A 从内存加载 count 并执行 count+=1,同时线程 B 从内存加载 count 并执行 count+=1,并同时写回内存。那么这时结果是:count = 1。2)线程 A 从内存加载 count 并执行 count+=1,并将 count 结果写回内存。线程 B 再从内存加载 count 并执行 count+=1。那么这时结果是:count = 2。可以看到如果要 count 结果正确,要保证 count 读取、操作、写入三个过程不被中断。这个过程,可以称之为原子操作。原子 (atomic)本意是“不能被进一步分割的最小粒子”,而原子操作 (atomic operation) 意为“不可被中断的一个或一系列操作”。处理器通过总线锁定、缓存锁定和原子指令等方式实现原子操作。1)总线锁定(Bus Locking):通过 LOCK 指令锁住总线 BUS,使当前处理器独享内存空间。但是此时其他处理器都不能访问内存其他地址,效率低。



2)缓存锁定(Cache Locking):现代处理器主要依赖缓存一致性协议(如 MESI)实现原子操作。当处理器核心执行 LOCK 指令时,会先尝试锁定目标内存地址所在的缓存行。通过 MESI 协议,处理器核心将缓存行置为独占状态(Exclusive/Modified),阻止其他处理器核心修改。操作完成后,缓存行状态更新并释放锁,其他核心可重新获取该行。若内存操作跨越两个缓存行(如未对齐的 8 字节写入),或目标地址未被缓存时,需直接锁总线。



3)原子指令(Atomic Instruction):处理器通常提供一些特殊的指令来实现原子操作,例如,x86 架构的 CMPXCHG(比较并交换)指令,ARM 架构的 LDREX 和 STREX(加载和存储独占)指令。在实际的并发编程中,缓存一致性协议和原子操作通常需要一起使用。例如,CMPXCHG 只在单核处理器下有效,多核处理器依然要加上 LOCK 前缀(LOCK CMPXCHG)。当处理器执行 CMPXCHG 指令时,它会先将需要操作的内存内容加载到缓存中,然后锁定这部分缓存,执行比较和交换操作,最后将结果写回内存。在这个过程中,其他的处理器不能访问被锁定的缓存,从而保证了操作的原子性。


compxchg [ax] (隐式参数,EAX累加器), [bx] (源操作数地址), [cx] (目标操作数地址)
复制代码


利用 CMPXCHG 指令可以通过循环 CAS 方式来实现原子操作。


// 判断当前机器是否是多核处理器int mp = os::is MP();_asm {    mov edx, dest    mov ecx, exchange value    mov eax, compare_value    // 这里需要先进行判断是否为多核处理器    LOCK IF MP(mp)    // 如果是多核处理器就会在这行指令前加Lock标记    cmpxchg dword ptr [edx],ecx}
复制代码


CAS(Compare and Swap)是一种常用的原子操作。CAS 操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。Java 语言提供了大量的原子操作类,来实现对应的 CAS 操作。比如 AtomicBoolean,AtomicInteger,AtomicLong 等。


private AtomicInteger count = new AtomicInteger(0);    public void test() {    List<Thread> ts = new ArrayList<>();    for (int i = 0; i < 100; i++) {        Thread t = new Thread(() -> {            for (int j = 0; j < 10000; j++) {               count.incrementAndGet();            }        });        ts.add(t);    }            // ...start启动线程,join等待线程    assert count.get() == 100 * 10000;}
复制代码


尽管 CAS 操作是原子的,但它也存在一些问题,主要包括以下几个方面。1)ABA 问题:在 CAS 操作中,如果一个值在操作开始时是 A,然后被改为 B,最后又被改回 A,那么 CAS 操作会误认为没有发生变化。为了解决 ABA 问题,可以使用版本号或标记来跟踪变化。2)自旋开销:CAS 操作是通过自旋来实现的,即不断尝试进行 CAS 操作直到成功或达到一定的尝试次数。如果 CAS 操作失败,线程会一直自旋等待,这会消耗处理器资源,会影响系统的性能。3)只能保证一个变量的原子性:CAS 操作只能保证一个变量的原子性,如果需要保证多个变量的一致性,需要使用其他的同步机制。


未完待续


很高兴与你相遇!如果你喜欢本文内容,记得关注哦!!!

发布于: 刚刚阅读数: 3
用户头像

milanyangbo

关注

让世界知道我的存在 2018-03-05 加入

技术/人文, 互联网

评论

发布
暂无评论
为什么i++不是原子操作?一个让无数并发程序崩溃的“常识”_并发编程_milanyangbo_InfoQ写作社区