Conceptos básicos de subprocesos múltiples de Java-12: explicación detallada del algoritmo CAS

El nombre completo de CAS es Compare And Swap, que es un concepto importante en la programación concurrente. Este artículo combina la operación multiproceso de Java para explicar el algoritmo CAS.

La ventaja del algoritmo CAS es que puede garantizar la seguridad de los subprocesos sin bloqueos, lo que reduce la competencia y la sobrecarga entre subprocesos.

Tabla de contenido

1. Contenido del algoritmo CAS

1. Ideas y pasos básicos

2. Pseudocódigo CAS (si imagina CAS como una función)

2. Aplicación del algoritmo CAS

1. Implementar la clase atómica.

*Pseudocódigo para implementar la clase atómica.

2. Implementar bloqueo de giro

*Pseudocódigo para implementar el bloqueo de giro

3. Cuestiones ABA del CAS

1. ERROR causado por un problema de ABA

2. Resuelva el problema del número de versión de uso de ABA


1. Contenido del algoritmo CAS

1. Ideas y pasos básicos

La idea básica del algoritmo CAS es comparar primero el valor en la memoria M con el valor en el registro A (antiguo valor esperado, expectValue) para ver si son iguales. Si son iguales, escriba el valor en el registro B. (nuevo valor, swapValue) en la memoria. ; Si no es igual, no se realiza ninguna operación. Todo el proceso es atómico y no será interrumpido por otras operaciones simultáneas.

Aunque implica el "intercambio" de memoria y valores de registro, la mayoría de las veces no nos importa el valor almacenado en el registro, sino más bien el valor en la memoria (el valor almacenado en la memoria es el valor del variable). Por lo tanto, el "intercambio" aquí no necesita considerarse como un intercambio, sino que puede considerarse directamente como una operación de asignación, es decir, el valor en el registro B se asigna directamente a la memoria M.

Una operación CAS consta de tres operandos: una ubicación de memoria (normalmente una variable compartida), el valor esperado y el nuevo valor. El proceso de ejecución de la operación CAS es el siguiente:

  1. Leer el valor actual de una ubicación de memoria.
  2. Compara el valor actual con el valor esperado para la igualdad.
  3. Si es igual, el nuevo valor se escribe en la ubicación de la memoria; de lo contrario, la operación falla.

2. Pseudocódigo CAS (si imagina CAS como una función )

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

El código proporcionado anteriormente es solo un pseudocódigo y no el código CAS real. De hecho, la operación CAS es una instrucción de hardware atómica compatible con el hardware de la CPU . Esta única instrucción puede completar la función del código anterior.

La mayor diferencia entre "una instrucción" y "un fragmento de código" es la atomicidad. El pseudocódigo anterior no es atómico y pueden ocurrir problemas de seguridad de subprocesos con la programación de subprocesos durante la operación; sin embargo, las instrucciones atómicas no tendrán problemas de seguridad de subprocesos.

Al mismo tiempo, CAS no tendrá el problema de la visibilidad de la memoria, que equivale a que el compilador ajuste una serie de instrucciones y ajuste las instrucciones para leer la memoria en instrucciones para leer registros. Pero CAS en sí es una operación de lectura de memoria a nivel de instrucción, por lo que no habrá problemas de inseguridad de subprocesos causados ​​por la visibilidad de la memoria.

Por lo tanto, CAS puede garantizar la seguridad de los subprocesos hasta cierto punto sin bloquearse. Esto conduce a una serie de operaciones basadas en el algoritmo CAS.


2. Aplicación del algoritmo CAS

CAS se puede utilizar para implementar programación sin bloqueos. Implementar clases atómicas e implementar bloqueos de giro son dos formas de programación sin bloqueos.

1. Implementar la clase atómica.

Hay muchas clases en el paquete java.util.concurrent.atomic de la biblioteca estándar que utilizan instrucciones muy eficientes a nivel de máquina ( en lugar de usar bloqueos) para garantizar la atomicidad de otras operaciones .

Por ejemplo , la clase Atomiclnteger proporciona métodos incrementAndGet, getAndIncrement  , decrementAndGet y getAndDecrement, que incrementan o disminuyen atómicamente un número entero respectivamente.

        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(()->{
                //num++
                num.getAndIncrement();
                //++num
                num.incrementAndGet();
                //num--
                num.getAndDecrement();
                //--num
                num.decrementAndGet();
        });

Por ejemplo, puedes generar de forma segura una secuencia numérica de la siguiente manera :

import java.util.concurrent.atomic.AtomicInteger;

public class Test2 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger num = new AtomicInteger(0);
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //num++
                num.getAndIncrement();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                //num++
                num.getAndIncrement();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(num.get());
    }
}

Resultado de la ejecución: el valor final de num es exactamente 100000

Esto se debe a que  el método getAndIncrement()  obtiene el valor de num de forma atómica e incrementa num . Es decir , la operación de obtener un valor, incrementarlo , establecerlo y luego generar un nuevo valor no se interrumpe . Se garantiza que incluso si varios subprocesos acceden a la misma instancia al mismo tiempo, se calculará y devolverá el valor correcto .

Al observar el código fuente, podemos encontrar que el método getAndIncrement() no usa bloqueo (sincronizado):

Pero luego ingresando al método getAndAddInt puedes encontrar que se usa el algoritmo CAS:

Después de ingresar el método compareAndSwapInt, encontrará que este es un método modificado por nativo. La implementación del algoritmo CAS se basa en el soporte de operación atómica proporcionado por el hardware y el sistema operativo subyacentes, por lo que es una operación de más bajo nivel. 

Anexo: un caso contrastante de subprocesos inseguros es:

El siguiente es un ejemplo de inseguridad de subprocesos. En este código, se crea una variable de contador y se crean dos subprocesos t1 y t2 respectivamente, de modo que estos dos subprocesos puedan incrementar el mismo contador 50.000 veces.

class Counter {
    private int count = 0;
 
    //让count增加
    public void add() {
        count++;
    }
 
    //获得count
    public int get() {
        return count;
    }
}
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
 
        // 创建两个线t1和t2,让这两个线程分别对同一个counter自增5w次
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
 
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
 
        t1.start();
        t2.start();
 
        // main线程等待两个线程都执行结束,然后再查看结果
        t1.join();
        t2.join();
 
        System.out.println(counter.get());
    }
}

Lógicamente hablando, el resultado final de generar el contador debería ser 100.000 veces. Pero después de ejecutar el programa, descubrimos que no solo el resultado no era 10w, sino que el resultado era diferente cada vez que se ejecutaba: el resultado real parecía un valor aleatorio.

Debido a la programación aleatoria de subprocesos, la declaración count++ no es atómica y se compone esencialmente de 3 instrucciones de CPU:

  1. carga. Leer datos en la memoria en los registros de la CPU.
  2. agregar. Realice la operación +1 en el valor en el registro.
  3. ahorrar. Escriba el valor en el registro en la memoria.

La CPU debe completar esta operación de autoincremento en tres pasos. Si es de un solo subproceso, no hay problema con estos tres pasos, pero en la programación de subprocesos múltiples, la situación será diferente. Dado que el orden de programación de subprocesos múltiples es incierto, durante el proceso de ejecución real, el orden de las instrucciones para las operaciones de conteo ++ de los dos subprocesos tendrá muchas posibilidades diferentes:

Lo anterior sólo enumera una parte muy pequeña de las posibilidades, en realidad hay muchas más situaciones posibles. ¡Bajo diferentes órdenes de disposición, los resultados de la ejecución del programa pueden ser completamente diferentes! Por ejemplo, el proceso de ejecución de las dos situaciones siguientes:

Por lo tanto,  dado que el orden de programación real de los subprocesos está desordenado, no podemos estar seguros de qué experimentaron los dos subprocesos durante el proceso de incremento automático, ni podemos estar seguros de cuántas instrucciones se "ejecutan secuencialmente" y cuántas instrucciones se "ejecutan escalonadamente". ". El resultado final se convierte en un valor cambiante. El recuento debe ser menor o igual a 10w.

(Del artículo: Conceptos básicos de subprocesos múltiples de Java-6: problemas y soluciones de seguridad de subprocesos

*Pseudocódigo para implementar la clase atómica.

Código:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

En el código anterior, aunque parece que justo después de asignar el valor a oldValue, se comparan el valor y el valor antiguo para ver si son iguales, los resultados de la comparación aún pueden ser desiguales. Porque esto está en un entorno de subprocesos múltiples. El valor es una variable miembro. Si dos subprocesos llaman al método getAndIncrement al mismo tiempo, puede producirse desigualdad. De hecho, el CAS aquí es para confirmar si el valor actual ha cambiado. Si no ha cambiado, se puede incrementar; si ha cambiado, primero se debe actualizar el valor y luego se puede incrementar.

Los subprocesos anteriores no eran seguros, una de las principales razones es que un subproceso no puede detectar a tiempo la modificación de la memoria por parte de otro subproceso:

El hilo no era seguro antes porque t1 se leía primero y luego se incrementaba. En este momento, antes de que se incremente t1, t2 ya se ha incrementado, pero t1 todavía se incrementa según el valor inicial de 0. Ocurrirán problemas en este momento.

Sin embargo, la operación CAS hace que t1 primero compare si el registro y el valor en la memoria son consistentes antes de ejecutar el incremento automático. Solo si son consistentes, se ejecutará el incremento automático. De lo contrario, el valor en la memoria volver a sincronizarse con el registro.

Esta operación no implica espera de bloqueo, por lo que será mucho más rápida que la solución de bloqueo anterior.

2. Implementar bloqueo de giro

El bloqueo de giro es un mecanismo de bloqueo de espera ocupado. Cuando un hilo necesita adquirir un bloqueo de giro, comprobará repetidamente si el bloqueo está disponible en lugar de bloquearlo inmediatamente. Si la adquisición del candado falla (el candado ya está ocupado por otro subproceso), el subproceso actual intentará inmediatamente adquirir el candado nuevamente y continuará girando (inactivo) esperando que se libere el candado hasta que se adquiera el candado. El primer intento de adquirir el candado falla y el segundo intento se producirá en muy poco tiempo. Esto garantiza que una vez que otros subprocesos liberen el bloqueo, el subproceso actual pueda obtener el bloqueo lo antes posible. Generalmente, en el caso de un bloqueo optimista (la probabilidad de conflicto de bloqueo es baja), es más apropiado implementar un bloqueo giratorio.

*Pseudocódigo para implementar el bloqueo de giro

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有
        // 如果这个锁已经被别的线程持有, 那么就自旋等待
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程
        while(!CAS(this.owner, null, Thread.currentThread())){
        
        }
   }
    public void unlock (){
        this.owner = null;
   }
}


3. Cuestiones ABA del CAS

El problema ABA de CAS es un problema clásico que se encuentra al usar CAS.

Se sabe que la clave de CAS es comparar los valores en la memoria y el registro para ver si son iguales, es a través de esta comparación determinar si el valor en la memoria ha cambiado. Sin embargo, ¿qué pasa si la comparación es la misma, pero en realidad el valor en la memoria no ha cambiado, sino que ha cambiado del valor A al valor B y luego de nuevo al valor A?

En este momento, existe una cierta probabilidad de que algo salga mal. Esta situación se llama problema ABA. CAS solo puede comparar si los valores son iguales, pero no puede determinar si el valor ha cambiado en el medio.

Esto es como comprar un teléfono móvil en un determinado sitio web de peces, pero no podemos saber si el teléfono móvil es un teléfono móvil nuevo que acaba de salir de fábrica o un teléfono reacondicionado que ha sido usado y renovado por otros.

1. ERROR causado por un problema de ABA

De hecho, en la mayoría de los casos, los problemas de ABA tienen poco impacto. Sin embargo, no se pueden descartar algunos casos especiales:

Supongamos que Xiao Ming tiene un depósito de 100. Quiere retirar 50 yuanes del cajero automático. El cajero automático crea dos subprocesos y los ejecuta simultáneamente -50

(Débito de 50 yuanes de la cuenta) Esta operación.

Esperamos que uno de los dos subprocesos ejecute -50 con éxito y el otro subproceso falle -50. Si utiliza CAS para completar este proceso de deducción, pueden surgir problemas.

proceso normal

  1. Deposita 100. El subproceso 1 obtiene que el valor de depósito actual sea 100 y el valor esperado se actualiza a 50; el subproceso 2 obtiene que el valor de depósito actual sea 100 y el valor esperado se actualiza a 50.
  2. El hilo 1 realiza la deducción con éxito y el depósito se cambia a 50; el hilo 2 está bloqueado y esperando.
  3. Es el turno de ejecución del subproceso 2 y se descubre que el depósito actual es 50, que es diferente de los 100 leídos antes , y la ejecución falla.

proceso anormal

  1. Deposita 100. El subproceso 1 obtiene que el valor de depósito actual sea 100 y el valor esperado se actualiza a 50; el subproceso 2 obtiene que el valor de depósito actual sea 100 y el valor esperado se actualiza a 50.
  2. El subproceso 1 ejecutó con éxito la deducción y el depósito se cambió a 50. El hilo 2 está bloqueado y esperando.
  3. Antes de que se ejecute el hilo 2 , el amigo de Xiao Ming acaba de transferir  50 a Xiao Ming. ¡En este momento, el saldo de la cuenta de Xiao Ming volvió a ser 100!
  4. Es el turno de ejecución del subproceso 2 y se descubre que el depósito actual es 100,  que es el mismo que el 100 leído antes , y la operación de deducción se realiza nuevamente. 

¡En este momento , la operación de deducción se realizó dos veces! Todo esto se debe a problemas de ABA .

2. Resuelva el problema del número de versión de uso de ABA

La clave del problema ABA es que el valor de la variable compartida en la memoria salta repetidamente. Si se acuerda que los datos sólo pueden cambiar en una dirección, el problema estará resuelto.

Esto introduce el concepto de "número de versión". Se acuerda que el número de versión solo se puede incrementar (cada vez que se modifica una variable, se agregará un número de versión). Y cada vez que CAS compara, la comparación no es el valor en sí, sino el número de versión. De esta manera, otros subprocesos pueden verificar si el número de versión ha cambiado al realizar operaciones CAS, evitando así la aparición de problemas de ABA.

(El número de versión se utiliza como base, no el valor de la variable. Se acuerda que el número de versión solo se puede incrementar, por lo que no habrá saltos horizontales repetidos como ABA).

Sin embargo, en situaciones reales, en la mayoría de los casos, incluso si encuentra problemas de ABA, no importa. Con solo saber el número de versión se puede utilizar para resolver problemas de ABA.

Supongo que te gusta

Origin blog.csdn.net/wyd_333/article/details/131710655
Recomendado
Clasificación