El programa de subprocesos múltiples se atascó en modo optimizado pero se ejecuta normalmente en -O0

68

Escribí un simple programa multiproceso de la siguiente manera:

static bool finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Se comporta normalmente en modo de depuración en Visual Studio o -O0en gc c e imprime el resultado después de 1segundos. Pero se atascó y no imprime nada en modo Release o -O1 -O2 -O3.

sz ppeter
fuente
Los comentarios no son para discusión extendida; Esta conversación se ha movido al chat .
Samuel Liew

Respuestas:

100

Dos hilos, que acceden a una variable no atómica, no protegida, son UB. Esto concierne finished. Podrías hacer finishedde tipo std::atomic<bool>para arreglar esto.

Mi solución:

#include <iostream>
#include <future>
#include <atomic>

static std::atomic<bool> finished = false;

int func()
{
    size_t i = 0;
    while (!finished)
        ++i;
    return i;
}

int main()
{
    auto result=std::async(std::launch::async, func);
    std::this_thread::sleep_for(std::chrono::seconds(1));
    finished=true;
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Salida:

result =1023045342
main thread id=140147660588864

Demo en vivo en coliru


Alguien puede pensar 'Es un bool- probablemente un poco. ¿Cómo puede ser esto no atómico? (Lo hice cuando comencé a usar subprocesos múltiples).

Pero tenga en cuenta que la falta de rasgaduras no es lo único que std::atomicle da. También hace que el acceso simultáneo de lectura + escritura desde múltiples hilos esté bien definido, evitando que el compilador suponga que releer la variable siempre verá el mismo valor.

Hacer un booldescuidado, no atómico puede causar problemas adicionales:

  • El compilador podría decidir optimizar la variable en un registro o incluso CSE múltiples accesos en uno y levantar una carga de un bucle.
  • La variable puede almacenarse en caché para un núcleo de CPU. (En la vida real, las CPU tienen cachés coherentes . Esto no es un problema real, pero el estándar C ++ es lo suficientemente flexible como para cubrir implementaciones hipotéticas de C ++ en memoria compartida no coherente donde funcionaría atomic<bool>con memory_order_relaxedalmacenamiento / carga, pero donde volatileno funcionaría. volátil para esto sería UB, a pesar de que funciona en la práctica en implementaciones reales de C ++).

Para evitar que esto suceda, se debe indicar explícitamente al compilador que no lo haga.


Estoy un poco sorprendido por la evolución de la discusión sobre la posible relación de volatileeste tema. Por lo tanto, me gustaría gastar mis dos centavos:

Scheff
fuente
44
func()Eché un vistazo y pensé "Podría optimizar eso" El optimizador no se preocupa por los hilos en absoluto, y detectará el bucle infinito, y felizmente lo convertirá en un "tiempo (Verdadero)" Si miramos a Dios. .org / z / Tl44iN podemos ver esto. Si está terminado True, vuelve. Si no, entra en un salto incondicional a sí mismo (un bucle infinito) en la etiqueta.L5
Baldrickk
2
@val: básicamente no hay razón para abusar volatileen C ++ 11 porque puedes obtener un asm idéntico con atomic<T>y std::memory_order_relaxed. Sin embargo, funciona en hardware real: los cachés son coherentes, por lo que una instrucción de carga no puede seguir leyendo un valor obsoleto una vez que una tienda en otro núcleo se compromete a almacenar en caché allí. (MESI)
Peter Cordes
55
Sin embargo, @PeterCordes Using volatilesigue siendo UB. Realmente nunca debe suponer algo que es definitivamente y claramente UB es seguro solo porque no puede pensar en una forma en que podría salir mal y funcionó cuando lo probó. Eso ha quemado a la gente una y otra vez.
David Schwartz
2
@Damon Mutexes tienen semántica de lanzamiento / adquisición. El compilador no puede optimizar la lectura si un mutex se bloqueó antes, por lo que se protege finishedcon un std::mutextrabajo (sin volatileo atomic). De hecho, puede reemplazar todos los elementos atómicos con un valor "simple" + esquema mutex; aún funcionaría y sería más lento. atomic<T>se le permite usar un mutex interno; solo atomic_flagse garantiza sin bloqueo.
Erlkoenig
42

La respuesta de Scheff describe cómo arreglar su código. Pensé que agregaría un poco de información sobre lo que realmente está sucediendo en este caso.

Compilé tu código en godbolt usando el nivel de optimización 1 ( -O1). Su función se compila así:

func():
  cmp BYTE PTR finished[rip], 0
  jne .L4
.L5:
  jmp .L5
.L4:
  mov eax, 0
  ret

¿Entonces, Que esta pasando aquí? Primero, tenemos una comparación:cmp BYTE PTR finished[rip], 0 esto verifica si finishedes falso o no.

Si es no falsa (también conocido como verdadera) debemos salir del bucle en la primera ejecución. Esto logra jne .L4que j umps cuando n ot e qual a la etiqueta.L4 cuando el valor de i( 0) se almacena en un registro para su uso posterior y se devuelve la función.

Si se Sin embargo, es falso, pasamos a

.L5:
  jmp .L5

Este es un salto incondicional, para etiquetar .L5que resulta ser el comando de salto en sí.

En otras palabras, el hilo se coloca en un bucle ocupado infinito.

Entonces, ¿por qué ha sucedido esto?

En lo que respecta al optimizador, los hilos están fuera de su alcance. Se supone que otros hilos no leen o escriben variables simultáneamente (porque eso sería una carrera de datos UB). Debe decirle que no puede optimizar los accesos de distancia. Aquí es donde entra la respuesta de Scheff. No me molestaré en repetirlo.

Debido a que no se le dice al optimizador que la finishedvariable puede cambiar potencialmente durante la ejecución de la función, ve quefinished la función en sí no la modifica y asume que es constante.

El código optimizado proporciona las dos rutas de código que resultarán de ingresar a la función con un valor bool constante; o ejecuta el bucle infinitamente, o el bucle nunca se ejecuta.

en -O0el compilador (como se esperaba) no optimiza el cuerpo del bucle y la comparación:

func():
  push rbp
  mov rbp, rsp
  mov QWORD PTR [rbp-8], 0
.L148:
  movzx eax, BYTE PTR finished[rip]
  test al, al
  jne .L147
  add QWORD PTR [rbp-8], 1
  jmp .L148
.L147:
  mov rax, QWORD PTR [rbp-8]
  pop rbp
  ret

por lo tanto, la función, cuando no se optimiza, funciona, la falta de atomicidad aquí no suele ser un problema, porque el código y el tipo de datos son simples. Probablemente lo peor con lo que podríamos encontrarnos aquí es un valor de iuno a lo que debería ser.

Un sistema más complejo con estructuras de datos es mucho más probable que produzca datos corruptos o una ejecución incorrecta.

Baldrickk
fuente
3
C ++ 11 hace que los hilos y un modelo de memoria con hilos sean parte del lenguaje en sí. Esto significa que los compiladores no pueden inventar escrituras ni siquiera para atomicvariables en el código que no escribe esas variables. por ejemplo if (cond) foo=1;, no se puede transformar en asm, foo = cond ? 1 : foo;porque esa carga + almacenamiento (no un RMW atómico) podría pisar una escritura desde otro hilo. Los compiladores ya estaban evitando cosas así porque querían ser útiles para escribir programas de subprocesos múltiples, pero C ++ 11 hizo oficial que los compiladores no debían romper el código donde escriben 2 hilos a[1]ya[2]
Peter Cordes
2
Pero sí, aparte de eso exageración acerca de cómo los compiladores no son conscientes de las discusiones en absoluto , su respuesta es correcta. Data-race UB es lo que permite elevar cargas de variables no atómicas, incluidas las globales, y las otras optimizaciones agresivas que queremos para el código de un solo subproceso. Programación MCU: la optimización de C ++ O2 se rompe mientras el bucle en la electrónica. SE es mi versión de esta explicación.
Peter Cordes
1
@PeterCordes: una ventaja de que Java use un GC es que la memoria para objetos no se reciclará sin una barrera de memoria global interpuesta entre el uso antiguo y el nuevo, lo que significa que cualquier núcleo que examine un objeto siempre verá algún valor que tenga celebrado en algún momento después de que la referencia se publicó por primera vez. Si bien las barreras de memoria global pueden ser muy costosas si se usan con frecuencia, pueden reducir en gran medida la necesidad de barreras de memoria en otros lugares, incluso cuando se usan con moderación.
supercat
1
Sí, sabía que eso era lo que intentabas decir, pero no creo que tu redacción al 100% signifique eso. Decir que el optimizador "los ignora por completo". no está del todo bien: es bien sabido que ignorar el enhebrado cuando se optimiza puede involucrar cosas como cargar / modificar un byte en el almacén de palabras / palabras, lo que en la práctica ha causado errores en los que el acceso de un hilo a un char o bitfield pasos en un escribir a un miembro de estructura adyacente. Vea lwn.net/Articles/478657 para la historia completa, y cómo solo el modelo de memoria C11 / C ++ 11 hace que tal optimización sea ilegal, no solo no deseada en la práctica.
Peter Cordes
1
No, eso es bueno ... Gracias @PeterCordes. Agradezco la mejora.
Baldrickk
5

En aras de la integridad en la curva de aprendizaje; Debe evitar el uso de variables globales. Hiciste un buen trabajo al hacerlo estático, por lo que será local para la unidad de traducción.

Aquí hay un ejemplo:

class ST {
public:
    int func()
    {
        size_t i = 0;
        while (!finished)
            ++i;
        return i;
    }
    void setFinished(bool val)
    {
        finished = val;
    }
private:
    std::atomic<bool> finished = false;
};

int main()
{
    ST st;
    auto result=std::async(std::launch::async, &ST::func, std::ref(st));
    std::this_thread::sleep_for(std::chrono::seconds(1));
    st.setFinished(true);
    std::cout<<"result ="<<result.get();
    std::cout<<"\nmain thread id="<<std::this_thread::get_id()<<std::endl;
}

Live on wandbox

Olvido
fuente
1
También podría declarar finishedcomo staticdentro del bloque de funciones. Todavía se inicializará solo una vez, y si se inicializa a una constante, esto no requiere bloqueo.
Davislor el
Los accesos a finishedtambién podrían usar std::memory_order_relaxedcarga y tiendas más baratas ; no se requiere ordenar wrt. otras variables en cualquier hilo. Sin staticembargo, no estoy seguro de que la sugerencia de @ Davislor tenga sentido; Si tuviera múltiples hilos de conteo de giros, no necesariamente querría detenerlos a todos con la misma bandera. Sin finishedembargo, desea escribir la inicialización de una manera que se compile solo para la inicialización, no en una tienda atómica. (Como lo está haciendo con la finished = false;sintaxis predeterminada del inicializador C ++ 17. Godbolt.org/z/EjoKgq ).
Peter Cordes
@PeterCordes Poner la bandera en un objeto permite que haya más de uno, para diferentes grupos de subprocesos, como usted dice. Sin embargo, el diseño original tenía una sola bandera para todos los hilos.
Davislor