yangzai
Me pregunto si la desreferenciación de un puntero siempre se traducirá en una instrucción Cargar/Almacenar a nivel de máquina, independientemente de cuán optimizado sea el compilador.
Supongamos que tenemos dos subprocesos, uno (llamémoslo Tom) recibe la entrada del usuario y escribe un bool
variable. La variable es leída por otro (y este es Jerry) para decidir si continúa un bucle. Sabemos que un compilador optimizador puede almacenar la variable en un registro al compilar el bucle. Entonces, en tiempo de ejecución, Jerry puede leer un valor obsoleto que es diferente de lo que escribe Tom. Como resultado, debemos declarar la bool
variables como volatile
.
Sin embargo, si hacer referencia a un puntero siempre causará acceso a la memoria, entonces los dos subprocesos pueden usar un puntero para hacer referencia a la variable. En cada escritura, Tom almacenará el nuevo valor en la memoria eliminando la referencia del puntero y escribiendo en él. En cada lectura, Jerry realmente puede leer lo que Tom escribió al eliminar la referencia a ese mismo puntero. Esto parece mejor que el dependiente de la implementación volatile
Soy nuevo en la programación de subprocesos múltiples, por lo que esta idea puede parecer trivial e innecesaria. Pero tengo mucha curiosidad al respecto.
Jan Schultke
¿Desreferenciar un puntero siempre causará acceso a la memoria?
No, por ejemplo:
int five() {
int x = 5;
int *ptr = &x;
return *ptr;
}
Cualquier compilador de optimización cuerdo no emitirá un mov
de la memoria de pila aquí, pero algo así como:
five():
mov eax, 5
ret
Esto está permitido debido a la regla como si.
¿Cómo realizo la comunicación entre subprocesos a través de un bool*
¿entonces?
Esto es lo que std::atomic<bool>
es para. No debe comunicarse entre subprocesos utilizando objetos no atómicos, porque acceder a la misma ubicación de memoria a través de dos subprocesos de manera conflictiva1) es un comportamiento indefinido en C++. std::atomic
hace que sea seguro para subprocesos, volatile
no. Por ejemplo:
void thread(std::atomic<bool> &stop_signal) {
while (!stop_signal) {
do_stuff();
}
}
Técnicamenteesto no implica que cada carga de stop_signal
realmente sucederá. El compilador puede hacer un desenrollado de bucle parcial como:
void thread(std::atomic<bool> &stop_signal) {
// only possible if the compiler knows that do_stuff() doesn't modify stop_signal
while (!stop_signal) {
do_stuff();
do_stuff();
do_stuff();
do_stuff();
}
}
un atómico load()
se le permite observar valores obsoletos, por lo que el compilador puede suponer que cuatro load()
s todos leerían el mismo valor. Sólo algunas operaciones, como fetch_add()
están obligados a observar el valor más reciente. Incluso entonces, esta optimización podría ser posible.
En la práctica, las optimizaciones como estas no se implementan para std::atomic
en cualquier compilador, entonces std::atomic
es cuasi-volatile
. Lo mismo se aplica a C atomic_bool
y _Atomic
tipos en general.
1) Dos accesos a la memoria en la misma ubicación entran en conflicto si al menos uno de ellos está escribiendo, es decir, dos lecturas en la misma ubicación no entran en conflicto. Ver [intro.races]
Ver también
std::atomic
en cppreference- C Tipos atómicos en cppreference
- N4455 Ningún compilador sano optimizaría Atomics
- ¿Qué es exactamente la regla “como si”?
-
Probablemente sea útil observar que no necesariamente necesita un
std::atomic
para cada valor compartido entre subprocesos, siempre que haya alguno forma de sincronización relacionada con él. Por ejemplo, si utiliza unstd::mutex
para proteger elbool
valor (posiblemente junto con algunos otros), entonces la sincronización entre desbloquear el mutex en un subproceso y adquirir el mutex en el otro subproceso hará que el compilador se asegure de que se haga todo lo necesario, por ejemplo, para asegurarse de que los cachés del procesador sean coherentes, para asegurarse de que las lecturas/escrituras no se reordenen más allá de las barreras, etc.– Daniel Schepler
16 de junio a las 18:33
-
“acceder a la misma memoria a través de dos subprocesos al mismo tiempo es un comportamiento indefinido”, solo si se trata de acceso de escritura, ¿verdad? Leer está bien.
– Tomas Weller
17 de junio a las 10:09
-
@ThomasWeller sí, lo aclaré en la respuesta ahora.
-Jan Schultke
17 de junio a las 10:21
-
Vale la pena mencionar que puedes usar
std::memory_order_relaxed
cargas y almacenes parastop_signal
(¿Por qué establecer el indicador de parada usando `memory_order_seq_cst`, si lo marca con `memory_order_relaxed`?). A menudo, cuando las personas piensan que quieren evitaratomic
es porque piensanatomic
es más lento Pero conrelaxed
, no lo es, es solo una carga pura en el asm incluso en ISA débilmente ordenados como ARM, sin instrucciones de barrera adicionales. (En x86, la adquisición/liberación son “gratuitas”, al igual que las cargas seq_cst; no se necesitan instrucciones de barrera adicionales).– Peter Cordes
18 de junio a las 6:14
-
Además, dado que la pregunta sobre
volatile
– ¿Cuándo usar volátil con subprocesos múltiples? – nunca, pero funciona en la práctica en los compiladores actuales, y se usó históricamente (junto con asm en línea para ordenar la memoria) antes de que quedara obsoleto porstd::atomic<>
. Mi respuesta allí explica cómo/por qué funcionó y todavía funciona en la práctica en implementaciones reales, para las personas curiosas sobre qué “magia” std::atomic necesita usar. (No mucho, el hardware de la CPU hace el trabajo de mantener la coherencia del caché)– Peter Cordes
18 de junio a las 6:32
Algunas formas de usar el lvalue producido al desreferenciar un puntero no darán como resultado un acceso. Por ejemplo, dado int arr[5][4]; int *p;
la declaración p = *arr;
no desreferenciará ningún almacenamiento asociado con arr
pero simplemente hacen que el compilador reconozca que el valor l en la mitad derecha de la asignación es un int[4]
que decaerá en un int*
.
Fuera de tales circunstancias, el Estándar busca clasificar como Comportamiento Indefinido todas las situaciones en las que el Estándar pretende permitir que una implementación procese una operación de desreferenciación realizando un acceso o no, a su antojo, y su decisión afectaría de manera observable el comportamiento del programa.
Esta filosofía conduce a algunos casos de esquina bastante turbios en situaciones en las que un programa usa algo de almacenamiento para mantener una estructura de tipo T, y luego una estructura de tipo U, y luego una estructura de tipo T nuevamente, luego hace una copia de la T sin haber escrito todos los campos, y finalmente usa fwrite
para generar la copia completa de la T. Si el compilador sabe que cierto campo en la T original se escribió con un cierto valor, podría generar un código que almacene ese mismo valor en la copia, sin importar si el almacenamiento subyacente podría haber cambiado. Si a nada en el universo le importa lo que contienen los bytes asociados con ese campo en los datos que se procesaron a través de fwrite
, esto no debería representar un problema, y requerir que el programador se asegure de que todo el almacenamiento asociado con T se escriba utilizando ese tipo antes de que se copie como un tipo T haría necesario que tanto el programador como la computadora que está ejecutando el programa realicen un trabajo inútil adicional. El Estándar no tiene forma de describir el comportamiento del programa que permitiría que una implementación fallara de manera observable en eliminar la referencia de todos los campos de la T al copiarlo, sin caracterizar el programa como invocando un comportamiento indefinido.
Otro caso en el que *p no daría como resultado el acceso a la memoria es con el operador sizeof: sizeof(*p) simplemente determinará el tamaño del tipo estático al que apunta p. Tenga en cuenta que en C, con argumentos de longitud variable, sizeof(*p) en realidad puede requerir un acceso a la memoria, pero los VLA son una extensión del compilador en C++.
Cuando se trabaja con subprocesos múltiples, la claridad es buena. Así que voy a desglosar cada pieza.
“La desreferenciación de los punteros siempre provocará el acceso a la memoria”.
No. Considere la declaración de expresión (void)*p
. El *p
realiza indirección. De [expr.unary.op]:
El operador unario * realiza el direccionamiento indirecto: la expresión a la que se aplica será un puntero a un tipo de objeto o un puntero a un tipo de función y el resultado es un lvalue que se refiere al objeto o función al que apunta la expresión.
Así que el resultado es una referencia de lvalue. Eso, por sí solo, no es suficiente para causar una “lectura” de los datos señalados por la p. En el ejemplo anterior, descarto explícitamente el resultado, por lo que no hay razón para leer la memoria.
Por supuesto, uno podría argumentar que la memoria de p
es leído. Solo para ser pedante, señalaría que esa es una interpretación de la palabra. Sin embargo, un compilador optimizador puede ver que el lvalue apuntado por p
no es necesario aquí, por lo que en realidad no necesita leer/escribir el puntero en absoluto.
Ahora, ¿qué pasa en un entorno multihilo? La clave de esto es la relación “sucede antes” en [intro.multithread]. Es un lenguaje formal increíblemente seco, pero la idea básica es que el evento A ocurre antes que el evento B si A se secuencia antes que B (en un solo hilo), o si A entre subprocesos ocurre antes que B. Este último es el lenguaje elegante que los abogados hablan para una herramienta utilizada para capturar el comportamiento de las primitivas de sincronización, como mutexes y atómicos.
Si A no sucede antes de B y B no sucede antes de A, entonces los dos eventos no están ordenados entre sí. Esto es lo que sucede en dos subprocesos cuando no tiene ningún mutex para forzar un pedido. Si un evento escribe en una ubicación de memoria y el otro lee o escribe en esa dirección, el resultado es una carrera de datos. Y una carrera de datos es un comportamiento indefinido: obtienes lo que obtienes. La especificación no tiene cualquier cosa para decir acerca de lo que sucede cuando eso ocurre. No dice nada sobre si desencadena un acceso a la memoria o no… no dice absolutamente nada al respecto.
Como efecto de las normas codificadas en [intro.multithread]el compilador puede efectivamente optimizar su código como si un subproceso estuviera operando en completo aislamiento a menos que una primitiva de enhebrado (como un mutex o atómico) fuerza lo contrario. Esto incluye todas las elisiones habituales, como no leer de la memoria si no es necesario.
“Como resultado, deberíamos declarar la variable bool como volátil”. – Eso está completamente mal.
volatile
no hace que una variable atómica/hilo sea segura. Necesitasstd::atomic
,std::mutex
o similar.– Jesper Juhl
16 de junio a las 10:21
Respuesta corta: No, el compilador es libre de optimizar siguiendo la regla como si, que normalmente es libre de asumir que no hay subprocesos múltiples. tu uso de
volatile
eludirlo también es obsoleto. Desde C++11 y C11, los lenguajes vienen con modelos de memoria que incluyen subprocesos múltiples. Use las primitivas apropiadas tales comostd::atomic
para lidiar con esto– Homero512
16 de junio a las 10:23
Desreferenciar un puntero en dos subprocesos distintos, a menos que esas operaciones estén explícitamente sincronizadas (por ejemplo, a través de instalaciones como
std::atomic
,std::mutex
) da un comportamiento indefinido.volatile
no introduce sincronización. Si el comportamiento no está definido, un compilador es correcto, de acuerdo con el estándar, independientemente de lo que haga (omite las operaciones, de alguna manera las realiza con algún tipo de sincronización, bloquea la máquina host)– Pedro
16 de junio a las 10:43
@JesperJuhl: La oración que cita, “Como resultado, debemos declarar el
bool
variables comovolatile
”, afirmavolatile
es necesario. Su declaración, “volatile
no hace que una variable atómica/hilo sea segura”, afirmavolatile
No es suficiente. afirmandovolatile
no es suficiente no es una refutación a la afirmación de que es necesario.–Eric Postpischil
16 de junio a las 12:37
@Peter: Eso no es del todo correcto. Acceder al mismo objeto (ya sea a través de punteros o no) desde múltiples subprocesos está bien si y solo si todos los accesos son de lectura. No habrá carrera de datos a menos que se produzca alguna modificación semántica. El compilador no puede introducir carreras de datos, por ejemplo, con escrituras espurias, en el comportamiento de C++. “Como si” le permite hacerlo a un nivel más bajo de abstracción, pero solo en la medida en que mantenga las garantías del modelo de memoria de C++.
– Ben Voigt
16 jun a las 21:00