¿Por qué volátil no se considera útil en la programación multiproceso C o C++?
Como se demuestra en esta respuesta que publiqué recientemente, parece que estoy confundido acerca de la utilidad (o la falta de ella) devolatile
contextos de programación multiproceso.
Tengo entendido que cada vez que una variable puede cambiarse fuera del flujo de control de un fragmento de código que accede a ella, esa variable debe declararse comovolatile
. Los manejadores de señales, los registros de E/S y las variables modificadas por otro subproceso constituyen situaciones de este tipo.
Entonces, si tiene un int global foo
, y foo
un hilo lo lee y otro lo configura atómicamente (probablemente usando una instrucción de máquina apropiada), el hilo de lectura ve esta situación de la misma manera que ve una variable modificada por un controlador de señal o modificado por una condición de hardware externo y, por lo tanto, foo
debe declararsevolatile
(o, para situaciones de subprocesos múltiples, accederse con carga protegida por memoria, lo que probablemente sea una mejor solución).
¿Cómo y dónde me equivoco?
El problema volatile
en un contexto multiproceso es que no proporciona todas las garantías que necesitamos. Tiene algunas propiedades que necesitamos, pero no todas, por lo que no podemos confiar volatile
solo en él .
Sin embargo, las primitivas que tendríamos que usar para las propiedades restantes también proporcionan las que sí volatile
lo hacen, por lo que efectivamente es innecesario.
Para accesos seguros para subprocesos a datos compartidos, necesitamos una garantía de que:
- la lectura/escritura realmente ocurre (que el compilador no simplemente almacenará el valor en un registro y pospondrá la actualización de la memoria principal hasta mucho más tarde)
- que no se produzca ningún reordenamiento. Supongamos que usamos una
volatile
variable como indicador para indicar si algunos datos están listos para ser leídos o no. En nuestro código, simplemente configuramos la bandera después de preparar los datos, para que todo se vea bien. Pero, ¿qué pasa si las instrucciones se reordenan de modo que la bandera se establezca primero ?
volatile
garantiza el primer punto. También garantiza que no se produzca ningún reordenamiento entre diferentes lecturas/escrituras volátiles . Todos volatile
los accesos a la memoria se producirán en el orden en que se especifican. Eso es todo lo que necesitamos para lo que volatile
está destinado: manipular registros de E/S o hardware mapeado en memoria, pero no nos ayuda en código multiproceso donde el volatile
objeto a menudo solo se usa para sincronizar el acceso a datos no volátiles. Esos accesos aún se pueden reordenar en relación con los volatile
.
La solución para evitar el reordenamiento es utilizar una barrera de memoria , que indica tanto al compilador como a la CPU que no se puede reordenar el acceso a la memoria a través de este punto . Colocar tales barreras alrededor de nuestro acceso a variables volátiles garantiza que incluso los accesos no volátiles no se reordenarán a través del volátil, lo que nos permitirá escribir código seguro para subprocesos.
Sin embargo, las barreras de memoria también garantizan que todas las lecturas/escrituras pendientes se ejecuten cuando se alcanza la barrera, por lo que efectivamente nos brinda todo lo que necesitamos por sí solo, haciéndolo volatile
innecesario. Podemos simplemente eliminar el volatile
calificador por completo.
Desde C++11, las variables atómicas ( std::atomic<T>
) nos dan todas las garantías relevantes.
También puede considerar esto en la documentación del kernel de Linux .
Los programadores de C a menudo han entendido que volátil significa que la variable podría cambiarse fuera del hilo de ejecución actual; como resultado, a veces se sienten tentados a usarlo en el código del kernel cuando se utilizan estructuras de datos compartidas. En otras palabras, se sabe que tratan los tipos volátiles como una especie de variable atómica simple, lo cual no es así. El uso de volátiles en el código del kernel casi nunca es correcto; este documento describe por qué.
El punto clave que hay que entender con respecto a lo volátil es que su propósito es suprimir la optimización, que casi nunca es lo que uno realmente quiere hacer. En el kernel, se deben proteger las estructuras de datos compartidas contra accesos concurrentes no deseados, lo cual es una tarea muy diferente. El proceso de protección contra la concurrencia no deseada también evitará casi todos los problemas relacionados con la optimización de una manera más eficiente.
Al igual que los volátiles, las primitivas del kernel que hacen que el acceso simultáneo a los datos sea seguro (spinlocks, mutexes, barreras de memoria, etc.) están diseñadas para evitar optimizaciones no deseadas. Si se usan correctamente, no será necesario utilizar volátiles también. Si volatile aún es necesario, es casi seguro que haya un error en alguna parte del código. En el código del kernel correctamente escrito, volatile sólo puede servir para ralentizar las cosas.
Considere un bloque típico de código del kernel:
spin_lock(&the_lock); do_something_on(&shared_data); do_something_else_with(&shared_data); spin_unlock(&the_lock);
Si todo el código sigue las reglas de bloqueo, el valor de share_data no puede cambiar inesperadamente mientras se mantiene the_lock. Cualquier otro código que quiera jugar con esos datos estará esperando en la cerradura. Las primitivas de spinlock actúan como barreras de memoria (están escritas explícitamente para hacerlo), lo que significa que el acceso a los datos no se optimizará a través de ellas. Entonces, el compilador podría pensar que sabe lo que habrá en share_data, pero la llamada spin_lock(), dado que actúa como una barrera de memoria, lo obligará a olvidar todo lo que sabe. No habrá problemas de optimización en los accesos a esos datos.
Si los datos compartidos se declararan volátiles, el bloqueo aún sería necesario. Pero también se impediría que el compilador optimice el acceso a datos_compartidos dentro de la sección crítica, cuando sabemos que nadie más puede trabajar con él. Mientras se mantiene el bloqueo, los datos_compartidos no son volátiles. Cuando se trata de datos compartidos, un bloqueo adecuado hace que los volátiles sean innecesarios y potencialmente dañinos.
La clase de almacenamiento volátil estaba originalmente pensada para registros de E/S mapeados en memoria. Dentro del kernel, los accesos a los registros también deben estar protegidos mediante bloqueos, pero tampoco se desea que el compilador "optimice" los accesos a los registros dentro de una sección crítica. Pero, dentro del kernel, los accesos a la memoria de E/S siempre se realizan a través de funciones de acceso; acceder a la memoria de E/S directamente a través de punteros está mal visto y no funciona en todas las arquitecturas. Esos descriptores de acceso están escritos para evitar optimizaciones no deseadas, por lo que, una vez más, volátil es innecesario.
Otra situación en la que uno podría verse tentado a utilizar volátil es cuando el procesador está ocupado esperando el valor de una variable. La forma correcta de realizar una espera ocupada es:
while (my_variable != what_i_want) cpu_relax();
La llamada cpu_relax() puede reducir el consumo de energía de la CPU o ceder el paso a un procesador gemelo con hiperproceso; también sirve como barrera de memoria, por lo que, una vez más, volátil es innecesario. Por supuesto, para empezar, la espera ocupada es generalmente un acto antisocial.
Todavía hay algunas situaciones raras en las que volátil tiene sentido en el kernel:
Las funciones de acceso mencionadas anteriormente pueden utilizar volátiles en arquitecturas donde funciona el acceso directo a la memoria de E/S. Esencialmente, cada llamada de acceso se convierte en una pequeña sección crítica por sí sola y garantiza que el acceso se realice según lo esperado por el programador.
El código ensamblador en línea que cambia la memoria, pero que no tiene otros efectos secundarios visibles, corre el riesgo de ser eliminado por GCC. Agregar la palabra clave volátil a las declaraciones asm evitará esta eliminación.
La variable jiffies es especial porque puede tener un valor diferente cada vez que se hace referencia a ella, pero se puede leer sin ningún bloqueo especial. Por lo tanto, los santiamén pueden ser volátiles, pero la adición de otras variables de este tipo está muy mal vista. Jiffies se considera una cuestión de "legado estúpido" (en palabras de Linus) a este respecto; arreglarlo sería más problemático de lo que vale la pena.
Los punteros a estructuras de datos en la memoria coherente que podrían ser modificados por dispositivos de E/S pueden, a veces, ser legítimamente volátiles. Un ejemplo de este tipo de situación es un búfer de anillo utilizado por un adaptador de red, donde ese adaptador cambia los punteros para indicar qué descriptores se han procesado.
Para la mayoría del código, no se aplica ninguna de las justificaciones anteriores para volátil. Como resultado, es probable que el uso de volátil se vea como un error y provocará un escrutinio adicional del código. Los desarrolladores que se sientan tentados a utilizar volátiles deberían dar un paso atrás y pensar en lo que realmente están tratando de lograr.
No creo que te equivoques: volátil es necesario para garantizar que el subproceso A verá cambiar el valor, si el valor se cambia por algo que no sea el subproceso A. Según tengo entendido, volátil es básicamente una forma de decirle al compilador "no almacene en caché esta variable en un registro, en su lugar asegúrese de leerla/escribirla siempre desde la memoria RAM en cada acceso".
La confusión se debe a que volátil no es suficiente para implementar varias cosas. En particular, los sistemas modernos utilizan múltiples niveles de almacenamiento en caché, las CPU multinúcleo modernas realizan algunas optimizaciones sofisticadas en tiempo de ejecución y los compiladores modernos realizan algunas optimizaciones sofisticadas en tiempo de compilación, y todo esto puede resultar en varios efectos secundarios que aparecen en una forma diferente. orden del orden que esperaría si solo mirara el código fuente.
Por lo tanto, volátil está bien, siempre y cuando tenga en cuenta que los cambios "observados" en la variable volátil pueden no ocurrir en el momento exacto que cree que ocurrirán. Específicamente, no intente utilizar variables volátiles como una forma de sincronizar u ordenar operaciones entre subprocesos, porque no funcionará de manera confiable.
Personalmente, mi uso principal (¿único?) de la bandera volátil es como un booleano "por favor, vete ahora". Si tengo un subproceso de trabajo que se repite continuamente, haré que verifique el valor booleano volátil en cada iteración del ciclo y salga si el valor booleano alguna vez es verdadero. Luego, el hilo principal puede limpiar de forma segura el hilo de trabajo configurando el valor booleano en verdadero y luego llamando a pthread_join() para esperar hasta que el hilo de trabajo desaparezca.