Notas de estudio de C/C++ Simultaneidad en hardware moderno

1. Concurrencia en hardware moderno

1. ¿Qué es la concurrencia?

function foo() { ... }
function bar() { ... }

function main() {
    t1 = startThread(foo)
    t2 = startThread(bar)
    // 在继续执行 main() 之前等待 t1 和 t2 完成
    waitUntilFinished(t1)
    waitUntilFinished(t2)
}

        En este programa de ejemplo, la concurrencia significa que foo() y bar() se ejecutan al mismo tiempo. ¿Cómo hace esto realmente la CPU?

2. ¿Cómo usar la concurrencia para que tu programa sea más rápido?

        Las CPU modernas pueden ejecutar varios flujos de instrucciones simultáneamente:

        1. Un solo núcleo de CPU puede ejecutar varios subprocesos: subprocesos múltiples simultáneos (SMT), que Intel denomina hiperprocesamiento

        2. Por supuesto, la CPU también puede tener varios núcleos que pueden ejecutarse de forma independiente

        Para obtener el mejor rendimiento en la programación, es esencial escribir programas multiproceso. Para hacer esto, se requiere una comprensión básica de cómo se comporta el hardware en un entorno de programación paralelo.

        La mayoría de los detalles de implementación de bajo nivel se pueden encontrar en el Manual del desarrollador de software de arquitectura Intel y en el Manual de referencia de la arquitectura ARM.

3. SMT multiproceso simultáneo

        Las CPU admiten el paralelismo a nivel de instrucción mediante el uso de ejecución fuera de orden

        Usando SMT (Subprocesos múltiples simultáneos), la CPU también admite paralelismo a nivel de subprocesos

        1. En un solo núcleo de CPU, ejecute múltiples subprocesos

        2. Muchos componentes de hardware, como ALU, unidades SIMD, etc., se comparten entre subprocesos

        3. Duplicar otros componentes para cada subproceso, como la unidad de control obtener y decodificar instrucciones, registrar archivos

4. El problema de SMT

        Cuando se usa SMT, varios flujos de instrucciones comparten algunos de los núcleos de la CPU.

        1. SMT no mejora el rendimiento cuando una transmisión usa todas las unidades de cómputo individualmente

        2. El mismo ancho de banda de memoria

        3. Es posible que algunas unidades solo existan una vez en el núcleo, por lo que SMT también degradará el rendimiento

        4. ¡Esto puede generar problemas de seguridad cuando dos subprocesos de procesos no relacionados se ejecutan en el mismo núcleo! Similar a las preocupaciones de seguridad de Spectre y Meltdown.

5. Coherencia de caché

        Diferentes núcleos pueden acceder a la misma memoria al mismo tiempo, múltiples núcleos pueden compartir caché, el caché es inclusivo 

        ¡La CPU debe asegurarse de que el caché sea consistente con el acceso concurrente! Comunicación entre CPU utilizando el protocolo de coherencia de caché

6. Protocolo MESI

        Las CPU y los cachés siempre leen y escriben con granularidad de línea de caché (es decir, 64 bytes)

        El protocolo genérico de coherencia de caché MESI asigna a cada línea de caché uno de cuatro estados:

                Modificado: la línea de caché solo se almacena en un caché y se ha modificado en el caché, pero no se ha vuelto a escribir en la memoria principal

                Exclusivo: las líneas de caché solo se almacenan en un caché para uso exclusivo de una CPU

                SharedShared: la línea de caché se almacena en al menos un caché, la CPU la utiliza actualmente para acceso de solo lectura y no se ha modificado

                Inválido: la línea de caché no fue cargada o utilizada exclusivamente por otro caché

(1) MESES Ejemplo (1)

 (2) MESES Ejemplo (2)

7. Acceso a la memoria y concurrencia

        Considere el siguiente programa de muestra, donde foo() y bar() se ejecutarán simultáneamente:

globalCounter = 0
function foo() {
    repeat 1000 times:
    globalCounter = globalCounter - 1
}

function bar() {
    repeat 1000 times:
    globalCounter = (globalCounter + 1) * 2
}

        El código de máquina para este programa podría verse así:

         ¿Cuál es el valor final de globalCounter?

8, orden de memoria

        La ejecución fuera de orden y el multiprocesamiento simultáneo conducen a la ejecución inesperada de instrucciones de carga y almacenamiento de memoria

        Todas las instrucciones ejecutadas eventualmente se completarán

        Sin embargo, los efectos de las instrucciones de memoria (es decir, lecturas y escrituras) pueden volverse visibles en un orden indeterminado.

        ¡El proveedor de la CPU define cómo se permite intercalar lecturas y escrituras! orden de memoria

        En general: las instrucciones relevantes en un solo hilo siempre funcionan como se esperaba:

store $123, A
load A, %r1

        Si solo este hilo accede a la ubicación de memoria en A, entonces r1 siempre contendrá 123

(1) Orden de memoria débil y fuerte

        Las arquitecturas de CPU a menudo tienen un orden de memoria débil (por ejemplo, ARM) o un orden de memoria fuerte (por ejemplo, x86)

        Secuencia de memoria débil:

                Las instrucciones de memoria y sus efectos se pueden reordenar siempre que se respeten las dependencias.

                Diferentes subprocesos verán escrituras en diferentes órdenes

        Fuerte orden de memoria:

                Dentro de un subproceso, solo se permiten tiendas perezosas después de cargas posteriores, todo lo demás no se reordena

                Cuando dos subprocesos realizan la tienda en la misma ubicación, todos los demás subprocesos verán el resultado escrito en el mismo orden

                Todos los demás subprocesos verán escrituras de un conjunto de subprocesos en el mismo orden

        Para ambos:

                Las escrituras de otros subprocesos se pueden reordenar

                Los accesos de memoria simultáneos a la misma ubicación se pueden reordenar

(2) Ejemplo de orden de memoria (1)

En este ejemplo, inicialmente la memoria en A contiene el valor 1 y la memoria en B contiene el valor 2.

Orden de memoria débil:

        Los hilos no tienen instrucciones dependientes.

        Las instrucciones de memoria se pueden reordenar arbitrariamente

        r1 = 3, r2 = 2, r3 = 4, r4 = 1 están permitidos

Fuerte orden de memoria:

        Los subprocesos 3 y 4 deben ver las escrituras de los subprocesos 1 y 2 en el mismo orden

        Ejemplo donde no se permite el ordenamiento de memoria débil

        r1 = 3, r2 = 2, r3 = 4, r4 = 3 están permitidos

(3) Ejemplo de orden de memoria (2) 

Ejemplo de visualización de orden de memoria débil:

        El subproceso 3 ve escribir A antes de escribir B. (4.) (1.)
        El subproceso 4 ve escribir B antes de escribir A. (8.) (5.)
        En memoria sólida, 5. no se permite que suceda antes de 8.

9, Barreras de memoria

        Las CPU multinúcleo tienen instrucciones de barrera de memoria especial (también conocida como barrera de memoria) que imponen requisitos de ordenación de memoria más estrictos

        Esto es especialmente útil para arquitecturas con ordenamiento de memoria débil.

        x86 tiene las siguientes instrucciones de barrera:

        Infracción: las cargas anteriores no se pueden reordenar fuera de esta instrucción, y las cargas y almacenes posteriores no se pueden reordenar antes de esta instrucción

        Advertencia: las tiendas anteriores no se pueden reordenar después de esta directiva, las tiendas posteriores no se pueden reordenar antes de esta directiva

        mfence: las cargas o las provisiones no se pueden reordenar antes o después de esta instrucción

        ARM tiene instrucciones de barrera de memoria de datos que admiten diferentes modos:

        dmb ishst: todas las escrituras que eran visibles o causadas por este hilo antes de esta instrucción serán visibles para todos los hilos antes de cualquier escritura de las tiendas que siguen esta instrucción

        dmb ish: todas las escrituras visibles o causadas por este hilo y las lecturas relacionadas que preceden a esta instrucción serán visibles para todos los hilos antes de cualquier lectura y escritura que siga a esta instrucción

        Para controlar adicionalmente la ejecución desordenada, ARM proporciona instrucciones de barrera de sincronización de datos: dsb ishst, dsb ish

10、Operaciones atómicas

        El orden de la memoria solo se preocupa por las cargas y las tiendas de memoria

        ¡No hay restricciones de orden de memoria en las tiendas simultáneas en la misma ubicación de memoria! el orden puede ser indefinido

        Para permitir modificaciones concurrentes deterministas, la mayoría de las arquitecturas admiten operaciones atómicas.

        Una operación atómica suele ser una secuencia: cargar datos, modificar datos, almacenar datos

        También conocido como lectura-modificación-escritura (RMW)

        La CPU garantiza que todas las operaciones RMW se realicen atómicamente, es decir, no se permiten otras cargas y almacenamientos simultáneos en el medio.

        Por lo general, solo se admite una única instrucción aritmética y bit a bit.

11, Operaciones de comparación e intercambio (1)

         En x86, la instrucción RMW puede bloquear el bus de memoria

        Para evitar problemas de rendimiento, solo hay unas pocas instrucciones RMW

        Para facilitar operaciones atómicas más complejas, se pueden utilizar operaciones atómicas de comparación e intercambio (CAS)

        ARM no admite el bloqueo del bus de memoria, por lo que todas las operaciones de RMW se implementan con CAS

        Una instrucción CAS tiene tres parámetros: ubicación de memoria m, valor esperado e y valor esperado d

        Conceptualmente, las operaciones CAS funcionan de la siguiente manera:

        Nota: las operaciones de CAS pueden fallar, por ejemplo, debido a modificaciones simultáneas

12, Operaciones de comparación e intercambio (2)

        Debido a que las operaciones de CAS pueden fallar, generalmente se usan en un ciclo con los siguientes pasos:

        1. Cargue el valor de la ubicación de la memoria en el registro local
        2. Use el registro local para el cálculo suponiendo que ningún otro subproceso modificará la ubicación de la memoria
        3. Generar un nuevo valor esperado
        para la ubicación de la memoria 4. Ubicación de la memoria CAS con valor en el registro local como valor esperado Acción
        5. Si la operación CAS falla, inicie el ciclo desde el principio

        ¡Tenga en cuenta que los pasos 2 y 3 pueden contener cualquier cantidad de instrucciones y no se limitan a las instrucciones RMW!

13, Operaciones de comparación e intercambio (3)

        Un ciclo típico que usa CAS se ve así:

success = false
while (not success) { (Step 5)
    expected = load(A) (Step 1)
    desired = non_trivial_operation(expected) (Steps 2, 3)
    success = CAS(A, expected, desired) (Step 4)
}

        Con este enfoque, se pueden realizar operaciones atómicas arbitrariamente complejas en ubicaciones de memoria

        Sin embargo, la probabilidad de falla aumenta cuanto más tiempo se dedica a operaciones no convencionales.

        Además, las operaciones no convencionales pueden realizarse con más frecuencia de la necesaria.

2. Programación en paralelo

        Los programas de subprocesos múltiples a menudo contienen muchas
                estructuras de datos de recursos compartidos.
                Identificadores del sistema operativo (como descriptores de archivos)
                en ubicaciones de memoria separadas.

        Necesidad de controlar el acceso simultáneo a los recursos compartidos
                El acceso no controlado conduce a condiciones de carrera Las condiciones de carrera a
                menudo terminan en estados de programa inconsistentes
                También son posibles otros resultados, como la corrupción silenciosa de datos        

        La sincronización se puede implementar de diferentes maneras mediante
                el soporte del sistema operativo, como a través de mutex
                . Soporte de hardware, especialmente a través de operaciones atómicas.

1. Exclusión mutua (1)

        Eliminar elementos de la lista vinculada al mismo tiempo

        La observación
                C en realidad no se elimina por
                subproceso, también es posible liberar la memoria del nodo después de la eliminación 

2. Exclusión mutua (2)

        Proteger los recursos compartidos al permitir el acceso solo dentro de las secciones críticas.
                Solo un subproceso a la vez puede ingresar a la sección crítica.
                Si se usa correctamente, aún es posible garantizar que el estado del programa sea siempre consistente y
                un comportamiento no determinista (pero consistente) todavía es posible

        Existen múltiples posibilidades para implementar la exclusión mutua.
                Las operaciones de prueba y configuración atómicas a
                        menudo requieren giros, lo que puede ser peligroso para el
                soporte del sistema operativo,
                        por ejemplo. Mutex en Linux

3. Bloquear

        Un mutex se logra adquiriendo un bloqueo en el objeto mutex,
                solo un subproceso puede adquirir el mutex a la vez.
                Intentar adquirir un bloqueo en un mutex bloqueado bloqueará el subproceso hasta que el mutex esté disponible nuevamente
                . El subproceso bloqueado puede ser suspendido por el núcleo para liberar recursos informáticos

        Se pueden usar múltiples mutex para representar secciones críticas separadas
                . Solo un subproceso puede ingresar a la misma sección crítica a la vez, pero los subprocesos pueden ingresar a diferentes secciones críticas al mismo tiempo
                . Permite una sincronización más detallada.
                Necesita una implementación cuidadosa para evitar puntos muertos .

4. Candado compartido

        Las exclusiones mutuas estrictas no siempre son necesarias
                . Los accesos de solo lectura simultáneos comunes al mismo recurso compartido no interfieren entre sí.
                El uso de exclusiones mutuas estrictas puede generar cuellos de botella innecesarios porque las lecturas también se bloquean entre sí
                . accesos de lectura

        Los bloqueos compartidos proporcionan una solución. Un
                subproceso puede adquirir un bloqueo exclusivo o un bloqueo compartido en una exclusión mutua.
                Si la exclusión mutua no está bloqueada exclusivamente, varios subprocesos pueden adquirir un bloqueo compartido en la exclusión mutua al mismo tiempo.
                Si la exclusión mutua no está bloqueada por cualquier otro modo de bloqueo (exclusivo o compartido), entonces un subproceso a la vez puede obtener un bloqueo exclusivo en el mutex

5. Problema de exclusión mutua (1)

        punto muerto

        Múltiples subprocesos cada uno espera a que otros subprocesos liberen bloqueos

        Evite interbloqueos
                Si es posible, los subprocesos no deben adquirir múltiples bloqueos
                Si no se puede evitar, los bloqueos siempre deben adquirirse en un orden coherente globalmente

6. Problema de exclusión mutua (2)


        La alta contención para mutex          hambrientos puede provocar que algunos subprocesos no progresen.
        Esto se puede mitigar parcialmente mediante el uso de un esquema de bloqueo menos restrictivo.

        Alta latencia
        Si la contención de mutex es intensa, algunos subprocesos se bloquearán durante mucho tiempo,
        lo que puede causar una
        degradación significativa del rendimiento del sistema e incluso puede ser inferior al rendimiento de un solo subproceso.

        Inversión
        de prioridad Los subprocesos de alta prioridad pueden ser bloqueados por subprocesos de menor prioridad
        , lo que puede impedir que los subprocesos
        de baja prioridad tengan suficientes recursos informáticos para liberar bloqueos rápidamente debido a las diferencias de prioridad

7. Sincronización asistida por hardware

        El uso de mutex suele ser relativamente costoso
               . Cada mutex requiere algún estado (de 16 a 40 bytes).
                Adquirir un bloqueo puede requerir una llamada al sistema, lo que puede llevar miles de ciclos o más.

        Entonces, los mutexes son mejores para el bloqueo de grano grueso,
                por ejemplo. Bloquear toda la estructura de datos en lugar de una parte de
                ella es suficiente si solo hay unos pocos subprocesos que compiten por el bloqueo en
                el mutex si hay secciones más críticas protegidas por el mutex, es más
                costoso que (potencialmente) adquirir la llamada al sistema La cerradura

        El rendimiento del mutex se degrada rápidamente bajo una alta contención.
                En particular, la latencia de la adquisición de bloqueos aumenta drásticamente.
                Esto sucede incluso cuando solo adquirimos bloqueos compartidos en el mutex.
                Podemos aprovechar el soporte de hardware para una sincronización más eficiente.

8. Bloqueo optimista (1)

        En general, el acceso de solo lectura a los recursos es más común que el acceso de escritura.
                Por lo tanto, debemos optimizar para el caso común de acceso de solo lectura.
                En particular, el acceso paralelo de solo lectura por parte de muchos subprocesos debería ser efectivo.
                Los bloqueos compartidos no lo son . adecuado para esto

        El bloqueo optimista puede proporcionar una sincronización eficiente entre el lector y el escritor
                Asociar una versión con un recurso compartido
                Las escrituras aún deben adquirir algún tipo de bloqueo exclusivo
                        Esto asegura que solo un autor a la vez pueda acceder al recurso
                        Al final de su sección crítica, el autor incrementa automáticamente la versión para
                lecturas simplemente Verifique que la versión
                        esté al comienzo de su sección crítica, la lectura lee atómicamente la versión actual
                        al final de su sección crítica, la lectura verifica que la versión no ha cambiado
                        De lo contrario, se produce una escritura simultánea y el la sección crítica se reinicia

9. Bloqueo optimista (2)

        Ejemplo (pseudocódigo)

writer(optLock) {
    lockExclusive(optLock.mutex) // begin critical section
    // modify the shared resource
    storeAtomic(optLock.version, optLock.version + 1)
    unlockExclusive(optLock.mutex) // end critical section
}

reader(optLock) {
    while(true) {
        current = loadAtomic(optLock.version); // begin critical section
        // read the shared resource
        if (current == loadAtomic(optLock.version)) // validate
            return; // end critical section
    }
}

10. Bloqueo optimista (3)

        ¿Por qué funciona el bloqueo optimista?
                Una lectura solo necesita ejecutar dos instrucciones de carga atómica,
                lo que es mucho más económico que adquirir un bloqueo compartido
                pero requiere pocas modificaciones; de lo contrario, la lectura debería reiniciarse con frecuencia.

        Los recursos compartidos del lector
                pueden modificarse cuando los lectores acceden a ellos
                . No podemos asumir que estamos leyendo desde un estado consistente.
                Las operaciones de lectura más complejas pueden requerir una validación intermedia adicional.

11. Más allá de la exclusión mutua

        En muchos casos, la exclusión mutua estricta no es necesaria en primer lugar
                , por ejemplo. Inserción paralela en la lista enlazada
                , no nos importa el orden de inserción
                , solo debemos asegurarnos de que todas las inserciones se reflejen en el estado final

        Esto se puede lograr de manera eficiente mediante el uso de operaciones atómicas (pseudocódigo)

threadSafePush(linkedList, element) {
    while (true) {
        head = loadAtomic(linkedList.head)
        element.next = head
        if (CAS(linkedList.head, head, element))
            break;
    }
}

12. Algoritmo sin bloqueo

        Los algoritmos o estructuras de datos que no dependen de bloqueos se denominan no bloqueantes,
                p. La función threadSafePush anterior
                La sincronización entre subprocesos a menudo se implementa mediante operaciones atómicas para
                permitir una implementación más eficiente de muchos algoritmos y estructuras de datos comunes

        Dichos algoritmos pueden proporcionar diferentes niveles de progreso que garantizan
                la libertad de espera: hay un límite superior en el número de pasos necesarios para completar cada operación
                        , que es difícil de alcanzar en la práctica

        sin bloqueo: si el programa se ejecuta durante el tiempo suficiente, al menos un subproceso progresa
                . A menudo se utiliza de manera informal (y técnicamente incorrecta) como sinónimo de no bloqueo.

13. Problema ABA (1)

        Las estructuras de datos sin bloqueo requieren una implementación cuidadosa.
                Ya no tenemos el lujo de las secciones críticas. Los
                subprocesos pueden realizar diferentes operaciones en la estructura de datos en paralelo (como inserciones y eliminaciones)
                . Una sola operación atómica que contiene estas operaciones compuestas se puede intercalar arbitrariamente.
                Esto puede provocar anomalías difíciles de depurar, como actualizaciones perdidas o problemas de ABA.

        A menudo, los problemas se pueden evitar asegurándose de que solo se realicen operaciones idénticas (como inserciones) en paralelo.

                P.ej. Inserte elementos en paralelo en el primer paso y elimínelos en paralelo en el segundo paso

14. Problema ABA (2)

        Considere la siguiente pila basada en una lista enlazada simple (pseudocódigo)

threadSafePush(stack, element) {
    while (true) {
        head = loadAtomic(stack.head)
        element.next = head
        if (CAS(stack.head, head, element))
            break;
    }
}

threadSafePop(stack) {
    while (true) {
        head = loadAtomic(stack.head)
        next = head.next
        if (CAS(stack.head, head, next))
            return head
    }
}

15, Problema ABA (3)

        Considere el siguiente estado inicial de la pila en la que dos subprocesos realizan algunas operaciones en paralelo

         Nuestra implementación permitirá realizar el intercalado de la siguiente manera

 16. El peligro del trompo (1)

        Se puede implementar un mutex "mejor" que requiere menos espacio y no usa
                llamadas al sistema usando operaciones atómicas:
                el mutex está representado por un solo número atómico
                0 cuando está desbloqueado y 1 cuando está bloqueado
                para bloquear el mutex, cámbielo a 1 solo si el el valor se cambia atómicamente a 0 usando CAS
                CAS se repite siempre que otro subproceso mantenga el mutex

function lock(mutexAddress) {
    while (CAS(mutexAddress, 0, 1) not sucessful) {
        <noop>
    }
}
function unlock(mutexAddress) {
    atomicStore(mutexAddress, 0)
}

17. El peligro del trompo (2)

        El uso de este bucle CAS como mutex, también conocido como bloqueo de giro, tiene varias desventajas:
        1. No es justo, es decir, no hay garantía de que el subproceso finalmente adquiera el bloqueo.

        2. El ciclo CAS consume ciclos de CPU (desperdiciando energía y recursos)

        3. Es fácil causar inversión de prioridad

                El programador del sistema operativo piensa que los subprocesos giratorios requieren mucho tiempo de CPU

                Los hilos giratorios en realidad no hacen ningún trabajo útil en absoluto.

                En el peor de los casos, el planificador toma el tiempo de CPU del subproceso que mantiene el bloqueo para dárselo al subproceso giratorio.

        4. El giro tarda más en girar, lo que empeora la situación
        3. Posible solución:
                Girar un número finito de veces (por ejemplo, cuántas iteraciones)
                Volver a "verdadero" mutuo si no se puede adquirir el bloqueo bloqueo de exclusión.
                De hecho, es la implementación habitual de los bloqueos de exclusión mutua (como los bloqueos sesgados, los bloqueos ligeros, los bloqueos pesados ​​en Java, los bloqueos sesgados parecen cancelarse en la última versión).

        A continuación, se muestra una implementación completa de un spinlock básico que utiliza C++ 11 atomics

struct spinlock {
  std::atomic<bool> lock_ = {0};

  void lock() noexcept {
    for (;;) {
      // 乐观地假设锁在第一次尝试时是空闲的
      if (!lock_.exchange(true, std::memory_order_acquire)) {
        return;
      }
      // 等待释放锁而不产生缓存未命中
      while (lock_.load(std::memory_order_relaxed)) {
        // 发出 X86 PAUSE 或 ARM YIELD 指令以减少超线程(hyper-threads)之间的争用
        __builtin_ia32_pause();
      }
    }
  }

  bool try_lock() noexcept {
    // 首先做一个简单的加载来检查锁是否空闲,以防止在有人这样做时不必要的缓存未命中 while(!try_lock())
    return !lock_.load(std::memory_order_relaxed) &&
           !lock_.exchange(true, std::memory_order_acquire);
  }

  void unlock() noexcept {
    lock_.store(false, std::memory_order_release);
  }
};

        El propósito de un spinlock es evitar que varios subprocesos accedan a una estructura de datos compartida al mismo tiempo. A diferencia de un mutex, el subproceso estará ocupado esperando y desperdiciando ciclos de CPU en lugar de ceder la CPU a otro subproceso. A menos que esté seguro de que comprende las consecuencias, no utilice spinlocks personalizados, sino variables atómicas proporcionadas en varios idiomas.

Supongo que te gusta

Origin blog.csdn.net/bashendixie5/article/details/127187791
Recomendado
Clasificación