写点什么

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

  • 2021 年 11 月 18 日
  • 本文字数:3161 字

    阅读完需:约 10 分钟

​​摘要:原子性是指一个或者多个操作在 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."<init>":()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 发生了线程切换,则可能会导致原子性问题,这也是导致并发编程频繁出问题的根源之一。我们只有充分理解并掌握线程的原子性以及引起原子性问题的根源,并在日常工作中时刻注意编写的并发程序是否存在原子性问题,才能更好的编写出并发程序。

总结


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



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

发布于: 2 小时前阅读数: 6
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

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