c++ c++11 atomic memory-fences

c++ - ¿Cuál es la diferencia entre usar cercas explícitas y std:: atomic?



c++11 memory-fences (5)

Suponiendo que las cargas y los almacenes de punteros alineados son naturalmente atómicos en la plataforma de destino, ¿cuál es la diferencia entre esto:

// Case 1: Dumb pointer, manual fence int* ptr; // ... std::atomic_thread_fence(std::memory_order_release); ptr = new int(-4);

esta:

// Case 2: atomic var, automatic fence std::atomic<int*> ptr; // ... ptr.store(new int(-4), std::memory_order_release);

y esto:

// Case 3: atomic var, manual fence std::atomic<int*> ptr; // ... std::atomic_thread_fence(std::memory_order_release); ptr.store(new int(-4), std::memory_order_relaxed);

Tenía la impresión de que todos eran equivalentes, sin embargo, Relacy detecta una carrera de datos en el primer caso (solo):

struct test_relacy_behaviour : public rl::test_suite<test_relacy_behaviour, 2> { rl::var<std::string*> ptr; rl::var<int> data; void before() { ptr($) = nullptr; rl::atomic_thread_fence(rl::memory_order_seq_cst); } void thread(unsigned int id) { if (id == 0) { std::string* p = new std::string("Hello"); data($) = 42; rl::atomic_thread_fence(rl::memory_order_release); ptr($) = p; } else { std::string* p2 = ptr($); // <-- Test fails here after the first thread completely finishes executing (no contention) rl::atomic_thread_fence(rl::memory_order_acquire); RL_ASSERT(!p2 || *p2 == "Hello" && data($) == 42); } } void after() { delete ptr($); } };

Me puse en contacto con el autor de Relacy para averiguar si se trataba de un comportamiento esperado; él dice que de hecho hay una carrera de datos en mi caso de prueba. Sin embargo, estoy teniendo problemas para detectarla; ¿Alguien me puede indicar qué es la carrera? Lo más importante, ¿cuáles son las diferencias entre estos tres casos?

Actualización : se me ha ocurrido que Relacy puede quejarse de la atomicidad (o falta de ella) de la variable a la que se accede a través de los subprocesos ... después de todo, no sé que solo pretendo usar este código en plataformas. donde el acceso entero / puntero alineado es naturalmente atómico.

Otra actualización : Jeff Preshing ha escrito una excelente publicación en el blog que explica la diferencia entre las cercas explícitas y las integradas ("cercas" frente a "operaciones"). ¡Los casos 2 y 3 aparentemente no son equivalentes! (En ciertas circunstancias sutiles, de todos modos).


Aunque varias respuestas cubren partes y fragmentos de lo que es el problema potencial y / o proporcionan información útil, ninguna respuesta describe correctamente los problemas potenciales para los tres casos.

Para sincronizar las operaciones de memoria entre hilos, liberar y adquirir barreras se utilizan para especificar el pedido.
En el diagrama, las operaciones de memoria A en el hilo 1 no pueden moverse hacia abajo a través de la barrera de liberación (unidireccional) (independientemente de si se trata de una operación de liberación en un almacén atómico o una valla de liberación independiente seguida de un almacén atómico relajado). Por lo tanto, se garantiza que las operaciones de memoria A sucederán antes de la tienda atómica. Lo mismo ocurre con las operaciones de memoria B en el subproceso 2 que no pueden moverse hacia arriba a través de la barrera de adquisición; Por lo tanto, la carga atómica ocurre antes de las operaciones de memoria B.

El propio atomic ptr proporciona ordenación entre subprocesos basada en la garantía de que tiene un solo pedido de modificación. Tan pronto como el subproceso 2 ve un valor para ptr , se garantiza que el almacenamiento (y por lo tanto las operaciones de memoria A) sucedieron antes de la carga. Debido a que se garantiza que la carga sucederá antes de las operaciones de memoria B, las reglas para la transitividad dicen que las operaciones de memoria A suceden antes de B y la sincronización se completa.

Con eso, veamos tus 3 casos.

El caso 1 se rompe porque ptr , un tipo no atómico, se modifica en diferentes hilos. Ese es un ejemplo clásico de una carrera de datos y causa un comportamiento indefinido.

El caso 2 es correcto. Como argumento, la asignación de enteros con new se secuencia antes de la operación de liberación. Esto es equivalente a:

// Case 2: atomic var, automatic fence std::atomic<int*> ptr; // ... int *tmp = new int(-4); ptr.store(tmp, std::memory_order_release);

El caso 3 está roto , aunque de una manera sutil. El problema es que a pesar de que la asignación ptr está correctamente secuenciada después de la cerca independiente, la asignación de enteros ( new ) también se secuencia después de la cerca, causando una carrera de datos en la ubicación de la memoria de enteros.

El código es equivalente a:

// Case 3: atomic var, manual fence std::atomic<int*> ptr; // ... std::atomic_thread_fence(std::memory_order_release); int *tmp = new int(-4); ptr.store(tmp, std::memory_order_relaxed);

Si lo asigna al diagrama anterior, se supone que el new operador formará parte de las operaciones de memoria A. Siguiendo una secuencia debajo del cerco de liberación, las garantías de orden ya no se mantienen y la asignación de enteros puede reordenar con las operaciones de memoria B en el subproceso 2. Por lo tanto, un load() en el hilo 2 puede devolver basura o causar otro comportamiento indefinido.


Creo que el código tiene una carrera. El caso 1 y el caso 2 no son equivalentes.

29.8 [atomics.fences]

-2- Una guía de liberación A se sincroniza con una guía de adquisición B si existen operaciones atómicas X e Y , ambas operan en algún objeto atómico M , de modo que A se secuencia antes de X , X modifica M , Y se secuencia antes de B e Y lee el valor escrito por X o un valor escrito por cualquier efecto secundario en la secuencia de liberación hipotética que X encabezaría si fuera una operación de liberación.

En el caso 1, su valla de liberación no se sincroniza con su valla de adquisición porque ptr no es un objeto atómico y el almacenamiento y la carga en ptr no son operaciones atómicas.

El caso 2 y el caso 3 son equivalentes (en realidad, no del todo, consulte los comentarios y la respuesta de ptr ), porque ptr es un objeto atómico y la tienda es una operación atómica. (Los párrafos 3 y 4 de [atomic.fences] describen cómo una cerca se sincroniza con una operación atómica y viceversa.)

La semántica de las cercas se define solo con respecto a los objetos atómicos y las operaciones atómicas. Si la plataforma de destino y su implementación ofrecen garantías más sólidas (como tratar cualquier tipo de puntero como un objeto atómico), en el mejor de los casos, la definición está definida por la implementación.

NB, tanto para el caso 2 como para el caso 3, la operación de adquisición en ptr podría suceder antes de la tienda y, por lo tanto, leería la basura del atomic<int*> no inicializado atomic<int*> . El simple uso de las operaciones de adquisición y liberación (o cercas) no garantiza que el almacenamiento se realice antes de la carga, solo garantiza que si la carga lee el valor almacenado, el código se sincronice correctamente.


El respaldo de memoria de una variable atómica solo se puede utilizar para los contenidos de la atómica. Sin embargo, una variable simple, como ptr en el caso 1, es una historia diferente. Una vez que un compilador tiene derecho a escribir en él, puede escribir cualquier cosa en él, incluso el valor de un valor temporal cuando se queda sin registros.

Recuerda, tu ejemplo es patológicamente limpio. Dado un ejemplo un poco más complejo:

std::string* p = new std::string("Hello"); data($) = 42; rl::atomic_thread_fence(rl::memory_order_release); std::string* p2 = new std::string("Bye"); ptr($) = p;

es totalmente legal que el compilador elija reutilizar su puntero

std::string* p = new std::string("Hello"); data($) = 42; rl::atomic_thread_fence(rl::memory_order_release); ptr($) = new std::string("Bye"); std::string* p2 = ptr($); ptr($) = p;

¿Por qué lo haría? No sé, tal vez algún truco exótico para mantener una línea de caché o algo así. El punto es que, dado que ptr no es atómico en el caso 1, hay un caso de carrera entre la escritura en la línea ''ptr ($) = p'' y la lectura en ''std :: string * p2 = ptr ($)'', cediendo un comportamiento indefinido. En este caso de prueba simple, el compilador no puede optar por ejercer este derecho, y puede ser seguro, pero en casos más complicados, el compilador tiene el derecho de abusar de ptr como le plazca, y Relacy se da cuenta de esto.

Mi artículo favorito sobre el tema: software.intel.com/en-us/blogs/2013/01/06/…


La carrera en el primer ejemplo es entre la publicación del puntero y las cosas a las que apunta. La razón es que tiene la creación e inicialización del puntero después de la cerca (= en el mismo lado que la publicación del puntero):

int* ptr; //noop std::atomic_thread_fence(std::memory_order_release); //fence between noop and interesting stuff ptr = new int(-4); //object creation, initalization, and publication

Si asumimos que los accesos de la CPU a los punteros correctamente alineados son atómicos , el código se puede corregir escribiendo esto:

int* ptr; //noop int* newPtr = new int(-4); //object creation & initalization std::atomic_thread_fence(std::memory_order_release); //fence between initialization and publication ptr = newPtr; //publication

Tenga en cuenta que aunque esto puede funcionar bien en muchas máquinas, no existe ninguna garantía dentro del estándar C ++ sobre la atomicidad de la última línea. Así que mejor usa atomic<> variables atomic<> en primer lugar.


Varias referencias pertinentes:

Algunos de los anteriores pueden interesarle a usted y otros lectores.