¿Por qué los sistemas que desarrollamos tienen errores concurrentes y cuáles son las causas fundamentales de los errores concurrentes?

Zhilan nació en un valle apartado y no es fragante por culpa de nadie.

prefacio

¿Por qué los sistemas que desarrollamos tienen errores concurrentes y cuáles son las causas fundamentales de los errores concurrentes?

Antes de hacer esta pregunta, hablemos de la visión de una semilla inteligente sobre la concurrencia.La concurrencia es realmente un tema familiar y desconocido. La familiaridad se debe a que Java admite naturalmente subprocesos múltiples, y los subprocesos múltiples son el terreno de la concurrencia (un solo subproceso no tendrá problemas de concurrencia, JavaScript es de un solo subproceso, verá, el programador front-end sentado frente a mí que escribe JavaScript no tendrá Debido al tiempo extra de trabajo de errores concurrentes, aunque los amigos de back-end pueden estar insatisfechos, es cierto).

Entonces, cuando aprendí los conceptos básicos, pasé por el bautismo de subprocesos múltiples y supe cuán impredecibles son los datos cuando se opera una colección desbloqueada bajo subprocesos múltiples. Por ejemplo, estamos operando una clase de colección no segura para subprocesos que se usa comúnmente en Java: HashMap,ArrayListen subprocesos múltiples Si lo hace, put(),add()obtendrá resultados de datos incorrectos.

Y cuando se separa del estudio de ejemplos de subprocesos múltiples puramente simulados, el sistema desarrollado por nosotros en la empresa se libera en línea, lo que enfrentamos es que cada usuario real llega a nuestro sistema y cada usuario está en la computadora. es un hilo en él. En este punto, debemos considerar si cada función del sistema es segura para subprocesos en un entorno de subprocesos múltiples. Por ejemplo, el sistema que desarrollamos a menudo se encuentra con escenarios con errores simultáneos, como hacer un pedido de compras en un escenario de comercio electrónico, y escenarios de alta concurrencia, como el robo de boletos para trenes y trenes de alta velocidad en un escenario de viaje. Si no se puede prevenir el problema de concurrencia, cuando una gran cantidad de usuarios van a comprar al mismo tiempo, el inventario estará sobrevendido, es decir, habrá errores en la concurrencia. Pero causar errores simultáneos en el sistema es solo el resultado Lo que queremos explorar es la causa de estos errores simultáneos.

(Pero, ¿son estos errores concurrentes realmente la olla de nuestros programadores de software? De hecho, no necesariamente, la siguiente semilla ágil lo llevará a encontrar la verdadera "olla")

Por lo tanto, todos tenemos el mismo consenso sobre la inseguridad de subprocesos múltiples en estos escenarios, es decir, el bloqueo es suficiente. Agregue bloqueos locales en un solo sistema, como common: Synchronized,Locketc., y agregue bloqueos distribuidos en microservicios, clústeres y sistemas distribuidos, como: Redis、ZooKeeper. Pero esto no es algo que me desconozca. Lo que realmente me desconcierta es por qué se necesita el bloqueo. El bloqueo es solo un medio para resolver el problema, y ​​¿cuál es la raíz del problema?

¿Por qué ocurren estos problemas con subprocesos múltiples y por qué los sistemas que desarrollamos tienen errores simultáneos?

Bloquear es solo la respuesta, ¿por qué bloquear es la raíz del problema?

Aquellos de ustedes que hayan leído el artículo anterior sobre semillas ágiles saben que, en lugar de preguntar cuál es la respuesta, obviamente estoy más interesado en lo que hay detrás de la respuesta.

Entonces, ¿qué es concurrencia y qué es paralelismo?

Antes de comenzar, debemos comprender los conceptos básicos de la concurrencia, porque además de la concurrencia existe el paralelismo que se puede confundir fácilmente con él . Además de ser indistinguible literal y conceptualmente, la concurrencia también parece estar ocurriendo en paralelo.

Hablemos primero de los conceptos de concurrencia y paralelismo La concurrencia es en realidad el cambio de múltiples subprocesos para su ejecución en el mismo punto de tiempo . El paralelismo puede ejecutar varios hilos al mismo tiempo .

(Desde la perspectiva del desarrollo, se visualiza la concurrencia y el paralelismo. Cuando desarrollamos un sistema, la responsabilidad de cada sistema de desarrollo es como un hilo, y un ingeniero es como un procesador; la concurrencia es como un ingeniero completamente seco (full stack) es responsable de la finalización; es escribir tanto el front-end como el back-end, y también es responsable del trabajo de operación y mantenimiento, como construir el servidor y alternar entre el front-end y el back-end responsabilidades Paralelamente, varios ingenieros son responsables de la finalización juntos, y puede haber ingenieros de front-end, ingenieros de back-end, ingenieros de bases de datos, ingenieros de operación y mantenimiento y otros ingenieros que trabajan de forma independiente al mismo tiempo)

Como se muestra abajo:image.png

Raíz 1: La atomicidad del cambio de contexto

Primero veamos la causa raíz de los errores concurrentes, el problema de atomicidad causado por el cambio de contexto, que comienza con una CPU de un solo núcleo, y el sistema operativo y el programa pueden implementar tareas concurrentes de subprocesos múltiples. (Es decir, puede escribir artículos sobre las pepitas en el navegador y, al mismo tiempo, puede abrir música y escuchar el sensual DISCO) Parece que se están ejecutando dos tareas al mismo tiempo, pero de hecho, en En la parte inferior de la computadora, la CPU asigna a cada hilo el tiempo de ejecución, es decir, el intervalo de tiempo (Time Slice) , por lo que el hilo ejecuta la tarea de acuerdo con el intervalo de tiempo asignado por la CPU al ejecutar.

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

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

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

创作不易,勿白嫖。

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

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

Supongo que te gusta

Origin juejin.im/post/7118919540959870989
Recomendado
Clasificación