私たちが開発するシステムに同時バグがあるのはなぜですか?また、同時バグの根本的な原因は何ですか?

ジランは人里離れた谷で生まれ、誰もいないので香りがよくありません。

序文

私たちが開発するシステムに同時バグがあるのはなぜですか?また、同時バグの根本的な原因は何ですか?

この質問をする前に、並行性に関する精通したシードの見方について話しましょう。並行性は、実際にはなじみのある、なじみのないトピックです。慣れているのは、Javaが自然にマルチスレッドをサポートし、マルチスレッドが並行性の土であるためです(シングルスレッドには並行性の問題はありません。JavaScriptはシングルスレッドです。JavaScriptを作成する私の反対側に座っているフロントエンドプログラマーはそうではありません。並行するバグ作業が時間の経過とともに発生するため、バックエンドの友人は不満を持っているかもしれませんが、それは事実です)。

したがって、基本を学んだとき、マルチスレッドのバプテスマを経験し、マルチスレッドでロック解除されたコレクションを操作するときにデータがどれほど予測できないかを知っていました。たとえば、Javaで一般的に使用されるスレッドセーフではないコレクションクラスを操作しています。HashMap,ArrayListマルチスレッドでそうput(),add()することは不正確なデータ結果をもたらすでしょう。

そして、純粋にシミュレートされたマルチスレッドの例の研究から切り離されると、会社で開発されたシステムがオンラインでリリースされます。私たちが直面しているのは、すべての実際のユーザーが私たちのシステムに到着し、すべてのユーザーがコンピューター上にいるということです。その中のスレッドです。この時点で、システムの各機能がマルチスレッド環境でスレッドセーフであるかどうかを検討する必要があります。たとえば、私たちが開発したシステムでは、eコマースシナリオでの買い物の注文などのバグが同時に発生するシナリオや、旅行シナリオでの電車や高速鉄道のチケット取得などの同時実行性の高いシナリオが頻繁に発生します。並行性の問題を防ぐことができない場合、多数のユーザーが同時に購入しようとすると、在庫が売られ過ぎになります。つまり、並行性にバグが発生します。しかし、システムで同時バグを引き起こすのは結果にすぎません。調査したいのは、これらの同時バグの原因です。

(しかし、これらの同時バグは本当に私たちのソフトウェアプログラマーのポットですか?実際、必ずしもそうではありませんが、次のアジャイルシードは本当の「ポット」を見つけるためにあなたを連れて行きます)

したがって、これらのシナリオでは、マルチスレッドの不安定性について全員が同じコンセンサスを持っています。つまり、ロックで十分です。common:などの単一システムにローカルロックを追加し、:Synchronized,Lockなどのマイクロサービス、クラスター、および分散システムに分散ロックを追加しますRedis、ZooKeeperしかし、これは私を不慣れにするものではありません。本当に不慣れなのは、なぜロックが必要なのかということです。ロックは問題を解決するための手段にすぎず、問題の原因は何ですか。

マルチスレッドでこれらの問題が発生するのはなぜですか?また、開発するシステムに同時にバグがあるのはなぜですか?

ロックはただの答えです、なぜ問題の根本をロックしているのですか?

以前のアジャイルシードの記事を読んだことがある人は、答えが何であるかを尋ねるのではなく、答えの背後にあるものに明らかに興味があることを知っていますか?

では、並行性とは何であり、並列性とは何ですか?

始める前に、並行性の基本的な概念を理解する必要があります。並行性に加えて、並列性があり、それと簡単に混同される可能性があるためです。文字通りおよび概念的に区別できないことに加えて、並行性も並行して発生しているように見えます。

最初に、並行性と並列性の概念について説明しましょう。並行性とは、実際には、同じ時点で実行するために複数のスレッドを切り替えることです並列処理では、複数のスレッドを同時に実行できます。

(開発の観点から、並行性と並列性が視覚化されます。システムを開発する場合、各開発システムの責任はスレッドのようであり、エンジニアはプロセッサーのようなものです。並行性は完全なドライ(フルスタック)エンジニアのようなものです。完了を担当します。フロントエンドとバックエンドの両方を作成し、サーバーの構築やフロントエンドとバックエンドの切り替えなどの運用と保守の作業も担当します。責任。並行して、複数のエンジニアが一緒に完了に責任を負い、フロントエンドエンジニア、バックエンドエンジニア、データベースエンジニア、運用および保守エンジニア、および他のエンジニアが同時に独立して作業することができます)

以下に示すように:image.png

ルート1:コンテキストスイッチングのアトミック性

まず、並行バグの根本原因、シングルコアCPUで始まるコンテキスト切り替えによって引き起こされる原子性の問題、およびオペレーティングシステムとプログラムがマルチスレッドの並行タスクを実装できることを見てみましょう。(つまり、ブラウザでナゲットに記事を書くと同時に、音楽を開いて蒸し暑いDISCOを聴くことができます)2つのタスクが同時に実行されているように見えますが、実際にはコンピュータの下部では、CPUが各スレッドに実行時間を割り当てます。つまり、タイムスライス(タイムスライス)であるため、スレッドは実行時にCPUによって割り当てられたタイムスライスに従ってタスクを実行します。

意味着时间片决定了一个线程能占用CPU运行的时长,所以当线程获得的CPU时间片用完,或者被迫暂停运行,就轮到另一个被选中的线程来占用CPU。而一个线程脱离处理器使用权暂停运行就是 “切出”;线程被选中占用处理器开始运行时就是 “切入”。而切出切入的过程中还需要保存和恢复来回切出切入的信息,这个保存和恢复的信息就是上下文

而所谓的上下文就得用到存储指令的寄存器和程序计数器。CPU寄存器负责存储已经和正在或要执行的任务,程序计数器负责存储CPU正在执行的指令位置以及即将执行的下一条指令的位置。

如果你再跳出细节来看,这整个过程其实就是上下文切换

关于程序计数器,在我的上一章中有畅谈过的JVM内存结构就有所涉及,而不管是寄存器,还是程序计数器,都相当于一个缓冲,其道理都是一样,一通百通的,感兴趣的朋友可以再回顾。

《我从冯·诺依曼计算机体系,追溯到了JVM,一切原来如此!》

所以从宏观层面,多线程看似同时运行,但在计算机微观层面,单CPU下多线程实则是在CPU时间片的上下文切换。而回到开头所提到的问题,而并发看似跟并发同时进行一样?,也只不过是计算机CPU与内存那纳秒级别的进行线程切换的速度超过了我们肉眼所能感知到的速度而已。

多线程的生命周期

当我们知道了并发和上下文切换,那么再细想,多线程生命周期中的线程上下文切换不也是并发的产物吗?

image.png

如上图所示,Java线程的生命周期包括了新建、就绪、运行、阻塞、死亡这五种状态。

当线程从运行状态到阻塞状态时就是线程的暂停。而线程从阻塞状态到就绪状态时就是线程的唤醒。线程从就绪状态到运行状态的过程或者从运行状态转为阻塞状态,再到就绪状态;然后拿到到CPU执行权限其实就是一个上下文切换过程

可以看到,线程的状态之间的上下文切换实际就是由两个主要的线程生命周期状态所引发而被选中后执行的。

暖男一颗剽悍的种子给你圈出来了,可以看到就绪状态运行状态是获取CPU执行权限和释放CPU执行权限的主要状态。

b90a4b2e2ebb45d6efd6afcfbb28fae.png

那么线程运行的状态转化又是怎么触发的呢?

有很多我们熟悉的关键字和方法,如:synchronized、lock、sleep()、yield()、wait()、join()、park()引发自发的上下文切换。

所以自发上下文切换就是通过程序自发的触发切换,而非自发上下文切换,可以是我们上面所说,当一个线程时间片用完被迫切换或者受系统执行优先权影响被迫切换,以及JVM垃圾回收也会触发被迫切换。

从上面所知,上下文切换带来的不是原子性的并发Bug,那么我们可以用亿小段代码来看验证并发时因线程上下文切换而导致原子性并发Bug。如下代码所示,一个线程执行addCount()方法后会循环1000次的count++操作(也就是从0加1到1000),而我们在calculation()中创建两个线程来执行,按照预期会打印2000的结果,但实际会如何请往下看。

public class AtomicTest {

    private static Integer count = 0;

    private static void addCount() {

        for (int i = 0; i < 1000; i++) {

            count++;
        }
    }

    public static Integer calculation() throws InterruptedException {

        Thread thread_1 = new Thread(()->{
            addCount();
        });

        Thread thread_2 = new Thread(()->{
            addCount();
        });

        thread_1.start();
        thread_2.start();

        thread_1.join();
        thread_2.join();

        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(calculation());
    }
复制代码

我们开启了两个线程,并且每个线程累加循环1000次,也就是正确的结果应该是2000。但实际上运行结果,不是正确结果2000,而是会在2000内的随机数。

AtomicTest类代码运行结果如下:

image.png

但是这样结束了吗?

当然没有,你还得知道这亿段代码真正导致Bug地方是哪里!

我们来看代码中的结果是通过count变量进行累加,所以关键的并发Bug想必也是出在这里。

count++;
复制代码

count++看似朴实无害,但逃不过机智的一颗剽悍的种子,通过javap -c反编译可以窥探到Java虽然通过一条语句就可以来完成的事情,但作为高级的编程语言,在编译器编译成字节码的背后实则是多条指令在CPU中完成的,从而多线程同时操作count++时因线程的上下文切换,导致在线程中这些指令都不是整体执行的。

//变量加载到操作数栈
iconst_0 

//从操作数栈存储到局部变量表
istore_1 

//将局部变量表中的变量加一
iinc 1 by 1 
复制代码

所以多线程操作一个不可分割的整体时,不被线程上下文切换而导致执行过程中断的特性就是原子性。(也就是我们常说的原子性就是所有操作要么不间断地全部被执行,要么一个都不执行)

根源二:CPU缓存的可见性问题

我们知道在计算机中,内存的速度要远比磁盘的速度快得多,而CPU的速度又远超内存的速度;那么如果说内存是兰博基尼,CPU就是超音速飞机(CPU与内存的速度是不在同一个速度单位的)。

但追求速度是计算机世界中的信条。所以为了内存也能搭上CPU的速度,CPU增加了缓存,来均衡与内存的速度差异;让内存也感受了一了把超音速飞机的推背感。

914ff28df879dc046b30c2b92bfa792.png

但遗憾的是在计算机中,解决一个问题的同时,就会出现另外的问题。那就是CPU一旦加入缓存之后,CPU缓存与内存就存在数据一致问题。(使用过Redis或者其他中间件来做缓存,想必都会遇到数据一致性问题)

在单个CPU时期,CPU缓存与内存的数据一致性是容易解决的。因为所有的线程都只能操作同一个CPU缓存;所以当一个线程对缓存进行了写操作,那么对于另一个线程一定是可见的。例如下图所示,线程A线程B操作同一个CPU缓存时,线程A更新了共享变量X的值后,线程B去访问共享变量X值的数据就是线程A所写的值(也就是说访问到的一定会是最新的值)。所以当一个线程对共享变量的更新时,另一个线程能够立刻访问到,就是可见性。

image.png

但到了多核CPU时,每个线程不再是在单一个CPU上操作,而是在多个CPU上操作着不同都的缓存;此时的CPU缓存与内存的数据一致性就变得复杂得多。例如下图所示,当线程A操作的是CPU_A的缓存,而线程B访问的是CPU_B的缓存,那么线程A共享变量X的操作对于线程B是不可见的。如下图所示,一图胜千言。

image.png

我们再次回顾上面原子性中展示过的AtomicTest类的代码,其实除了并发时的原子性Bug,还存在可见性Bug。

再看如下代码所示,当多个线程同一时间开始执行,在多CPU下线程读取到count = 值可能来自不同CPU中的缓存,而同样count++也可能是在不同CPU缓存中+1。所以有可能当同时写入内存后,count正确的值应该是2的,然而发现还是1。所以代码中的两个线程都基于各自的CPU缓存中count值进行计算,导致了count值没法正确累加到2000;而这也正是CPU缓存所导致的可见性Bug。

(代码中的原子性Bug在上述中有详解,这里不再赘述,而是要关注代码中还存在的可见性Bug)

public class AtomicTest {

    private static Integer count = 0;

    private static void addCount() {

        for (int i = 0; i < 1000; i++) {

            count++;
        }
    }

    public static Integer calculation() throws InterruptedException {

        Thread thread_1 = new Thread(()->{
            addCount();
        });

        Thread thread_2 = new Thread(()->{
            addCount();
        });

        thread_1.start();
        thread_2.start();

        thread_1.join();
        thread_2.join();

        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println(calculation());
    }
复制代码

打开任务管理器可以看到CPU中其实已经有着L1、L2、L3好几级别的缓存。 image.png

看到这你会发现这哪里是后端的锅,并发Bug根源的原子性问题和可见性问题很明显是硬件工程师的“锅”。

image.png

根源三:指令优化的有序性问题

我们知道程序的运行是按我们所编写的代码逻辑来实现的,而每一个功能的逻辑则是按代码的先后顺序执行的。但编译器并不会按部就班执行我们的代码,而是想方设法的让不同指令操作重叠,从而能并行处理,提高指令的执行速度。例如:

原来的执行顺序:

//第一处
int x = 1;
int y = 2;

//第二处
x++;
y++;

//第三处
int count = y + x;
复制代码

可能重排后代码:

//第一处
int y = 2;
y++;

//第二处
int x = 1;
x++;

//第三处
int count = y + x;
复制代码

上面的示例中,虽然编译器调整了我们编写的代码顺序,但不会造成影响。但是Bug总会出现在不经意中。

懒汉式的双重校验锁问题

有序性的最经典的案例是在单例模式中懒汉式实现的双重校验锁(DCL)来解决线程不安全问题。代码如下所示:

public class LazySingleton {
   private static LazySingleton instance;
   private static LazySingleton getInstance(){
        if (instance == null) {
            synchronized(LazySingleton.class) {
              if (instance == null){
                  instance = new LazySingleton();
              }
            }
        }
        return instance;
    }
}
复制代码

上面代码看似并发下无隙可乘,但实际JVM的编译的指令优化下就会出现对象不能完成初始化。暖男一颗剽悍的种子带你一起来找茬;在代码中我们知道最为关键的是对象的实例化。(单例模式其作用就是保证一个类仅有一个实例)

instance = new LazySingleton();
复制代码

一颗剽悍的种子为了让你能更好的Get到,再通过javap -c LazySingleton反编译来观察上面这行代码。

//new 创建 LazySingleton 对象实例,分配内存
new #3 <demo/LazySingleton>

//复制栈顶地址,并压入栈顶
dup

//调用构造器方法,初始化 LazySingleton 对象
invokespecial #4 <demo/LazySingleton.<init> : ()V>

//存入局部方法变量表
aload_0
复制代码

那么上面这行代码在原来的执行顺序是这样的:

  1. 对象分配内存空间。
  2. 在内存空间中初始化LazySingletonDemo对象。
  3. 对象将内存地址赋值给instance变量。

指令重排后:

  1. 对象分配内存空间。
  2. 对象将内存地址赋值给instance变量。
  3. 在内存空间中初始化LazySingletonDemo对象。

也就说指令2和指令3的两个阶段,对Java编译器来说,所执行顺序是未定义的。那么当一个线程new LazySingletonDemo()时,编译器仅为该对象分配内存空间。而另一个线程调用getInstance()方法,由于instance != null,所以直接返回instance对象,但instance对象还没有被有效赋值,所以无法正常获取到单例对象。

所以有序性,就是编译器为了提高执行性能对指令进行优化,从而打破了我们代码的执行顺序,导致了并发时的有序性Bug。

总结

我们从追问为什么我们开发的系统会有并发Bug,从而知道了并发Bug背后其实是上下文带来的原子性问题CPU缓存带来的可见性问题,以及指令优化带来的有序问题。而并发Bug解决的手段是加锁,但不管是加的是什么样的锁;加锁要解决核心都是围绕并发Bug的根源。

所以理解了并发Bug根源到底是什么,更多是让你在使用锁时能意识到为什么要锁;就像开头所说从单体系统的使用Synchronized,Lock等锁,到分布式系统Redis、ZooKeeper的锁一直在变,可问题本质并没有变,我想这应该才是我们应该多花时间和精力的地方。

好了,这一个多月就到这了。

我是一颗剽悍的种子,怕什么真理无穷,进一寸,有进一寸的欢喜。感谢各位朋友的:关注点赞收藏评论 ,我们下回见!

创作不易,勿白嫖。

一颗剽悍的种子 | 文 【原创】

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

おすすめ

転載: juejin.im/post/7118919540959870989