资讯专栏INFORMATION COLUMN

解密并发幕后黑手:线程切换引发的原子性问题

SunZhaopeng / 2080人阅读

摘要:线程在执行某项操作时,此时如果发生了线程切换,转而去执行其他的任务,中断了当前线程执行的操作,这就会造成原子性问题。所以,如果在中存在正在执行的线程,恰好此时发生了线程切换,则可能会导致原子性问题,这也是导致并发编程频繁出问题的根源之一。

摘要:原子性是指一个或者多个操作在CPU中执行的过程不被中断的特性。原子性操作一旦开始运行,就会一直到运行结束为止,中间不会有中断的情况发生。

本文分享自华为云社区《【高并发】解密导致并发问题的第二个幕后黑手——原子性问题》,作者: 冰 河。

原子性

原子性是指一个或者多个操作在CPU中执行的过程不被中断的特性。原子性操作一旦开始运行,就会一直到运行结束为止,中间不会有中断的情况发生。

我们也可以这样理解原子性,就是线程在执行一系列操作时,这些操作会被当做一个不可拆分的整体执行,这些操作要么全部执行,要么全部不执行,不会存在只执行一部分的情况,这就是原子性操作。

关于原子性操作一个典型的场景就是转账,例如,小明和小刚的账户余额都是200元,此时小明给小刚转账100元,如果转账成功,则小明的账户余额为100元,小刚的账户余额为300元;如果转账失败,则小明和小刚的账户余额仍然为200元。不会存在小明账户为100元,小刚账户为200元,或者小明账户为200元,小刚账户为300元的情况。

这里,小明给小刚转账100元的操作,就是一个原子性操作,它涉及小明账户余额减少100元,小刚账户余额增加100元的操作,这两个操作是一个不可分割的整体,要么全部执行,要么全部不执行。

小明给小刚转账成功,则如下所示。

小明给小刚转账失败,则如下所示。

不会出现小明账户为100元,小刚账户为200元的情况。

也不会出现小明账户为200元,小刚账户为300元的情况。

线程切换

在并发编程中,往往设置的线程数目会大于CPU数目,而每个CPU在同一时刻只能被一个线程使用。而CPU资源的分配采用了时间片轮转策略,也就是给每个线程分配一个时间片,线程在这个时间片内占用CPU的资源来执行任务。当占用CPU资源的线程执行完任务后,会让出CPU的资源供其他线程运行,这就是任务切换,也叫做线程切换或者线程的上下文切换。

如果大家还是不太理解的话,我们可以用下面的图来模拟线程在CPU中的切换过程。

在图中存在线程A和线程B两个线程,其中线程A和线程B中的每个小方块代表此时线程占有CPU资源并执行任务,这个小方块占有的时间,被称为时间片,在这个时间片中,占有CPU资源的线程会在CPU上执行,未占有CPU资源的线程则不会在CPU上执行。而每个虚线部分就代表了此时的线程不占用CPU资源。CPU会在线程A和线程B之间频繁切换。

原子性问题

理解了什么是原子性,再看什么是原子性问题就比较简单了。

原子性问题是指一个或者多个操作在CPU中执行的过程中出现了被中断的情况。

线程在执行某项操作时,此时如果CPU发生了线程切换,CPU转而去执行其他的任务,中断了当前线程执行的操作,这就会造成原子性问题。

如果你还不能理解的话,我们来举一个例子:假设你在银行排队办理业务,小明在你前面,柜台的业务员为小明办理完业务,正好排到你时,此时银行下班了,柜台的业务员微笑着告诉你:实在不好意思,先生(女士),我们下班了,您明天再来吧!此时的你就好比是正好占有了CPU资源的线程,而柜台的业务员就是那颗发生了线程切换的CPU,她将线程切换到了下班这个线程,执行下班的操作去了。

Java中的原子性问题

在Java中,并发程序是基于多线程技术来编写的,这也会涉及到CPU的对于线程的切换问题,正是CPU中对任务的切换机制,导致了并发编程会出现原子性的诡异问题,而原子性问题,也成为了导致并发问题的第二个“幕后黑手”。

在并发编程中,往往Java语言中一条简单的语句,会对应着CPU中的多条指令,假设我们编写的ThreadTest类的代码如下所示。

package io.mykit.concurrent.lab01;/** * @author binghe * @version 1.0.0 * @description 测试原子性 */public class ThreadTest {    private Long count;    public Long getCount(){        return count;    }    public void incrementCount(){        count++;    }}

接下来,我们打开ThreadTest类的class文件所在的目录,在cmd命令行输入如下命令。

javap -c ThreadTest

得出如下的结果信息,如下所示。

d:>javap -c ThreadTestCompiled from "ThreadTest.java"public class io.mykit.concurrent.lab01.ThreadTest {  public io.mykit.concurrent.lab01.ThreadTest();    Code:       0: aload_0       1: invokespecial #1                  // Method java/lang/Object."":()V       4: return  public java.lang.Long getCount();    Code:       0: aload_0       1: getfield      #2                  // Field count:Ljava/lang/Long;       4: areturn  public void incrementCount();    Code:       0: aload_0       1: getfield      #2                  // Field count:Ljava/lang/Long;       4: astore_1       5: aload_0       6: aload_0       7: getfield      #2                  // Field count:Ljava/lang/Long;      10: invokevirtual #3                  // Method java/lang/Long.longValue:()J      13: lconst_1      14: ladd      15: invokestatic  #4                  // Method java/lang/Long.valueOf:(J)Ljava/lang/Long;      18: dup_x1      19: putfield      #2                  // Field count:Ljava/lang/Long;      22: astore_2      23: aload_1      24: pop      25: return}

这里,我们主要关注下incrementCount()方法对应的CPU指令,如下所示。

public void incrementCount();    Code:       0: aload_0       1: getfield      #2                  // Field count:Ljava/lang/Long;       4: astore_1       5: aload_0       6: aload_0       7: getfield      #2                  // Field count:Ljava/lang/Long;      10: invokevirtual #3                  // Method java/lang/Long.longValue:()J      13: lconst_1      14: ladd      15: invokestatic  #4                  // Method java/lang/Long.valueOf:(J)Ljava/lang/Long;      18: dup_x1      19: putfield      #2                  // Field count:Ljava/lang/Long;      22: astore_2      23: aload_1      24: pop      25: return

可以看到,Java语言中短短的几行incrementCount()方法竟然对应着那么多的CPU指令。这些CPU指令我们大致可以分成三步。

  • 指令1:把变量count从内存加载的CPU寄存器。
  • 指令2:在寄存器中执行count++操作。
  • 指令3:将结果写入缓存(可能是CPU缓存,也可能是内存)。

在操作系统执行线程切换时,可能发生在任何一条CPU指令完成后,而不是程序中的某条语句完成后。如果线程A执行完指令1后,操作系统发生了线程切换,当两个线程都执行count++操作后,得到的结果是1而不是2。这里,我们可以使用下图来表示这个过程。

由上图,我们可以看出:线程A将count=0加载到CPU的寄存器后,发生了线程切换。此时内存中的count值仍然为0,线程B将count=0加载到寄存器,执行count++操作,并将count=1写到内存。此时,CPU切换到线程A,执行线程A中的count++操作后,线程A中的count值为1,线程A将count=1写入内存,此时内存中的count值最终为1。

所以,如果在CPU中存在正在执行的线程,恰好此时CPU发生了线程切换,则可能会导致原子性问题,这也是导致并发编程频繁出问题的根源之一。我们只有充分理解并掌握线程的原子性以及引起原子性问题的根源,并在日常工作中时刻注意编写的并发程序是否存在原子性问题,才能更好的编写出并发程序。

总结

缓存带来的可见性问题、线程切换带来的原子性问题和编译优化带来的有序性问题,是导致并发编程频繁出现诡异问题的三个源头,我们已经介绍了缓存带来的可见性问题和线程切换带来的原子性问题。下一篇中,我们继续深耕高并发中的有序性问题。

点击关注,第一时间了解华为云新鲜技术~

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/123874.html

相关文章

  • 并发高?可能是编译优化引发有序问题

    摘要:并发高可能是编译优化引发有序性问题有序性例如下面的三行代码。并发高可能是编译优化引发有序性问题内存空间总结导致并发编程产生各种诡异问题的根源有三个缓存导致的可见性问题线程切换导致的原子性问题和编译优化带来的有序性问题。 ​​摘要:CPU为了对程序进行优化,会对程序的指令进行重排序,此时程序的执行顺序和代码的编写顺序不一定一...

    番茄西红柿 评论0 收藏2637
  • [Java并发-1]入门:并发编程Bug源头

    摘要:所以这情况下,当线程操作变量的时候,变量并不对线程可见。总结,缓存引发的可见性问题,切换线程带来的原子性问题,编译带来的有序性问题深刻理解这些前因后果,可以诊断大部分并发的问题 背景介绍 如何解决并发问题,首先要理解并发问题的实际源头怎么发生的。 现代计算机的不同硬件的运行速度是差异很大的,这个大家应该都是知道的。 计算机数据传输运行速度上的快慢比较: CPU > 缓存 > I/O ...

    xiguadada 评论0 收藏0
  • Java 并发编程(学习)

    摘要:并发编程的挑战并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度的并发执行。的实现原理与应用在多线程并发编程中一直是元老级角色,很多人都会称呼它为重量级锁。 并发编程的挑战 并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度的并发执行。如果希望通过多线程执行任务让程序运行的更快,会面临非常多的挑战:(1)上下文切换(2)死...

    NervosNetwork 评论0 收藏0
  • Java并发

    摘要:对象改变条件对象当前线程要等待线程终止之后才能从返回。如果线程在上的操作中被中断,通道会被关闭,线程的中断状态会被设置,并得到一个。清除线程的中断状态。非公平性锁虽然可能造成饥饿,但极少的线程切换,保证其更大的吞吐量。 声明:Java并发的内容是自己阅读《Java并发编程实战》和《Java并发编程的艺术》整理来的。 showImg(https://segmentfault.com/im...

    SKYZACK 评论0 收藏0
  • AtomicInteger原理

    摘要:提供这些原子类的目的就是为了解决基本类型操作的非原子性导致在多线程并发情况下引发的问题。测试代码引发的线程问题最终的值为如果是原子操作,那么结果应该就是,反复运行几次发现结果大部分情况下都不是,这也证明了的非原子性在多线程下产生的问题。 AtomicInteger的原理 java的并发原子包里面提供了很多可以进行原子操作的类,比如: AtomicInteger AtomicBoole...

    tuantuan 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<