Warum haben die von uns entwickelten Systeme gleichzeitig auftretende Fehler und was sind die Hauptursachen für gleichzeitig auftretende Fehler?

Zhilan wurde in einem abgelegenen Tal geboren und duftet wegen niemandem.

Vorwort

Warum haben die von uns entwickelten Systeme gleichzeitig auftretende Fehler und was sind die Hauptursachen für gleichzeitig auftretende Fehler?

Bevor wir diese Frage stellen, lassen Sie uns über die Sicht eines versierten Saatguts auf Nebenläufigkeit sprechen. Vertrautheit liegt daran, dass Java von Natur aus Multi-Threading unterstützt und Multi-Threading der Nährboden für Nebenläufigkeit ist (Single-Thread wird keine Parallelitätsprobleme haben, JavaScript ist Single-Threaded, sehen Sie, der Front-End-Programmierer, der mir gegenüber sitzt und JavaScript schreibt, wird es nicht tun Wegen der gleichzeitigen Bugarbeit werden Überstunden gemacht, auch wenn die Backend-Freunde unzufrieden sein mögen, das stimmt).

Als ich also die Grundlagen erlernte, ging ich durch die Multithreading-Taufe und wusste, wie unvorhersehbar die Daten sind, wenn eine nicht gesperrte Sammlung unter Multithreading betrieben wird.Zum Beispiel betreiben wir eine nicht-threadsichere Sammlungsklasse, die häufig in Java verwendet wird: HashMap,ArrayListim Multithreading Dies put(),add()führt zu falschen Datenergebnissen.

Und losgelöst vom Studium rein simulierter Multithreading-Beispiele wird das von uns im Unternehmen entwickelte System online freigegeben, womit wir konfrontiert sind, dass jeder reale Benutzer in unserem System ankommt, und jeder Benutzer auf dem Rechner sitzt ist ein Faden darin. An dieser Stelle müssen wir überlegen, ob jede Funktion des Systems in einer Multithread-Umgebung Thread-sicher ist. Beispielsweise trifft das von uns entwickelte System häufig auf Szenarien mit gleichzeitigen Fehlern, wie z. B. das Aufgeben einer Einkaufsbestellung in einem E-Commerce-Szenario, und Szenarien mit hoher Parallelität, wie das Greifen von Fahrkarten für Züge und Hochgeschwindigkeitszüge in einem Reiseszenario. Wenn das Parallelitätsproblem nicht verhindert werden kann, wenn eine große Anzahl von Benutzern gleichzeitig einkaufen geht, wird das Inventar überverkauft, das heißt, es treten Fehler bei der Parallelität auf. Aber gleichzeitige Fehler im System zu verursachen, ist nur das Ergebnis.Was wir untersuchen wollen, ist die Ursache dieser gleichzeitigen Fehler.

(Aber sind diese gleichzeitig auftretenden Fehler wirklich der Topf unserer Softwareprogrammierer? In der Tat, nicht unbedingt, der folgende agile Seed wird Sie dazu bringen, den wahren „Topf“ zu finden.)

Daher haben wir alle den gleichen Konsens über die Multi-Threading-Unsicherheit in diesen Szenarien, das heißt, das Sperren ist ausreichend. Fügen Sie lokale Sperren in einem einzelnen System hinzu, z. B. common: Synchronized,Locketc., und fügen Sie verteilte Sperren in Microservices, Clustern und verteilten Systemen hinzu, z. B.: Redis、ZooKeeper. Aber das ist nichts, was mich fremd macht. Was mich wirklich fremd macht, ist, warum Sperren benötigt wird. Sperren ist nur ein Mittel, um das Problem zu lösen, und was ist die Wurzel des Problems?

Warum treten diese Probleme unter Multi-Threading auf und warum haben die von uns entwickelten Systeme gleichzeitig Fehler?

Sperren ist nur die Antwort, warum Sperren die Wurzel des Problems ist!

Diejenigen von Ihnen, die den vorherigen Artikel über agile Seed gelesen haben, wissen, dass ich mich offensichtlich mehr dafür interessiere, anstatt zu fragen, was die Antwort ist, was sich hinter der Antwort verbirgt.

Was ist Parallelität und was ist Parallelität?

Bevor wir beginnen, müssen wir die Grundkonzepte der Nebenläufigkeit verstehen, denn neben Nebenläufigkeit gibt es Parallelität , die leicht damit verwechselt werden kann . Abgesehen davon, dass sie buchstäblich und konzeptionell nicht unterscheidbar ist, scheint Nebenläufigkeit auch parallel zu geschehen.

Lassen Sie uns zuerst über die Konzepte von Parallelität und Parallelität sprechen.Parallelität ist eigentlich das Umschalten mehrerer Threads zur Ausführung zum selben Zeitpunkt . Parallelität kann mehrere Threads gleichzeitig ausführen .

(Aus Sicht der Entwicklung werden Nebenläufigkeit und Parallelität visualisiert. Wenn wir ein System entwickeln, ist die Verantwortung jedes Entwicklungssystems wie ein Faden und ein Ingenieur wie ein Prozessor; Nebenläufigkeit ist wie ein Full-Dry-Ingenieur (Full Stack). für die Fertigstellung zuständig; sie soll sowohl das Frontend als auch das Backend schreiben, und sie ist auch für den Betrieb und die Wartungsarbeiten wie den Aufbau des Servers und das Hin- und Herwechseln zwischen Frontend und Backend verantwortlich Verantwortlichkeiten. Parallel dazu sind mehrere Ingenieure gemeinsam für die Fertigstellung verantwortlich, und es können Front-End-Ingenieure, Back-End-Ingenieure, Datenbankingenieure, Betriebs- und Wartungsingenieure und andere Ingenieure gleichzeitig unabhängig voneinander arbeiten.)

Wie nachfolgend dargestellt:image.png

Wurzel 1: Die Unteilbarkeit des Kontextwechsels

Schauen wir uns zunächst die Hauptursache für gleichzeitige Fehler an, das Atomitätsproblem, das durch Kontextwechsel verursacht wird, der mit einer Single-Core-CPU beginnt, und das Betriebssystem und das Programm können gleichzeitige Multithread-Aufgaben implementieren. (Das heißt, Sie können Artikel über die Nuggets im Browser schreiben und gleichzeitig Musik öffnen und die schwüle DISCO hören.) Es scheint, dass zwei Aufgaben gleichzeitig ausgeführt werden, aber tatsächlich nicht Auf der Unterseite des Computers weist die CPU jedem Thread die Ausführungszeit, also die Zeitscheibe (Time Slice) zu , sodass der Thread die Aufgabe gemäß der von der CPU zugewiesenen Zeitscheibe bei der Ausführung ausführt.

意味着时间片决定了一个线程能占用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的锁一直在变,可问题本质并没有变,我想这应该才是我们应该多花时间和精力的地方。

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

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

创作不易,勿白嫖。

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

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

Ich denke du magst

Origin juejin.im/post/7118919540959870989
Empfohlen
Rangfolge