Programación concurrente: explore la esencia detrás de la visibilidad y el principio de la volatilidad

visibilidad

En un entorno de subproceso único, si primero escribe un valor en una variable y luego lee el valor de la variable sin interferencia de escritura, entonces el valor de la variable leída en este momento debe ser el que se escribe antes del valor. Esto hubiera sido algo muy normal. Sin embargo, en un entorno de subprocesos múltiples, cuando la lectura y la escritura ocurren en diferentes subprocesos, puede suceder que el subproceso de lectura no pueda leer el último valor escrito por otros subprocesos de manera oportuna. Esto se llama visibilidad.

causa de la invisibilidad

Hay dos razones para la invisibilidad, una es la coherencia de la memoria caché y la otra es la reordenación de instrucciones.

coherencia de caché

Podemos ver la arquitectura de caché del sistema operativo como se muestra en la figura a continuación. Arquitectura de caché del sistema operativo.pngEl diseñador original sistema diseñó el caché de tercer nivel para mejorar la velocidad de acceso a los datos y el rendimiento (debido a que el caché está cerca de la CPU, la velocidad de acceso es rápida). , y la frecuencia y el ancho de banda del caché de tercer nivel más alto y todo en el chip de la CPU, mientras que la memoria principal necesita comunicarse con el procesador a través de una conexión como un bus, y el retraso de acceso es relativamente alto)

Con este caché de tercer nivel, para mejorar el rendimiento, cada hilo tiene su propia memoria de trabajo, es decir, caché.Cuando un hilo modifica el valor de una variable compartida, primero puede almacenar el valor en su propia memoria de trabajo. no se vuelve a escribir inmediatamente en la memoria principal. Cuando otros subprocesos leen la variable compartida, pueden leer el valor de su propia memoria de trabajo en lugar de obtener el último valor de la memoria principal. En este caso, si un subproceso modifica el valor de la variable compartida, es posible que otros subprocesos no puedan percibir el cambio de inmediato, lo que genera problemas de invisibilidad.

Para solucionar la invisibilidad provocada por la coherencia de caché, el nivel del sistema propone el bloqueo de bus y el bloqueo de caché
: cuando uno de los procesadores quiere operar en la memoria compartida, envía una señal LOCK# en el bus, lo que hace que otros procesadores no puede acceder a los datos en la memoria compartida a través del bus. El bloqueo del bus bloquea la comunicación entre la CPU y la memoria, lo que hace imposible que otros procesadores operen datos en otras direcciones de memoria durante el bloqueo, por lo que la sobrecarga del bloqueo del bus Relativamente grande, este mecanismo es obviamente inapropiado.

缓存锁:就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执 行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作 的原子性。缓存锁的实现方式有多种,其中比较常见的是MESI(Modified, Exclusive, Shared, Invalid)协议。MESI协议定义了缓存行的四种状态:修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid)。处理器在执行读写操作时,会根据缓存行的状态来确定是否需要使用缓存锁,以保证数据的一致性

下面是MESI协议的四种状态和其含义:

  • Modified(修改):缓存行被修改且未写回内存。在该状态下,缓存行是处理器私有的,其他处理器无法缓存该数据。如果该缓存行被写回内存,状态会转换为Shared或Invalid。

  • Exclusive(独占):缓存行只存在于当前处理器的缓存中,未被其他处理器缓存。该数据是一致的,其他处理器可以通过缓存一致性协议来读取数据。

  • Shared(共享):缓存行被多个处理器缓存,且数据是一致的。多个处理器可以同时缓存该数据,读取操作不会修改缓存行的内容。如果某个处理器要修改数据,则会将缓存行状态转换为Modified,并阻止其他处理器的读访问。

  • Invalid(无效):缓存行无效,需要从内存中读取最新数据。这种状态发生在其他处理器修改了共享数据,并将其标记为Invalid,通知其他处理器需要重新从内存中获取最新数据。

MESI协议的实现原理如下:

  • 当处理器读取一个缓存行时,会首先检查缓存行的状态:

    • 如果状态是Modified或Exclusive,表示缓存中的数据是一致的,可以直接读取。
    • 如果状态是Shared,表示其他处理器也在缓存该数据,可以直接读取。
    • 如果状态是Invalid,表示缓存行无效,需要从内存中获取最新数据。
  • 当处理器要写入一个缓存行时,会根据以下情况进行处理:

    • 如果状态是Modified,表示缓存中的数据已被修改,可以直接写入。
    • 如果状态是Exclusive,表示缓存中的数据是一致的,可以直接写入,并将状态转换为Modified。
    • 如果状态是Shared,表示其他处理器也在缓存该数据,需要进行协调。
      • 处理器会发出一个写的信号,通知其他处理器将该数据的缓存行状态转换为Invalid,从而使其他处理器重新从内存中读取最新数据。
      • 处理器将缓存行状态转换为Modified,表示该数据被修改,并且只有自己能够缓存该数据。
  • 在状态转换时,缓存一致性协议会使用总线或其他互联机制来进行通信,以确保各个处理器的缓存状态保持一致。

指令重排序

针对上面的缓存一致性协议我们提出一个这样的例子比如两个cpu异步访问两个参数如下伪代码:

executeToCPU0(){ 
   x = 1; 
   flag = true;
}
executeToCPU1(){
   while(flag){
     assert(x==1)
   }
}
复制代码

可以发现有可能会抛出异常,安装我们正常的思维while循环进来的时候 x应该是等于1的所以应该没问题。为什么会有可能抛出异常呢?就是因为处理器对内存写入操作的效率的提高引出了存储缓冲器(Store Buffers):
存储缓冲器的工作原理如下:

  1. 当处理器执行写操作时,写入的数据和目标内存地址会被缓存到存储缓冲器中,而不是立即写入内存。
  2. 处理器可以继续执行后续的指令,而不需要等待写入操作完成。
  3. 在后续的阶段,处理器会根据一定的策略将存储缓冲器中的写操作提交到内存中。这个提交的过程通常发生在特定的点,如内存屏障指令、条件分支或是其他内部机制触发的时候。
  4. 内存子系统负责将存储缓冲器中的写操作同步到实际的内存位置。

因为Store Buffers的存在也就有可能出现flag = true;先执行x = 1;后执行也就是指令重排序 如下图所示: Archivo sin título (Ultra HD).jpeg 这个Store Buffers通俗点讲就是一个mq就是我们把写的操作丢到mq里面不耽误下面代码的执行

针对于指令重排序的问题系统又提出了内存屏障来禁止指令重排序。

内存屏障分为两种类型:读屏障(Load Barrier)和写屏障(Store Barrier)。

  • 读屏障(Load Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏 障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的

  • 写屏障(Store Barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(store Buffers)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的

  • 全屏障 (Full Barrier) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障 后的读写操作

executeToCPU0(){ 
   x = 1; 
   //storeBarrier()写屏障,写入到内存
   flag = true;
}
executeToCPU1(){
   while(flag){
     //loadBarrier(); //读屏障
     assert(x==1)
   }
}
复制代码

这样也就可以保证指令不会被重排

JMM

JMM(Java Memory Model,Java内存模型)是Java语言规范中定义的一种规范,用于描述Java程序在多线程环境下的内存访问行为。JMM 定义了线程如何与主内存和工作内存进行交互,以及如何保证多线程程序的正确性。

JMM 主要关注以下几个方面:

  1. 主内存(Main Memory):主内存是所有线程共享的内存区域,包含了程序的变量和数据。所有线程都可以读写主内存中的数据。(也就是内存)
  2. 工作内存(Working Memory):每个线程都有自己的工作内存,工作内存是线程私有的内存区域。线程执行时,它的读写操作都是在工作内存中进行的。(对应这cpu高度缓存)
  3. 内存间的交互:线程之间通过主内存进行通信。当一个线程修改了共享变量的值时,它必须将该值写回主内存。其他线程在读取该共享变量时,会从主内存中获取最新的值。
  4. 原子性、可见性和有序性:JMM 定义了一系列规则和特性来保证多线程程序的正确性。其中包括原子性(Atomicity):对基本类型的读写具有原子性;可见性(Visibility):一个线程对共享变量的修改对其他线程可见;有序性(Ordering):程序的执行结果与代码的编写顺序保持一致。

JMM 提供了一套规范,确保多线程程序在不同的平台和实现中表现一致。同时,它也提供了一些同步机制(如锁、volatile关键字、synchronized关键字、原子类等)来帮助程序员编写正确且高效的多线程代码。

需要注意的是,虽然 JMM 提供了一定的保证,但在编写多线程程序时,仍然需要程序员根据具体情况使用适当的同步机制,以确保线程安全性和正确性。

vloatile原理

上文说了那么多其实大致的原理大家应该也清楚了无非就是告诉系统需要添加内存屏障,使用系统的内存屏障来实现防止指令重排序。
这里我们可以简单的写一个demo验证一下:

public class TestVolatile {

    public static volatile int  x = 1;

    public static void main(String[] args){
        x = 2;
        System.out.println(x);
    }
}
复制代码

我们看一下编译后的字节码文件 imagen.png 发现使用vloatile修饰的变量会多一个ACC_VOLATILE的标记
我们再看一下字节码命令如下: imagen.png 这个时候我们再去jvm源码里面看putstatic源码如下(具体位置/hotspot/src/share/vm/interpreter路径下的bytecodeInterpreter.cpp) imagen.png 至此vloatile原理也就比较清晰了

Happens-Before模型

Happens-Before模型是Java内存模型(JMM)中定义的一种偏序关系,用于描述并发程序中不同操作之间的可见性和顺序性规则。它是JMM中的一个重要概念,用于指导程序员编写正确且具有可预测行为的多线程代码。

Happens-Before模型的基本原则是,如果一个操作" happens-before"另一个操作,那么第一个操作的结果对于第二个操作是可见的,而且第一个操作一定在第二个操作之前执行。

Happens-Before关系的规则包括:

  1. 程序次序规则(Program Order Rule):同一个线程中的操作,按照程序的顺序执行,前一个操作的结果对后续操作可见。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作 happens-before 后续的lock操作,确保共享变量的可见性。
  3. volatile变量规则(Volatile Variable Rule):对于一个volatile变量的写操作 happens-before 后续的对该变量的读操作,确保volatile变量的可见性。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法的调用 happens-before 新线程中的任意操作。
  5. 线程终止规则(Thread Termination Rule):线程中的任意操作 happens-before 对该线程的终止检测,即Thread.join()的完成。
  6. 传递性规则(Transitive Rule):如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C。

这些规则为程序员提供了一些有序性和可见性的保证,以帮助编写正确的多线程代码。通过遵守Happens-Before模型的规则,程序员可以确保多线程程序的执行结果是可预测的,避免出现数据竞争和不确定的行为。

需要注意的是,Happens-Before模型是一种约束性规范,确保程序在不同的平台和实现中表现一致。但它并不代表真实的操作执行顺序,具体的执行顺序由处理器、编译器和运行时环境等因素决定。

案例说明

我们先看一段代码如下图所示:

private static boolean stop;
public static void main(String[] args) throws InterruptedException {
    stop = false;
    Thread thread=new Thread(()->{
        int i=0;
        while(!stop){
            i++;
        }
    });
    thread.start();
    Thread.sleep(1000);
    stop=true;
}
复制代码

我们可以通过正常思路分析一下,我们可以看到的是首先会开辟一个线程运行i++的操作知道stop是true的时候,下面的代码呢是隔了一秒之后会将stop置为true所以这段代码会在一秒后执行完成,实际结果是这段代码不会终止。

Cabe señalar que muchas personas aquí piensan que la invisibilidad es causada por el caché. De hecho, no tiene automáticamente un protocolo de coherencia de caché. La invisibilidad es solo temporal y no permanecerá invisible. La causa principal de este resultado es JIT. optimización Lo anterior Después de que el código se ejecuta durante un período de tiempo, el compilador juzgará el código anterior como un código caliente y lo optimizará al código que se muestra a continuación (esto está relacionado con la versión JDK)

if(stop){
   while(true){
   }
}
复制代码

Aquí hay varias soluciones:

  1. Agregar salida dentro del hilo
  2. Añadir Thread.sleep(0)
  3. Agregue la palabra clave volátil

JIT compila el código activo (segmento de código que se ejecuta con frecuencia) en el intérprete en código de máquina para mejorar la eficiencia de ejecución del programa. Las tres soluciones anteriores pueden evitar la optimización de JIT.

Supongo que te gusta

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