Mientras trabajaba en un video tutorial descargable en línea para el desarrollo de gráficos 3D y motores de juegos trabajando con OpenGL moderno. Usamos volatile
dentro de una de nuestras clases. El sitio web del tutorial se puede encontrar aquí y el video que trabaja con la volatile
palabra clave se encuentra en el Shader Engine
video de la serie 98. Estos trabajos no son míos, pero están acreditados Marek A. Krzeminski, MASc
y este es un extracto de la página de descarga del video.
Y si está suscrito a su sitio web y tiene acceso a sus videos dentro de este video, hace referencia a este artículo sobre el uso de Volatile
conmultithreading
programación.
volátil: el mejor amigo del programador multiproceso
Por Andrei Alexandrescu, 01 de febrero de 2001
La palabra clave volátil se diseñó para evitar las optimizaciones del compilador que podrían hacer que el código sea incorrecto en presencia de ciertos eventos asincrónicos.
No quiero estropear su estado de ánimo, pero esta columna aborda el temido tema de la programación multiproceso. Si, como dice la entrega anterior de Generic, la programación segura para excepciones es difícil, es un juego de niños en comparación con la programación multiproceso.
Los programas que utilizan varios subprocesos son notoriamente difíciles de escribir, demostrar que son correctos, depurar, mantener y domesticar en general. Los programas multiproceso incorrectos pueden ejecutarse durante años sin un problema técnico, solo para volverse inesperadamente fuera de control porque se ha cumplido alguna condición de tiempo crítica.
No hace falta decir que un programador que escribe código multiproceso necesita toda la ayuda que pueda obtener. Esta columna se centra en las condiciones de carrera, una fuente común de problemas en los programas multiproceso, y le brinda información y herramientas sobre cómo evitarlas y, sorprendentemente, hacer que el compilador trabaje duro para ayudarlo con eso.
Solo una pequeña palabra clave
Aunque tanto los estándares C como C ++ son notoriamente silenciosos cuando se trata de subprocesos, hacen una pequeña concesión al subproceso múltiple, en la forma de la palabra clave volátil.
Al igual que su contraparte más conocida const, volatile es un modificador de tipo. Está diseñado para usarse junto con variables a las que se accede y se modifican en diferentes subprocesos. Básicamente, sin volátiles, la escritura de programas multiproceso se vuelve imposible o el compilador desperdicia grandes oportunidades de optimización. Se necesita una explicación.
Considere el siguiente código:
class Gadget {
public:
void Wait() {
while (!flag_) {
Sleep(1000);
}
}
void Wakeup() {
flag_ = true;
}
...
private:
bool flag_;
};
El propósito de Gadget :: Wait anterior es verificar la variable de miembro flag_ cada segundo y regresar cuando esa variable haya sido establecida en verdadera por otro hilo. Al menos eso es lo que pretendía su programador, pero, lamentablemente, Wait es incorrecto.
Suponga que el compilador descubre que Sleep (1000) es una llamada a una biblioteca externa que no puede modificar la variable miembro flag_. Luego, el compilador concluye que puede almacenar en caché flag_ en un registro y usar ese registro en lugar de acceder a la memoria interna más lenta. Esta es una excelente optimización para código de un solo subproceso, pero en este caso, perjudica la corrección: después de llamar a Wait para algún objeto Gadget, aunque otro subproceso llame a Wakeup, Wait se repetirá para siempre. Esto se debe a que el cambio de flag_ no se reflejará en el registro que almacena en caché flag_. La optimización es demasiado ... optimista.
El almacenamiento en caché de variables en registros es una optimización muy valiosa que se aplica la mayor parte del tiempo, por lo que sería una pena desperdiciarla. C y C ++ le dan la oportunidad de deshabilitar explícitamente dicho almacenamiento en caché. Si usa el modificador volátil en una variable, el compilador no almacenará en caché esa variable en los registros; cada acceso alcanzará la ubicación de memoria real de esa variable. Entonces, todo lo que tienes que hacer para que el combo Wait / Wakeup de Gadget funcione es calificar flag_ apropiadamente:
class Gadget {
public:
... as above ...
private:
volatile bool flag_;
};
La mayoría de las explicaciones de la justificación y el uso de volatile se detienen aquí y le aconsejan que califique los tipos primitivos que usa en múltiples subprocesos. Sin embargo, hay mucho más que puede hacer con volatile, porque es parte del maravilloso sistema de tipos de C ++.
Uso de volátiles con tipos definidos por el usuario
Puede calificar de forma volátil no solo los tipos primitivos, sino también los tipos definidos por el usuario. En ese caso, volatile modifica el tipo de forma similar a const. (También puede aplicar const y volatile al mismo tipo simultáneamente).
A diferencia de const, volatile discrimina entre tipos primitivos y tipos definidos por el usuario. Es decir, a diferencia de las clases, los tipos primitivos aún admiten todas sus operaciones (suma, multiplicación, asignación, etc.) cuando están calificados como volátiles. Por ejemplo, puede asignar un int no volátil a un int volátil, pero no puede asignar un objeto no volátil a un objeto volátil.
Ilustremos cómo funciona volatile en tipos definidos por el usuario en un ejemplo.
class Gadget {
public:
void Foo() volatile;
void Bar();
...
private:
String name_;
int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;
Si cree que lo volátil no es tan útil con los objetos, prepárese para una sorpresa.
volatileGadget.Foo();
regularGadget.Foo();
volatileGadget.Bar();
La conversión de un tipo no calificado a su contraparte volátil es trivial. Sin embargo, al igual que con const, no puede regresar de volátil a no calificado. Debes usar un yeso:
Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar();
Una clase calificada como volátil da acceso solo a un subconjunto de su interfaz, un subconjunto que está bajo el control del implementador de la clase. Los usuarios pueden obtener acceso completo a la interfaz de ese tipo solo mediante el uso de const_cast. Además, al igual que la constness, la volatilidad se propaga de la clase a sus miembros (por ejemplo, volatileGadget.name_ y volatileGadget.state_ son variables volátiles).
volátiles, secciones críticas y condiciones de carrera
El dispositivo de sincronización más simple y más utilizado en programas multiproceso es el mutex. Un mutex expone las primitivas Adquirir y Liberar. Una vez que llame a Acquire en algún hilo, cualquier otro hilo que llame a Acquire se bloqueará. Más tarde, cuando ese hilo llame a Release, se liberará precisamente un hilo bloqueado en una llamada Acquire. En otras palabras, para un mutex dado, solo un subproceso puede obtener tiempo de procesador entre una llamada a Adquirir y una llamada a Liberar. El código de ejecución entre una llamada a Adquirir y una llamada a Liberar se denomina sección crítica. (La terminología de Windows es un poco confusa porque llama al mutex en sí mismo una sección crítica, mientras que "mutex" es en realidad un mutex entre procesos. Hubiera sido bueno si se llamaran subproceso mutex y proceso mutex).
Los mutex se utilizan para proteger los datos contra las condiciones de carrera. Por definición, una condición de carrera ocurre cuando el efecto de más subprocesos en los datos depende de cómo se programan los subprocesos. Las condiciones de carrera aparecen cuando dos o más subprocesos compiten por usar los mismos datos. Debido a que los subprocesos pueden interrumpirse entre sí en momentos arbitrarios, los datos pueden corromperse o malinterpretarse. En consecuencia, los cambios y, a veces, los accesos a los datos deben protegerse cuidadosamente con secciones críticas. En la programación orientada a objetos, esto generalmente significa que almacena un mutex en una clase como una variable miembro y lo usa cada vez que accede al estado de esa clase.
Los programadores de multiproceso experimentados pueden haber bostezado leyendo los dos párrafos anteriores, pero su propósito es proporcionar un entrenamiento intelectual, porque ahora lo vincularemos con la conexión volátil. Hacemos esto trazando un paralelo entre el mundo de los tipos de C ++ y el mundo de la semántica de subprocesos.
- Fuera de una sección crítica, cualquier hilo puede interrumpir cualquier otro en cualquier momento; no hay control, por lo que las variables accesibles desde múltiples hilos son volátiles. Esto está en consonancia con la intención original de volatile: la de evitar que el compilador almacene en caché sin saberlo los valores utilizados por varios subprocesos a la vez.
- Dentro de una sección crítica definida por un mutex, solo un hilo tiene acceso. En consecuencia, dentro de una sección crítica, el código de ejecución tiene semántica de un solo subproceso. La variable controlada ya no es volátil; puede eliminar el calificador volátil.
En resumen, los datos compartidos entre subprocesos son conceptualmente volátiles fuera de una sección crítica y no volátiles dentro de una sección crítica.
Entras en una sección crítica bloqueando un mutex. Elimina el calificador volátil de un tipo aplicando un const_cast. Si logramos unir estas dos operaciones, creamos una conexión entre el sistema de tipos de C ++ y la semántica de subprocesos de una aplicación. Podemos hacer que el compilador compruebe las condiciones de carrera por nosotros.
LockingPtr
Necesitamos una herramienta que recopile una adquisición de mutex y un const_cast. Desarrollemos una plantilla de clase LockingPtr que inicializa con un objeto volátil obj y un mutex mtx. Durante su vida útil, LockingPtr mantiene mtx adquirido. Además, LockingPtr ofrece acceso al obj despojado de volátiles. El acceso se ofrece en forma de puntero inteligente, a través de operador-> y operador *. El const_cast se realiza dentro de LockingPtr. La conversión es semánticamente válida porque LockingPtr mantiene el mutex adquirido durante toda su vida.
Primero, definamos el esqueleto de una clase Mutex con la que LockingPtr funcionará:
class Mutex {
public:
void Acquire();
void Release();
...
};
Para usar LockingPtr, implemente Mutex usando las estructuras de datos nativas y las funciones primitivas de su sistema operativo.
LockingPtr tiene una plantilla con el tipo de variable controlada. Por ejemplo, si quieres controlar un widget, utilizas un LockingPtr que inicializas con una variable de tipo Widget volátil.
La definición de LockingPtr es muy simple. LockingPtr implementa un puntero inteligente poco sofisticado. Se centra únicamente en recopilar un const_cast y una sección crítica.
template <typename T>
class LockingPtr {
public:
LockingPtr(volatile T& obj, Mutex& mtx)
: pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {
mtx.Lock();
}
~LockingPtr() {
pMtx_->Unlock();
}
T& operator*() {
return *pObj_;
}
T* operator->() {
return pObj_;
}
private:
T* pObj_;
Mutex* pMtx_;
LockingPtr(const LockingPtr&);
LockingPtr& operator=(const LockingPtr&);
};
A pesar de su simplicidad, LockingPtr es una ayuda muy útil para escribir código multiproceso correcto. Debe definir los objetos que se comparten entre subprocesos como volátiles y nunca usar const_cast con ellos; siempre use objetos automáticos LockingPtr. Ilustremos esto con un ejemplo.
Supongamos que tiene dos subprocesos que comparten un objeto vectorial:
class SyncBuf {
public:
void Thread1();
void Thread2();
private:
typedef vector<char> BufT;
volatile BufT buffer_;
Mutex mtx_;
};
Dentro de una función de hilo, simplemente usa un LockingPtr para obtener acceso controlado a la variable de miembro buffer_:
void SyncBuf::Thread1() {
LockingPtr<BufT> lpBuf(buffer_, mtx_);
BufT::iterator i = lpBuf->begin();
for (; i != lpBuf->end(); ++i) {
... use *i ...
}
}
El código es muy fácil de escribir y comprender; siempre que necesite usar buffer_, debe crear un LockingPtr que apunte a él. Una vez que hagas eso, tendrás acceso a toda la interfaz de vector.
Lo bueno es que si comete un error, el compilador lo señalará:
void SyncBuf::Thread2() {
BufT::iterator i = buffer_.begin();
for ( ; i != lpBuf->end(); ++i ) {
... use *i ...
}
}
No puede acceder a ninguna función de buffer_ hasta que aplique un const_cast o use LockingPtr. La diferencia es que LockingPtr ofrece una forma ordenada de aplicar const_cast a variables volátiles.
LockingPtr es muy expresivo. Si solo necesita llamar a una función, puede crear un objeto LockingPtr temporal sin nombre y usarlo directamente:
unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}
Volver a Tipos primitivos
Vimos lo bien que protege los objetos volátiles contra el acceso incontrolado y cómo LockingPtr proporciona una forma simple y efectiva de escribir código seguro para subprocesos. Volvamos ahora a los tipos primitivos, que son tratados de manera diferente por volátiles.
Consideremos un ejemplo en el que varios subprocesos comparten una variable de tipo int.
class Counter {
public:
...
void Increment() { ++ctr_; }
void Decrement() { —ctr_; }
private:
int ctr_;
};
Si se van a llamar Incremento y Decremento desde diferentes hilos, el fragmento de arriba tiene errores. Primero, ctr_ debe ser volátil. En segundo lugar, incluso una operación aparentemente atómica como ++ ctr_ es en realidad una operación de tres etapas. La memoria en sí misma no tiene capacidades aritméticas. Al incrementar una variable, el procesador:
- Lee esa variable en un registro
- Incrementa el valor en el registro
- Escribe el resultado en la memoria.
Esta operación de tres pasos se denomina RMW (lectura-modificación-escritura). Durante la parte de modificación de una operación RMW, la mayoría de los procesadores liberan el bus de memoria para dar acceso a la memoria a otros procesadores.
Si en ese momento otro procesador realiza una operación RMW sobre la misma variable, tenemos una condición de carrera: la segunda escritura sobrescribe el efecto de la primera.
Para evitar eso, puede confiar, nuevamente, en LockingPtr:
class Counter {
public:
...
void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
volatile int ctr_;
Mutex mtx_;
};
Ahora el código es correcto, pero su calidad es inferior en comparación con el código de SyncBuf. ¿Por qué? Porque con Counter, el compilador no le advertirá si accede por error a ctr_ directamente (sin bloquearlo). El compilador compila ++ ctr_ si ctr_ es volátil, aunque el código generado es simplemente incorrecto. El compilador ya no es tu aliado y solo tu atención puede ayudarte a evitar las condiciones de carrera.
¿Qué deberías hacer entonces? Simplemente encapsule los datos primitivos que usa en estructuras de nivel superior y use volátiles con esas estructuras. Paradójicamente, es peor usar volatile directamente con incorporados, a pesar del hecho de que inicialmente esta era la intención de uso de volatile.
Funciones de miembro volátiles
Hasta ahora, hemos tenido clases que agregan miembros de datos volátiles; ahora pensemos en diseñar clases que a su vez serán parte de objetos más grandes y compartidas entre subprocesos. Aquí es donde las funciones de miembro volátiles pueden ser de gran ayuda.
Al diseñar su clase, califica de forma volátil solo aquellas funciones miembro que son seguras para subprocesos. Debe asumir que el código externo llamará a las funciones volátiles desde cualquier código en cualquier momento. No olvide: volátil equivale a código multiproceso gratuito y sin sección crítica; no volátil equivale al escenario de un solo subproceso o dentro de una sección crítica.
Por ejemplo, usted define un widget de clase que implementa una operación en dos variantes: una segura para subprocesos y otra rápida y desprotegida.
class Widget {
public:
void Operation() volatile;
void Operation();
...
private:
Mutex mtx_;
};
Observe el uso de la sobrecarga. Ahora, el usuario de Widget puede invocar Operation usando una sintaxis uniforme, ya sea para objetos volátiles y obtener seguridad de subprocesos, o para objetos regulares y obtener velocidad. El usuario debe tener cuidado al definir los objetos Widget compartidos como volátiles.
Cuando se implementa una función miembro volátil, la primera operación suele ser bloquearla con LockingPtr. Luego, el trabajo se realiza utilizando el hermano no volátil:
void Widget::Operation() volatile {
LockingPtr<Widget> lpThis(*this, mtx_);
lpThis->Operation();
}
Resumen
Al escribir programas multiproceso, puede utilizar volatile a su favor. Debes ceñirte a las siguientes reglas:
- Defina todos los objetos compartidos como volátiles.
- No use volatile directamente con tipos primitivos.
- Al definir clases compartidas, utilice funciones miembro volátiles para expresar la seguridad de los subprocesos.
Si hace esto, y si usa el componente genérico simple LockingPtr, puede escribir código seguro para subprocesos y preocuparse mucho menos por las condiciones de carrera, porque el compilador se preocupará por usted y señalará diligentemente los puntos en los que está equivocado.
Un par de proyectos en los que he estado involucrado usan volatile y LockingPtr con gran efecto. El código es limpio y comprensible. Recuerdo un par de puntos muertos, pero prefiero los puntos muertos a las condiciones de carrera porque son mucho más fáciles de depurar. Prácticamente no hubo problemas relacionados con las condiciones de carrera. Pero entonces nunca se sabe.
Agradecimientos
Muchas gracias a James Kanze y Sorin Jianu que ayudaron con ideas perspicaces.
Andrei Alexandrescu es Gerente de Desarrollo en RealNetworks Inc. (www.realnetworks.com), con sede en Seattle, WA, y autor del aclamado libro Modern C ++ Design. Puede ser contactado en www.moderncppdesign.com. Andrei también es uno de los instructores destacados del Seminario C ++ (www.gotw.ca/cpp_seminar).
Este artículo puede estar un poco anticuado, pero da una buena idea de un uso excelente del uso del modificador volátil en el uso de la programación multiproceso para ayudar a mantener los eventos asíncronos mientras el compilador verifica las condiciones de carrera por nosotros. Es posible que esto no responda directamente a la pregunta original de los OP sobre la creación de una barrera de memoria, pero elijo publicar esto como una respuesta para otros como una excelente referencia para un buen uso de volátiles cuando se trabaja con aplicaciones multiproceso.