c++ - seccion - ¿Por qué el código que muta una variable compartida a través de hilos aparentemente NO sufre una condición de carrera?
race condition vulnerability (5)
Estoy usando Cygwin GCC y ejecuto este código:
#include <iostream>
#include <thread>
#include <vector>
using namespace std;
unsigned u = 0;
void foo()
{
u++;
}
int main()
{
vector<thread> threads;
for(int i = 0; i < 1000; i++) {
threads.push_back (thread (foo));
}
for (auto& t : threads) t.join();
cout << u << endl;
return 0;
}
Compilado con la línea:
g++ -Wall -fexceptions -g -std=c++14 -c main.cpp -o main.o
Imprime 1000, que es correcto. Sin embargo, esperaba un número menor debido a que los hilos sobrescribían un valor previamente incrementado. ¿Por qué este código no sufre de acceso mutuo?
Mi máquina de prueba tiene 4 núcleos, y no impongo restricciones al programa que conozco.
El problema persiste cuando se reemplaza el contenido del
foo
compartido con algo más complejo, por ej.
if (u % 3 == 0) {
u += 4;
} else {
u -= 1;
}
-
La respuesta probable a por qué la condición de carrera no se manifestó para ti, aunque existe, es que
foo()
es tan rápido, en comparación con el tiempo que lleva iniciar un hilo, que cada hilo termina antes de que el siguiente pueda comenzar . Pero... -
Incluso con su versión original, el resultado varía según el sistema: lo intenté a su manera en una Macbook (quad-core), y en diez ejecuciones, obtuve 1000 tres veces, 999 seis veces y 998 una vez. Entonces la carrera es algo rara, pero claramente presente.
-
Compiló con
''-g''
, que tiene una forma de hacer desaparecer los errores. Recopilé su código, aún sin cambios pero sin la''-g''
, y la carrera se hizo mucho más pronunciada: obtuve 1000 una vez, 999 tres veces, 998 dos veces, 997 dos veces, 996 una vez y 992 una vez. -
Re. la sugerencia de agregar un sueño, eso ayuda, pero (a) un tiempo de sueño fijo deja los hilos aún sesgados por la hora de inicio (sujeto a la resolución del temporizador), y (b) un sueño aleatorio los extiende cuando lo que queremos es acérquelos más juntos. En cambio, los codificaría para esperar una señal de inicio, para poder crearlos todos antes de dejar que se pongan a trabajar. Con esta versión (con o sin
''-g''
), obtengo resultados por todas partes, tan bajos como 974 y no superiores a 998:#include <iostream> #include <thread> #include <vector> using namespace std; unsigned u = 0; bool start = false; void foo() { while (!start) { std::this_thread::yield(); } u++; } int main() { vector<thread> threads; for(int i = 0; i < 1000; i++) { threads.push_back (thread (foo)); } start = true; for (auto& t : threads) t.join(); cout << u << endl; return 0; }
Creo que no es tanto si duermes antes o después de
u++
.
Es más bien que la operación
u++
traduce en código que, en comparación con la sobrecarga de los hilos de generación que llaman
foo
, se realiza muy rápidamente de manera que es poco probable que sea interceptado.
Sin embargo, si "prolonga" la operación
u++
, entonces la condición de carrera será mucho más probable:
void foo()
{
unsigned i = u;
for (int s=0;s<10000;s++);
u = i+1;
}
resultado:
694
Por cierto: también intenté
if (u % 2) {
u += 2;
} else {
u -= 1;
}
y me dio la mayoría de las veces
1997
, pero a veces
1995
.
Es importante comprender que una condición de carrera no garantiza que el código se ejecutará incorrectamente, simplemente que podría hacer algo, ya que es un comportamiento indefinido. Incluyendo correr como se esperaba.
Particularmente en las máquinas X86 y AMD64, las condiciones de carrera en algunos casos rara vez causan problemas, ya que muchas de las instrucciones son atómicas y las garantías de coherencia son muy altas. Estas garantías se ven algo reducidas en los sistemas multiprocesador donde se necesita el prefijo de bloqueo para que muchas instrucciones sean atómicas.
Si en su máquina el incremento es una operación atómica, es probable que esto se ejecute correctamente, aunque de acuerdo con el estándar del lenguaje, es Comportamiento indefinido.
Específicamente, espero que en este caso el código se esté compilando en una instrucción Atómica de recuperación y adición (ADD o XADD en el ensamblaje X86) que de hecho es atómica en sistemas de un solo procesador, sin embargo, en sistemas multiprocesador, no se garantiza que sea atómico y bloqueado. sería necesario para que así sea. Si está ejecutando en un sistema multiprocesador, habrá una ventana donde los hilos podrían interferir y producir resultados incorrectos.
Específicamente compilé su código para ensamblar usando
https://godbolt.org/
y compila
foo()
para:
foo():
add DWORD PTR u[rip], 1
ret
Esto significa que solo está ejecutando una instrucción de agregar que para un solo procesador será atómica (aunque, como se mencionó anteriormente, no lo es para un sistema multiprocesador).
Sufre de una condición de carrera.
Poner a
usleep(1000);
antes de
u++;
en
foo
y veo diferentes resultados (<1000) cada vez.
foo()
es tan corto que cada subproceso probablemente termina antes de que el siguiente se genere.
Si agrega un sueño por un tiempo aleatorio en
foo()
antes de
u++
, puede comenzar a ver lo que espera.