sincronizar sincronizacion programacion multihilos metodos metodo hilos fuente ejemplos ejemplo codigo c++ multithreading mutex volatile memory-barriers

c++ - sincronizacion - sincronizar metodo java



Sincronización de hilos 101 (6)

Anteriormente, he escrito un código multiproceso muy simple, y siempre he sido consciente de que en cualquier momento podría haber un cambio de contexto justo en el medio de lo que estoy haciendo, por lo que siempre he protegido el acceso a las variables compartidas a través de una clase de sección CC que entra en la sección crítica de construcción y la deja en destrucción. Sé que esto es bastante agresivo y entro y salgo de las secciones críticas con bastante frecuencia y, a veces, con ingenio (p. Ej., Al inicio de una función cuando podría colocar la sección CCritical dentro de un bloque de código más estricto) pero mi código no falla y funciona lo suficientemente rápido .

En el trabajo, mi código multiproceso debe ser más estricto, solo bloqueo / sincronización en el nivel más bajo necesario.

En el trabajo intentaba depurar un código multiproceso, y encontré esto:

EnterCriticalSection(&m_Crit4); m_bSomeVariable = true; LeaveCriticalSection(&m_Crit4);

Ahora, m_bSomeVariable es un Win32 BOOL (no volátil), que por lo que sé se define como un int, y en x86 leer y escribir estos valores es una sola instrucción, y dado que los cambios de contexto ocurren en un límite de instrucción, entonces no hay Necesidad de sincronizar esta operación con una sección crítica.

Investigué un poco más en línea para ver si esta operación no necesitaba sincronización y se me ocurrieron dos escenarios:

  1. La CPU implementa la ejecución fuera de orden o el segundo subproceso se ejecuta en un núcleo diferente y el valor actualizado no se escribe en la RAM para que el otro núcleo la vea; y
  2. El int no está alineado con 4 bytes.

Creo que el número 1 se puede resolver usando la palabra clave "volátil". En VS2005 y versiones posteriores, el compilador de C ++ rodea el acceso a esta variable utilizando barreras de memoria, lo que garantiza que la variable siempre se escriba / lea por completo en la memoria principal del sistema antes de usarla.

Número 2 No puedo verificar, no sé por qué la alineación de bytes haría una diferencia. No conozco el conjunto de instrucciones x86, pero ¿es necesario dar a mov una dirección alineada de 4 bytes? Si no es así, ¿necesitas usar una combinación de instrucciones? Eso introduciría el problema.

Asi que...

PREGUNTA 1: ¿Usar la palabra clave "volátil" (implícitamente usar barreras de memoria e insinuar al compilador que no optimice este código) exime a un programador de la necesidad de sincronizar una variable de 4 bytes / 8 bytes en x86 / x64 entre lectura / escribir operaciones?

PREGUNTA 2: ¿Existe el requisito explícito de que la variable esté alineada con 4 bytes / 8 bytes?

Investigué un poco más nuestro código y las variables definidas en la clase:

class CExample { private: CRITICAL_SECTION m_Crit1; // Protects variable a CRITICAL_SECTION m_Crit2; // Protects variable b CRITICAL_SECTION m_Crit3; // Protects variable c CRITICAL_SECTION m_Crit4; // Protects variable d // ... };

Ahora, para mí esto parece excesivo. Pensé que las secciones críticas sincronizaban los hilos entre un proceso, así que si tienes uno, puedes ingresarlo y ningún otro hilo en ese proceso puede ejecutarse. No hay necesidad de una sección crítica para cada variable que quiera proteger, si está en una sección crítica, entonces nada más puede interrumpirlo.

Creo que lo único que puede cambiar las variables desde fuera de una sección crítica es si el proceso comparte una página de memoria con otro proceso (¿puede hacerlo?) Y el otro proceso comienza a cambiar los valores. Mutexes también ayudaría aquí, los mutex nombrados se comparten entre procesos, o solo procesos del mismo nombre?

PREGUNTA 3: ¿Es correcto mi análisis de las secciones críticas, y este código debería reescribirse para usar mutexes? He echado un vistazo a otros objetos de sincronización (semáforos y spinlocks), ¿son más adecuados aquí?

PREGUNTA 4: ¿Dónde se adaptan mejor las secciones críticas / mutexes / semáforos / spinlocks? Es decir, a qué problema de sincronización se deben aplicar. ¿Existe una gran penalización de rendimiento por elegir una sobre la otra?

Y mientras estamos en ello, leí que los spinlocks no deberían usarse en un entorno de multiproceso de un solo núcleo, solo en un entorno de múltiples hilos de múltiples núcleos. Entonces, PREGUNTA 5: ¿Está esto mal o, si no, por qué está bien?

Gracias de antemano por cualquier respuesta :)


Q1: Usando la palabra clave "volátil"

En VS2005 y versiones posteriores, el compilador de C ++ rodea el acceso a esta variable utilizando barreras de memoria, lo que garantiza que la variable siempre se escriba / lea por completo en la memoria principal del sistema antes de usarla.

Exactamente. Si no está creando un código portátil, Visual Studio lo implementa exactamente de esta manera. Si desea ser portátil, sus opciones están actualmente "limitadas". Hasta C ++ 0x, no hay una forma portátil de especificar operaciones atómicas con orden de lectura / escritura garantizada y necesita implementar soluciones por plataforma. Dicho esto, boost ya hizo el trabajo sucio por ti, y puedes usar sus primitivos atómicos .

P2: ¿La variable debe estar alineada con 4 bytes / 8 bytes?

Si los mantienes alineados, estás a salvo. Si no lo hace, las reglas son complicadas (líneas de caché, ...), por lo tanto, la forma más segura es mantenerlas alineadas, ya que esto es fácil de lograr.

P3: ¿Debería reescribirse este código para utilizar mutexes?

La sección crítica es un mutex ligero. A menos que necesite sincronizar entre procesos, use secciones críticas.

P4: ¿Dónde se adaptan mejor las secciones críticas / mutexes / semáforos / spinlocks?

Las secciones críticas pueden incluso hacerte esperas .

P5: Los giros no deben usarse en un solo núcleo

El bloqueo giratorio utiliza el hecho de que mientras la CPU en espera está girando, otra CPU puede liberar el bloqueo. Esto no puede suceder con una sola CPU, por lo tanto, es solo una pérdida de tiempo allí. En los bloqueos de giro de múltiples CPU puede ser una buena idea, pero depende de la frecuencia con la que la espera de giro tenga éxito. La idea de esperar un poco es mucho más rápida que hacer un cambio de contexto una y otra vez, por lo tanto, si es probable que la espera sea breve, es mejor esperar.


1) No es volátil, simplemente dice que se debe volver a cargar el valor de la memoria cada vez que TODAVÍA sea posible que se actualice a la mitad.

Edición: 2) Windows proporciona algunas funciones atómicas. Busque las funciones "Enclavadas" .

Los comentarios me llevaron a leer un poco más. Si leyó la Guía de programación del sistema Intel, puede ver que las lecturas y escrituras alineadas son atómicas.

8.1.1 Operaciones atómicas garantizadas El procesador Intel486 (y los procesadores más nuevos desde entonces) garantiza que las siguientes operaciones básicas de memoria siempre se realizarán atómicamente:
• Leer o escribir un byte
• Leer o escribir una palabra alineada en un límite de 16 bits
• Leer o escribir una palabra doble alineada en un límite de 32 bits
El procesador Pentium (y los procesadores más nuevos desde entonces) garantiza que las siguientes operaciones de memoria adicional siempre se llevarán a cabo atómicamente:
• Lectura o escritura de una quadword alineada en un límite de 64 bits
• Accesos de 16 bits a ubicaciones de memoria no almacenadas en un bus de datos de 32 bits.
Los procesadores de la familia P6 (y los procesadores más nuevos desde entonces) garantizan que la siguiente operación de memoria adicional siempre se llevará a cabo atómicamente:
• Accesos no alineados de 16, 32 y 64 bits a la memoria caché que caben dentro de una línea de caché
No se garantiza que los accesos a la memoria en caché que se dividen en anchos de bus, líneas de caché y límites de páginas sean atómicos por Intel Core 2 Duo, Intel Atom, Intel Core Duo, Pentium M, Pentium 4, Intel Xeon, P6, Pentium , y procesadores Intel486. Los procesadores Intel Core 2 Duo, Intel Atom, Intel Core Duo, Pentium M, Pentium 4, Intel Xeon y P6 proporcionan señales de control de bus que permiten a los subsistemas de memoria externa realizar accesos divididos atómicos; sin embargo, los accesos de datos no alineados afectarán seriamente el rendimiento del procesador y deben evitarse. Se puede implementar una instrucción x87 o una instrucción SSE que acceda a datos más grandes que un quadword utilizando múltiples accesos a la memoria. Si tal instrucción se almacena en la memoria, algunos de los accesos pueden completarse (escribir en la memoria), mientras que otra causa que la operación falle por razones arquitectónicas (por ejemplo, debido a una entrada de la tabla de páginas que está marcada como "no presente"). En este caso, los efectos de los accesos completados pueden ser visibles para el software a pesar de que la instrucción general causó una falla. Si la invalidación de TLB se ha retrasado (consulte la Sección 4.10.3.4), dichos errores de página pueden ocurrir incluso si todos los accesos son a la misma página.

Entonces, básicamente, sí, si realiza una lectura / escritura de 8 bits desde cualquier dirección, una lectura / escritura de 16 bits desde una dirección alineada de 16 bits, etc., está obteniendo operaciones atómicas. También es interesante notar que puede hacer lecturas / escrituras de memoria no alineadas dentro de una línea de caché en una máquina moderna. Sin embargo, las reglas parecen bastante complejas, así que no confiaría en ellas si fuera usted. Saludos a los comentaristas que es una buena experiencia de aprendizaje para mí que uno :)

3) Una sección crítica intentará girar el bloqueo para su bloqueo unas cuantas veces y luego bloqueará un mutex. Spin Locking puede absorber el poder de la CPU sin hacer nada y un mutex puede tardar un tiempo en hacerlo. Las secciones críticas son una buena opción si no puede usar las funciones interbloqueadas.

4) Hay penalizaciones de rendimiento por elegir una sobre otra. Es una gran pregunta para pasar por los beneficios de todo aquí. La ayuda de MSDN tiene mucha buena información sobre cada uno de estos. Les sugiero que los lean.

5) Puede usar un bloqueo de giro en un entorno de un solo subproceso, por lo general no es necesario, ya que la gestión de subprocesos significa que no puede tener 2 procesadores que accedan a los mismos datos simultáneamente. Simplemente no es posible.


1: El volátil en sí mismo es prácticamente inútil para el multihilo. Garantiza que la lectura / escritura se ejecutará, en lugar de almacenar el valor en un registro, y garantiza que la lectura / escritura no se reordenará con respecto a otras lecturas / escrituras volatile . Pero aún puede ser reordenado con respecto a los no volátiles, que es básicamente el 99.9% de su código. Microsoft ha redefinido la volatile para envolver también todos los accesos en barreras de memoria, pero no se garantiza que este sea el caso en general. Se romperá silenciosamente en cualquier compilador que defina la volatile como lo hace el estándar. (El código se compilará y ejecutará, ya no será seguro para subprocesos)

Aparte de eso, las lecturas / escrituras en objetos de tamaño entero son atómicas en x86 siempre que el objeto esté bien alineado. (Sin embargo, no tiene garantía de cuándo se producirá la escritura. El compilador y la CPU pueden reordenar, por lo que es atómico, pero no seguro para subprocesos)

2: Sí, el objeto debe estar alineado para que la lectura / escritura sea atómica.

3: En realidad no. Solo un hilo puede ejecutar código dentro de una sección crítica dada a la vez. Otros hilos aún pueden ejecutar otro código. Así que puedes tener cuatro variables, cada una protegida por una sección crítica diferente. Si todos compartieran la misma sección crítica, no podría manipular el objeto 1 mientras manipulas el objeto 2, que es ineficiente y restringe el paralelismo más de lo necesario. Si están protegidos por diferentes secciones críticas, no podemos manipular el mismo objeto simultáneamente.

4: Los rizos raramente son una buena idea. Son útiles si esperas que un subproceso tenga que esperar solo un tiempo muy corto antes de poder adquirir el bloqueo, y no necesitas una latencia mínima. Evita el cambio de contexto del sistema operativo, que es una operación relativamente lenta. En su lugar, el hilo simplemente se asienta en un bucle que sondea constantemente una variable. Por lo tanto, un mayor uso de la CPU (el núcleo no se libera para ejecutar otro subproceso mientras espera el spinlock), pero el subproceso podrá continuar tan pronto como se libere el bloqueo.

En cuanto a los demás, las características de rendimiento son prácticamente las mismas: simplemente use la que mejor se adapte a sus necesidades. Normalmente, las secciones críticas son más convenientes para proteger las variables compartidas, y se pueden usar las exclusiones mutuas para establecer una "bandera" que permita que otros subprocesos continúen.

En cuanto a no usar los spinlocks en un entorno de un solo núcleo, recuerde que el spinlock no produce realmente. El hilo A que espera en un spinlock no se pone en espera, lo que permite que el sistema operativo programe el hilo B para que se ejecute. Pero como A está esperando en este spinlock, algún otro hilo tendrá que liberar ese bloqueo. Si solo tiene un solo núcleo, ese otro hilo solo podrá ejecutarse cuando A se apague. Con un sistema operativo sano, eso sucederá tarde o temprano de todos modos como parte del cambio de contexto regular. Pero como sabemos que A no podrá obtener el bloqueo hasta que B haya tenido un tiempo para ejecutar y liberar el bloqueo, estaríamos mejor si A solo cediera de inmediato, fue puesto en una cola de espera por el SO. y se reinicia cuando B ha liberado el bloqueo. Y eso es lo que hacen todos los otros tipos de bloqueo. Un spinlock todavía funcionará en un entorno de un solo núcleo (suponiendo un sistema operativo con multitarea preventiva), simplemente será muy ineficiente.


Lo volátil no implica barreras de memoria.

Solo significa que formará parte del estado percibido del modelo de memoria. La implicación de esto es que el compilador no puede optimizar la variable, ni puede realizar operaciones en la variable solo en los registros de la CPU (en realidad se cargará y almacenará en la memoria).

Como no hay barreras de memoria implícitas, el compilador puede reordenar las instrucciones a voluntad. La única garantía es que el orden en el cual las diferentes variables volátiles son leídas / escritas será el mismo que en el código:

void test() { volatile int a; volatile int b; int c; c = 1; a = 5; b = 3; }

Con el código anterior (suponiendo que c no está optimizado), la actualización a c puede ocurrir antes o después de las actualizaciones a y b , lo que proporciona 3 resultados posibles. Se garantiza que las actualizaciones a y b se realizarán en orden. c puede ser optimizado fácilmente por cualquier compilador. Con suficiente información, el compilador puede incluso optimizar a y b (si se puede probar que ningún otro subproceso lee las variables y que no están vinculados a una matriz de hardware (por lo tanto, en este caso, pueden eliminarse). Tenga en cuenta que el estándar no requiere un comportamiento específico, sino más bien un estado perceptible con la regla " as-if .


No uses volatile. No tiene prácticamente nada que ver con la seguridad de los hilos. Vea here para el low-down.

La asignación a BOOL no necesita ninguna primitiva de sincronización. Funcionará bien sin ningún esfuerzo especial de su parte.

Si desea establecer la variable y luego asegurarse de que otro subproceso ve el nuevo valor, debe establecer algún tipo de comunicación entre los dos subprocesos. Solo el bloqueo inmediatamente antes de la asignación no logra nada porque la otra hebra podría haber desaparecido antes de que adquiriera el bloqueo.

Una última palabra de precaución: es extremadamente difícil hacer hilos. Los programadores más experimentados tienden a sentirse menos cómodos con el uso de subprocesos, que deben hacer que suenen las campanas de alarma para cualquier persona que no tenga experiencia con su uso. Le sugiero que utilice algunas primitivas de alto nivel para implementar la concurrencia en su aplicación. Pasar estructuras de datos inmutables a través de colas sincronizadas es un enfoque que reduce sustancialmente el peligro.


Preguntas 3: Las sugerencias y las exclusión mutuas funcionan de manera muy parecida. Un mutex de Win32 es un objeto del kernel, por lo que se puede compartir entre procesos y esperar con WaitForMultipleObjects, que no se puede hacer con una CRITICAL_SECTION. Por otro lado, una CRITICAL_SECTION es más liviana y, por lo tanto, más rápida. Pero la lógica del código no debe verse afectada por lo que usas.

También comentó que "no hay necesidad de una sección crítica para cada variable que quiera proteger, si se encuentra en una sección crítica, entonces nada más puede interrumpirlo". Esto es cierto, pero la compensación es que los accesos a cualquiera de las variables necesitarían que mantuviera ese bloqueo. Si las variables se pueden actualizar de manera significativa de manera independiente, está perdiendo la oportunidad de paralelizar esas operaciones. (Sin embargo, dado que estos son miembros del mismo objeto, pensaría mucho antes de llegar a la conclusión de que realmente se puede acceder a ellos independientemente uno del otro).