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

简介: 简介:本文详解“原子性”概念及实现原理,通过Java代码示例说明多线程环境下count++操作为何非原子,并探讨处理器如何通过总线锁定、缓存锁定及原子指令(如CAS)保障操作不可中断。同时分析CAS存在的ABA问题、自旋开销及局限性,为并发编程打下理论基础。

原子性:不可分割的操作

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,使当前处理器独享内存空间。但是此时其他处理器都不能访问内存其他地址,效率低。
image.png

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

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操作只能保证一个变量的原子性,如果需要保证多个变量的一致性,需要使用其他的同步机制。

未完待续

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

目录
相关文章
|
存储 人工智能 算法
聚类的k值确定之轮廓系数
聚类的k值确定之轮廓系数
4497 0
|
NoSQL 数据可视化 Linux
Windows版Redis3.0和5.0安装教程(2024)
Windows版Redis3.0和5.0安装教程
2047 0
|
8月前
|
新能源
大盘择时:慎用固定均线!12年回测A股数据揭示择时策略的3大适应性缺陷
动量策略加入50日与200日均线择时后收益下降,主要因均线滞后、逻辑冲突及市场变化。解决方案包括动态调整择时参数、结合多指标验证、优化动量执行细节,提升策略适应性与收益表现。
|
10月前
|
JSON API 数据格式
实时外汇行情接口接入教程
本教程将指导您如何通过简单的几步接入实时外汇行情接口,获取您所需的外汇数据。
|
7月前
|
应用服务中间件 Linux nginx
在虚拟机Docker环境下部署Nginx的步骤。
以上就是在Docker环境下部署Nginx的步骤。需要注意,Docker和Nginix都有很多高级用法和细节需要掌握,以上只是一个基础入门级别的教程。如果你想要更深入地学习和使用它们,请参考官方文档或者其他专业书籍。
311 14
|
9月前
|
机器学习/深度学习 传感器 人工智能
什么叫通用人工智能?7大维度看清海内外AGI发展趋势
AGI探索之路充满矛盾与挑战。一边是AI在算法设计和数学难题上超越人类,另一边却在复杂推理中“放弃思考”。从技术突破到伦理治理,从算力竞赛到认知革命,AGI正重塑智能本质,或终将开创一种全新的理解世界的方式。
615 0
|
9月前
|
人工智能 IDE 前端开发
云开发CloudBase 实现五子棋在线小游戏
本文介绍了使用腾讯云CloudBase和CodeBuddy IDE开发在线对战五子棋小游戏的过程。作者通过本地工具配置CloudBase AI ToolKit,尝试创建云函数和使用云数据库存储游戏数据,但在云函数部分遇到困难。随后改用CodeBuddy IDE进行开发,利用其AI全栈能力实现从需求规划、代码开发到部署的全流程。开发过程中遇到云函数异常、前端交互问题等,通过AI对话式调试(截图、日志分析)高效修复,最终实现支持实时对战、房间管理、胜负判定等功能的双端适配五子棋游戏,并成功部署上线。
387 0
|
人工智能 搜索推荐 前端开发
OpenDeepSearch:搜索引擎革命!这个开源深度搜索工具让AI代理直接读懂网页,复杂问题一键拆解
OpenDeepSearch是基于开源推理模型的深度搜索工具,通过语义重排和多源整合优化检索效果,支持与AI代理无缝集成,提供快速和专业两种搜索模式。
866 10
OpenDeepSearch:搜索引擎革命!这个开源深度搜索工具让AI代理直接读懂网页,复杂问题一键拆解
|
缓存 Dubbo Java
理解的Java中SPI机制
本文深入解析了JDK提供的Java SPI(Service Provider Interface)机制,这是一种基于接口编程、策略模式与配置文件组合实现的动态加载机制,核心在于解耦。文章通过具体示例介绍了SPI的使用方法,包括定义接口、创建配置文件及加载实现类的过程,并分析了其原理与优缺点。SPI适用于框架扩展或替换场景,如JDBC驱动加载、SLF4J日志实现等,但存在加载效率低和线程安全问题。
687 7
理解的Java中SPI机制