c++ - multihilo - programacion concurrente y paralela
¿Por qué la volatilidad no se considera útil en la programación multiproceso C o C++? (9)
Como se demostró en esta respuesta que publiqué recientemente, parece confundirme acerca de la utilidad (o la falta de ella) de la volatile
en contextos de programación de subprocesos múltiples.
Entiendo esto: 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 como volatile
. Controladores de señal, registros de E / S y variables modificadas por otro hilo constituyen todas estas situaciones.
Entonces, si tiene un foo
global global, y foo
es leído por un hilo y configurado atómicamente por otro hilo (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 una señal controlador o modificado por una condición de hardware externo y, por lo tanto, foo
debe declararse volatile
(o, para situaciones de subprocesamiento múltiple, accedido con carga delimitada por memoria, que probablemente sea una mejor solución).
¿Cómo y dónde estoy equivocado?
De acuerdo con mi antiguo estándar C, "lo que constituye un acceso a un objeto que tiene un tipo calificado volátil está definido por la implementación" . Así que los escritores de compiladores de C podrían haber elegido tener "volátil" significa "acceso seguro a subprocesos en un entorno de procesos múltiples" . Pero no lo hicieron.
En su lugar, se agregaron las operaciones requeridas para hacer segura una hebra de sección crítica en un entorno de memoria compartida multiproceso de múltiples núcleos como nuevas características definidas por la implementación. Y, liberado del requisito de que "volátil" proporcionaría acceso atómico y ordenamiento de acceso en un entorno multiproceso, los autores del compilador priorizaron la reducción de código sobre la semántica histórica "volátil" dependiente de la implementación.
Esto significa que cosas como los semáforos "volátiles" alrededor de secciones críticas de códigos, que no funcionan en hardware nuevo con compiladores nuevos, pueden haber funcionado con viejos compiladores en hardware antiguo, y los ejemplos antiguos a veces no son incorrectos, simplemente antiguos.
El problema con la 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 únicamente en la volatile
.
Sin embargo, las primitivas que deberíamos usar para las propiedades restantes también proporcionan las que son volatile
, por lo que es efectivamente innecesario.
Para acceder a los hilos de seguridad de los datos compartidos, necesitamos una garantía de que:
- la lectura / escritura sucede realmente (que el compilador no solo almacenará el valor en un registro en su lugar y diferirá la actualización de la memoria principal hasta mucho más tarde)
- que no se realiza ningún reordenamiento Supongamos que usamos una variable
volatile
como indicador para indicar si algunos datos están listos para ser leídos o no. En nuestro código, simplemente configuramos el indicador después de preparar los datos, por lo que todo se ve bien. Pero, ¿qué pasa si las instrucciones se reordenan para que la bandera se establezca primero ?
volatile
garantiza el primer punto. También garantiza que no se realice ningún reordenamiento entre diferentes lecturas / escrituras volátiles . Todos volatile
accesos de memoria volatile
ocurrirán en el orden en que se especifican. Eso es todo lo que necesitamos para lo volatile
: manipular registros de E / S o hardware mapeado en memoria, pero no nos ayuda en el código multiproceso donde el objeto volatile
menudo solo se utiliza 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 usar una barrera de memoria , que indica tanto al compilador como a la CPU que no se puede reordenar el acceso a memoria a través de este punto . Situar tales barreras alrededor de nuestro acceso variable volátil asegura que incluso los accesos no volátiles no se reordenarán en el volátil, lo que nos permite escribir código seguro para subprocesos.
Sin embargo, las barreras de memoria también aseguran que todas las lecturas / escrituras pendientes se ejecuten cuando se alcanza la barrera, por lo que efectivamente nos da todo lo que necesitamos por sí mismo, haciendo que la volatile
innecesaria. Podemos eliminar el calificador volatile
completo.
Desde C ++ 11, las variables atómicas ( std::atomic<T>
) nos dan todas las garantías relevantes.
Esto es todo lo que "volátil" está haciendo: "Oye compilador, esta variable podría cambiar EN CUALQUIER MOMENTO (en cualquier tic de reloj) incluso si NO HAYA NINGUNA INSTRUCCIÓN LOCAL actuando sobre ella. NO coloque en caché este valor en un registro".
Eso es. Le dice al compilador que su valor es, bueno, volátil, este valor puede ser alterado en cualquier momento por la lógica externa (otro hilo, otro proceso, el Kernel, etc.). Existe más o menos únicamente para suprimir las optimizaciones del compilador que almacenarán en caché silenciosamente un valor en un registro que es inherentemente inseguro para la caché EVER.
Puede encontrar artículos como "Dr. Dobbs" que arrojan volatilidad como panacea para la programación de múltiples subprocesos. Su enfoque no está totalmente desprovisto de mérito, pero tiene el defecto fundamental de hacer que los usuarios de un objeto sean responsables de la seguridad de sus hilos, que tiende a tener los mismos problemas que otras violaciones de la encapsulación.
La pregunta frecuente comp.programming.threads tiene una explicación clásica de Dave Butenhof:
Q56: ¿Por qué no necesito declarar variables compartidas VOLATILE?
Sin embargo, me preocupan los casos en que tanto el compilador como la biblioteca de hilos cumplen con sus respectivas especificaciones. Un compilador de C conforme puede asignar globalmente alguna variable compartida (no volátil) a un registro que se guarda y restaura a medida que la CPU pasa de un hilo a otro. Cada hilo tendrá su propio valor privado para esta variable compartida, que no es lo que queremos de una variable compartida.
En cierto sentido, esto es cierto, si el compilador sabe lo suficiente sobre los respectivos ámbitos de la variable y las funciones pthread_cond_wait (o pthread_mutex_lock). En la práctica, la mayoría de los compiladores no intentarán conservar copias de registros de datos globales a través de una llamada a una función externa, porque es muy difícil saber si la rutina de alguna manera podría tener acceso a la dirección de los datos.
Así que sí, es cierto que un compilador que se ajusta estrictamente (pero muy agresivamente) a ANSI C podría no funcionar con múltiples hilos sin volátil. Pero alguien debería arreglarlo mejor. Porque cualquier SISTEMA (es decir, pragmáticamente, una combinación de kernel, bibliotecas y compilador de C) que no proporciona garantías de coherencia de memoria POSIX no cumple con el estándar POSIX. Período. El sistema NO PUEDE exigirle que utilice variables volátiles en las variables compartidas para un comportamiento correcto, porque POSIX solo requiere que las funciones de sincronización POSIX sean necesarias.
Entonces, si tu programa se rompe porque no usaste volátil, es un ERROR. Puede no ser un error en C, o un error en la biblioteca de hilos, o un error en el kernel. Pero es un error del SISTEMA, y uno o más de esos componentes tendrán que trabajar para solucionarlo.
No desea utilizar volátil porque, en cualquier sistema donde haga alguna diferencia, será mucho más caro que una variable no volátil adecuada. (ANSI C requiere "puntos de secuencia" para las variables volátiles en cada expresión, mientras que POSIX solo los requiere en las operaciones de sincronización: una aplicación de procesamiento intensivo computacional verá sustancialmente más actividad de memoria usando volátiles, y, después de todo, es la actividad de memoria que realmente te hace más lento.)
/ --- [Dave Butenhof] ----------------------- [[email protected]] --- /
| Digital Equipment Corporation 110 Spit Brook Rd ZKO2-3 / Q18 |
| 603.881.2218, FAX 603.881.0120 Nashua NH 03062-2698 |
----------------- [Mejor vida a través de la concurrencia] ---------------- /
El Sr. Butenhof cubre gran parte del mismo terreno en esta publicación de Usenet :
El uso de "volátil" no es suficiente para garantizar la visibilidad de la memoria adecuada o la sincronización entre los hilos. El uso de un mutex es suficiente y, excepto recurriendo a varias alternativas de códigos de máquina no portátiles, (o implicaciones más sutiles de las reglas de memoria POSIX que son mucho más difíciles de aplicar en general, como se explicó en mi publicación anterior), mutex es NECESARIO.
Por lo tanto, como explicó Bryan, el uso de volátiles no logra nada más que evitar que el compilador realice optimizaciones útiles y deseables, sin proporcionar ayuda alguna para hacer que el código sea "seguro para hilos". De nada, por supuesto, para declarar que todo lo que quiere es "volátil": después de todo, es un atributo de almacenamiento ANSI C legal. Simplemente no espere que resuelva ningún problema de sincronización de subprocesos.
Todo eso es igualmente aplicable a C ++.
No creo que estés equivocado: la volatilidad es necesaria para garantizar que el hilo A vea el cambio de valor, si el valor es cambiado por algo distinto al hilo A. Según tengo entendido, volátil es básicamente una forma de decirle al compilador "no almacena 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 la volatilidad no es suficiente para implementar una serie de cosas. En particular, los sistemas modernos usan múltiples niveles de almacenamiento en caché, las CPUs multi-core modernas hacen algunas optimizaciones sofisticadas en tiempo de ejecución, y los compiladores modernos hacen algunas optimizaciones sofisticadas en tiempo de compilación, y todo esto puede dar lugar a varios efectos secundarios que aparecen en una ordene desde el orden que esperaría si solo mirara el código fuente.
Tan volátil está bien, siempre y cuando tengas en cuenta que los cambios ''observados'' en la variable volátil pueden no ocurrir en el momento exacto en que piensas que lo harán. Específicamente, no intente utilizar variables volátiles como una forma de sincronizar u ordenar operaciones entre subprocesos, ya que no funcionará de manera confiable.
Personalmente, mi uso principal (¿único?) Para la bandera volátil es como un booleano "pleaseGoAwayNow". Si tengo un hilo de trabajo que se repite continuamente, haré que compruebe el booleano volátil en cada iteración del ciclo y salga si el valor booleano es verdadero. El hilo principal puede limpiar de forma segura el hilo de trabajo estableciendo booleano en verdadero y luego llamando a pthread_join () para esperar hasta que el hilo de trabajo se haya ido.
Para que sus datos sean consistentes en un entorno concurrente, necesita dos condiciones para aplicar:
1) Atomicidad, es decir, si leo o escribo algunos datos en la memoria, entonces esos datos se leen / escriben en una sola pasada y no se pueden interrumpir ni contestar debido, por ejemplo, a un cambio de contexto.
2) Consistencia, es decir, el orden de las operaciones de lectura / escritura debe verse como el mismo entre múltiples entornos concurrentes, ya sean hilos, máquinas, etc.
la volatilidad no se ajusta a ninguno de los anteriores - o más particularmente, el estándar c o c ++ en cuanto a qué tan volátil debe comportarse no incluye ninguno de los anteriores.
Es incluso peor en la práctica ya que algunos compiladores (como el compilador Itanium de Intel) intentan implementar algún elemento de comportamiento seguro de acceso concurrente (es decir, asegurando vallas de memoria), sin embargo no hay consistencia en las implementaciones del compilador y además el estándar no requiere esto de la implementación en primer lugar.
Marcar una variable como volátil solo significará que está forzando el vaciado y el valor de la memoria cada vez que, en muchos casos, ralentiza el código, ya que básicamente ha quemado el rendimiento de la caché.
c # y java AFAIK corrigen esto haciendo que volátil se adhiera a 1) y 2) sin embargo, no se puede decir lo mismo de los compiladores de c / c ++, así que básicamente lo hacen como mejor les parezca.
Para un debate más profundo (aunque no imparcial) sobre el tema, lea this
También puede considerar esto desde la Documentación del kernel de Linux .
Los programadores de C a menudo han considerado volátil que significa que la variable podría cambiarse fuera del hilo de ejecución actual; como resultado, a veces están tentados de usarlo en el código del núcleo 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 fácil, y no lo son. El uso de código volátil en kernel casi nunca es correcto; este documento describe por qué.
El punto clave para entender con respecto a la volatilidad es que su propósito es suprimir la optimización, que casi nunca es lo que uno realmente quiere hacer. En el kernel, uno debe proteger las estructuras de datos compartidas contra el acceso concurrente no deseado, que 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 la volatilidad, las primitivas del kernel que hacen que el acceso concurrente a los datos sean seguros (spinlocks, mutexes, barreras de memoria, etc.) están diseñadas para evitar una optimización no deseada. Si se usan correctamente, no será necesario usar también sustancias volátiles. Si todavía es necesario volátil, es casi seguro que hay un error en el código en alguna parte. En el código del kernel escrito correctamente, volátil solo puede servir para desacelerar las cosas.
Considere un bloque típico de código 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 shared_data no puede cambiar inesperadamente mientras se mantiene el_lock. Cualquier otro código que pueda querer jugar con esa información estará esperando en el bloqueo. Las primitivas de spinlock actúan como barreras de memoria, están explícitamente escritas para hacerlo, lo que significa que los accesos a los datos no se optimizarán a través de ellos. Entonces, el compilador podría pensar que sabe lo que será en datos compartidos, pero la llamada a spin_lock (), ya que actúa como una barrera de memoria, lo forzará a olvidar todo lo que sabe. No habrá problemas de optimización con los accesos a esos datos.
Si shared_data se declarara volátil, el bloqueo aún sería necesario. Pero el compilador tampoco podrá optimizar el acceso a shared_data dentro de la sección crítica, cuando sabemos que nadie más puede estar trabajando con él. Mientras se mantiene el bloqueo, shared_data no es volátil. Cuando se trata de datos compartidos, el bloqueo adecuado hace que la volatilidad sea innecesaria y potencialmente dañina.
La clase de almacenamiento volátil se diseñó originalmente para registros de E / S mapeados en memoria. Dentro del kernel, los accesos de registro, también, deberían estar protegidos por bloqueos, pero uno tampoco quiere que el compilador "optimice" los accesos de registro dentro de una sección crítica. Pero, dentro del kernel, los accesos de 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 accesadores están escritos para evitar una optimización no deseada, por lo que, una vez más, la volatilidad es innecesaria.
Otra situación en la que uno podría sentirse tentado a usar volátiles 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 potencia de la CPU o ceder a un procesador gemelo hyperthreaded; también pasa a servir como una barrera de memoria, por lo que, una vez más, la volatilidad es innecesaria. Por supuesto, la espera ocupada es generalmente un acto antisocial, para empezar.
Todavía hay algunas situaciones raras donde lo volátil tiene sentido en el kernel:
Las funciones de acceso mencionadas anteriormente pueden usar 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í misma y garantiza que el acceso se produzca como espera 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 de ASM evitará esta eliminación.
La variable de jiffies es especial ya que puede tener un valor diferente cada vez que se hace referencia a ella, pero se puede leer sin ningún bloqueo especial. Entonces los jiffies pueden ser volátiles, pero la adición de otras variables de este tipo está muy mal visto. Jiffies es considerado un asunto de "legado estúpido" (palabras de Linus) en este sentido; arreglarlo sería más problemas de lo que vale.
Los punteros a las estructuras de datos en la memoria coherente que pueden ser modificados por dispositivos de E / S pueden, a veces, ser legítimamente volátiles. Un búfer de anillo utilizado por un adaptador de red, donde ese adaptador cambia los punteros para indicar qué descripciones se han procesado, es un ejemplo de este tipo de situación.
Para la mayoría de los códigos, no se aplica ninguna de las justificaciones anteriores para volátiles. Como resultado, es probable que el uso de volátiles sea visto como un error y traerá un escrutinio adicional al código. Los desarrolladores que estén tentados a usar productos volátiles deberían dar un paso atrás y pensar en lo que realmente están tratando de lograr.
Tu comprensión es realmente incorrecta.
La propiedad, que tienen las variables volátiles, es "lee y escribe en esta variable como parte del comportamiento perceptible del programa". Eso significa que este programa funciona (dado el hardware apropiado):
int volatile* reg=IO_MAPPED_REGISTER_ADDRESS;
*reg=1; // turn the fuel on
*reg=2; // ignition
*reg=3; // release
int x=*reg; // fire missiles
El problema es que esta no es la propiedad que queremos de un hilo seguro.
Por ejemplo, un contador seguro de subprocesos sería justo (código similar al núcleo de Linux, no conoce el equivalente de c ++ 0x):
atomic_t counter;
...
atomic_inc(&counter);
Esto es atómico, sin una barrera de memoria. Debe agregarlos si es necesario. Agregar volátiles probablemente no ayudaría, porque no relacionaría el acceso al código cercano (por ejemplo, para agregar un elemento a la lista que el contador está contando). Ciertamente, no necesita ver el contador incrementado fuera de su programa, y las optimizaciones siguen siendo deseables, por ejemplo.
atomic_inc(&counter);
atomic_inc(&counter);
todavía se puede optimizar para
atomically {
counter+=2;
}
si el optimizador es lo suficientemente inteligente (no cambia la semántica del código).
volatile
es útil (aunque insuficiente) para implementar la construcción básica de un mutex de spinlock, pero una vez que tienes eso (o algo superior), no necesitas otro volatile
.
La forma típica de programación multiproceso no es proteger todas las variables compartidas a nivel de máquina, sino más bien introducir variables de protección que guían el flujo del programa. En lugar de volatile bool my_shared_flag;
Deberías
pthread_mutex_t flag_guard_mutex; // contains something volatile
bool my_shared_flag;
Esto no solo encapsula la "parte difícil", sino que es fundamentalmente necesaria: C no incluye las operaciones atómicas necesarias para implementar un mutex; solo tiene volatile
para ofrecer garantías adicionales sobre operaciones ordinarias .
Ahora tienes algo como esto:
pthread_mutex_lock( &flag_guard_mutex );
my_local_state = my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );
pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag
my_shared_flag = ! my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );
my_shared_flag
no necesita ser volátil, a pesar de ser descartable, porque
- Otro hilo tiene acceso a él.
- Lo que significa que se debe haber tomado una referencia en algún momento (con el operador
&
).- (O se hizo una referencia a una estructura contenedora)
-
pthread_mutex_lock
es una función de biblioteca. - Lo que significa que el compilador no puede decir si
pthread_mutex_lock
alguna manera adquiere esa referencia. - ¡Lo que significa que el compilador debe asumir que
pthread_mutex_lock
modifica la bandera compartida ! - Entonces la variable debe ser recargada desde la memoria.
volatile
, aunque significativo en este contexto, es extraño.