Quiero escribir código portátil (Intel, ARM, PowerPC ...) que resuelve una variante de un problema clásico:
Initially: X=Y=0
Thread A:
X=1
if(!Y){ do something }
Thread B:
Y=1
if(!X){ do something }
en el que el objetivo es evitar una situación en la que ambos hilos están haciendosomething
. (Está bien si ninguna de las dos cosas funciona; este no es un mecanismo de ejecutar exactamente una vez). Corríjame si ve algunos defectos en mi razonamiento a continuación.
Soy consciente de que puedo lograr el objetivo con memory_order_seq_cst
atomic store
sys de load
la siguiente manera:
std::atomic<int> x{0},y{0};
void thread_a(){
x.store(1);
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!x.load()) bar();
}
que logra el objetivo, porque debe haber un orden total único en los
{x.store(1), y.store(1), y.load(), x.load()}
eventos, que debe estar de acuerdo con el orden del programa "bordes"
x.store(1)
"en TO es antes"y.load()
y.store(1)
"en TO es antes"x.load()
y si foo()
fue llamado, entonces tenemos ventaja adicional:
y.load()
"lee el valor antes"y.store(1)
y si bar()
fue llamado, entonces tenemos ventaja adicional:
x.load()
"lee el valor antes"x.store(1)
y todos estos bordes combinados juntos formarían un ciclo:
x.store(1)
"en TO es antes" y.load()
"lee el valor antes" y.store(1)
"en TO es antes" x.load()
"lee el valor antes"x.store(true)
lo que viola el hecho de que las órdenes no tienen ciclos.
Intencionalmente uso términos no estándar "en TO is before" y "lee el valor antes" en lugar de términos estándar como happens-before
, porque quiero solicitar comentarios sobre la exactitud de mi suposición de que estos bordes realmente implican happens-before
relación, se pueden combinar en un solo gráfico, y el ciclo en dicho gráfico combinado está prohibido. No estoy seguro de eso. Lo que sé es que este código produce barreras correctas en Intel gcc & clang y en ARM gcc
Ahora, mi verdadero problema es un poco más complicado, porque no tengo control sobre "X": está oculto detrás de algunas macros, plantillas, etc. y podría ser más débil que seq_cst
Ni siquiera sé si "X" es una variable única, o algún otro concepto (por ejemplo, un semáforo o mutex ligero). Todo lo que sé es que tengo dos macros set()
y check()
eso check()
devuelve true
"después" de que haya llamado otro hilo set()
. (Se está también sabe que set
y check
son hilos de proceso seguro y no puede crear UB-carrera de datos).
Entonces, conceptualmente set()
es algo así como "X = 1" y check()
es como "X", pero no tengo acceso directo a los atómicos involucrados, si los hay.
void thread_a(){
set();
if(!y.load()) foo();
}
void thread_b(){
y.store(1);
if(!check()) bar();
}
Estoy preocupado, eso set()
podría implementarse internamente como x.store(1,std::memory_order_release)
y / ocheck()
podría ser x.load(std::memory_order_acquire)
. O hipotéticamente std::mutex
que un hilo se está desbloqueando y otro está try_lock
ing; en el estándar ISO std::mutex
solo se garantiza que tiene pedidos de adquisición y liberación, no seq_cst.
Si este es el caso, entonces check()
si el cuerpo puede ser "reordenado" antes y.store(true)
( Ver la respuesta de Alex donde demuestran que esto sucede en PowerPC ).
Esto sería realmente malo, ya que ahora esta secuencia de eventos es posible:
thread_b()
primero carga el valor anterior dex
(0
)thread_a()
ejecuta todo incluidofoo()
thread_b()
ejecuta todo incluidobar()
Entonces, ambos foo()
y bar()
me llamaron, lo que tuve que evitar. ¿Cuáles son mis opciones para evitar eso?
Opcion A
Intente forzar la barrera Tienda-Carga. Esto, en la práctica, se puede lograr std::atomic_thread_fence(std::memory_order_seq_cst);
, como explica Alex en una respuesta diferente, todos los compiladores probados emitieron una valla completa:
- x86_64: MFENCE
- PowerPC: hwsync
- Itanuim: mf
- ARMv7 / ARMv8: dmb ish
- MIPS64: sincronización
El problema con este enfoque es que no pude encontrar ninguna garantía en las reglas de C ++, que std::atomic_thread_fence(std::memory_order_seq_cst)
debe traducirse en una barrera de memoria completa. En realidad, el concepto de atomic_thread_fence
s en C ++ parece estar en un nivel diferente de abstracción que el concepto de ensamblaje de barreras de memoria y se ocupa más de cosas como "qué operación atómica se sincroniza con qué". ¿Hay alguna prueba teórica de que la siguiente implementación logre el objetivo?
void thread_a(){
set();
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!y.load()) foo();
}
void thread_b(){
y.store(true);
std::atomic_thread_fence(std::memory_order_seq_cst)
if(!check()) bar();
}
Opcion B
Use el control que tenemos sobre Y para lograr la sincronización, usando operaciones de lectura-modificación-escritura memory_order_acq_rel en Y:
void thread_a(){
set();
if(!y.fetch_add(0,std::memory_order_acq_rel)) foo();
}
void thread_b(){
y.exchange(1,std::memory_order_acq_rel);
if(!check()) bar();
}
La idea aquí es que los accesos a un solo atómico ( y
) deben formarse en un solo orden en el que todos los observadores estén de acuerdo, por lo que fetch_add
es anterior exchange
o viceversa.
Si fetch_add
es antes, exchange
entonces la parte "liberar" se fetch_add
sincroniza con la parte "adquirir" exchange
y, por lo tanto, todos los efectos secundarios set()
deben ser visibles para la ejecución del código check()
, por bar()
lo que no se llamará.
De lo contrario, exchange
es antes fetch_add
, luego fetch_add
verá 1
y no llamará foo()
. Entonces, es imposible llamar a ambos foo()
y bar()
. ¿Es correcto este razonamiento?
Opcion C
Use atómica ficticia para introducir "bordes" que eviten el desastre. Considere el siguiente enfoque:
void thread_a(){
std::atomic<int> dummy1{};
set();
dummy1.store(13);
if(!y.load()) foo();
}
void thread_b(){
std::atomic<int> dummy2{};
y.store(1);
dummy2.load();
if(!check()) bar();
}
Si crees que el problema aquí es que los atomic
s son locales, entonces imagínate moverlos a un alcance global, en el siguiente razonamiento no parece importarme, y escribí el código intencionalmente para exponer lo gracioso que es ese muñeco1 y dummy2 están completamente separados.
¿Por qué demonios esto podría funcionar? Bueno, debe haber un orden total único {dummy1.store(13), y.load(), y.store(1), dummy2.load()}
que debe ser coherente con los "bordes" del orden del programa:
dummy1.store(13)
"en TO es antes"y.load()
y.store(1)
"en TO es antes"dummy2.load()
(Con suerte, una seq_cst store + load forma el equivalente en C ++ de una barrera de memoria completa que incluye StoreLoad, como lo hacen en asm en ISA reales, incluso AArch64, donde no se requieren instrucciones de barrera separadas).
Ahora, tenemos dos casos a considerar: cualquiera y.store(1)
es antesy.load()
o después en el orden total.
Si y.store(1)
es antes, y.load()
entonces foo()
no se llamará y estamos a salvo.
Si y.load()
es antes y.store(1)
, luego combinándolo con los dos bordes que ya tenemos en orden de programa, deducimos que:
dummy1.store(13)
"en TO es antes"dummy2.load()
Ahora, dummy1.store(13)
es una operación de liberación, que libera los efectos de set()
, y dummy2.load()
es una operación de adquisición, por lo que check()
debería ver los efectos set()
y, por bar()
lo tanto , no se llamará y estamos a salvo.
¿Es correcto pensar aquí que check()
verá los resultados de set()
? ¿Puedo combinar los "bordes" de varios tipos ("orden del programa", también conocido como Secuenciado antes, "orden total", "antes del lanzamiento", "después de adquirir") de esa manera? Tengo serias dudas sobre esto: las reglas de C ++ parecen hablar de relaciones "sincronizadas con" entre la tienda y la carga en la misma ubicación; aquí no existe tal situación.
Tenga en cuenta que solo nos preocupa el caso en el que dumm1.store
se sabe (a través de otro razonamiento) que está antes dummy2.load
en el orden total seq_cst. Entonces, si hubieran estado accediendo a la misma variable, la carga habría visto el valor almacenado y sincronizado con él.
(El razonamiento de barrera de memoria / reordenamiento para implementaciones donde las cargas y almacenes atómicos se compilan en al menos barreras de memoria de 1 vía (y las operaciones seq_cst no pueden reordenarse: por ejemplo, una tienda seq_cst no puede pasar una carga seq_cst) es que cualquier carga / las tiendas después dummy2.load
definitivamente se vuelven visibles para otros hilos después y.store
. Y de manera similar para el otro hilo, ... antes y.load
).
Puedes jugar con mi implementación de las Opciones A, B, C en https://godbolt.org/z/u3dTa8
foo()
y que bar()
ambos sean llamados.
compare_exchange_*
para realizar una operación RMW en un bool atómico sin cambiar su valor (simplemente configure el esperado y nuevo en el mismo valor).
atomic<bool>
tiene exchange
y compare_exchange_weak
. Este último puede usarse para hacer un RMW ficticio (intentando) CAS (verdadero, verdadero) o falso, falso. O bien falla o reemplaza atómicamente el valor consigo mismo. (En x86-64 asm, ese truco lock cmpxchg16b
es cómo hacer cargas atómicas garantizadas de 16 bytes; ineficiente pero menos malo que tomar un bloqueo por separado.)
foo()
ni bar()
se llamará ni se llamará. No quería llevar a muchos elementos del código del "mundo real", para evitar el tipo de respuestas "crees que tienes el problema X pero tienes el problema Y". Pero, si uno realmente necesita saber cuál es el piso de fondo: set()
es realmente some_mutex_exit()
, check()
es try_enter_some_mutex()
, y
"hay algunos camareros", foo()
es "salir sin despertar a nadie", bar()
es "esperar al despertar" ... Pero, me niego a discuta este diseño aquí, no puedo cambiarlo realmente.
std::atomic_thread_fence(std::memory_order_seq_cst)
se compila a una barrera completa, pero dado que todo el concepto es un detalle de implementación que no encontrará cualquier mención de ello en el estándar. (Los modelos de memoria de la CPU generalmente se definen en términos de qué reiniciaciones están permitidas en relación con la coherencia secuencial. Por ejemplo, x86 es seq-cst + un almacenamiento intermedio de almacenamiento con reenvío)