¿Se necesita mutex para sincronizar una bandera simple entre pthreads?

Imaginemos que tengo algunos hilos de trabajo como los siguientes:

while (1) { do_something(); if (flag_isset()) do_something_else(); } 

Tenemos un par de funciones de ayuda para verificar y configurar una bandera:

 void flag_set() { global_flag = 1; } void flag_clear() { global_flag = 0; } int flag_isset() { return global_flag; } 

Por lo tanto, los subprocesos siguen llamando a do_something() en un bucle ocupado y, en caso de que otros subprocesos establezcan global_flag el subproceso también llama a do_something_else() (que podría, por ejemplo, generar información sobre el progreso o la depuración cuando se solicite mediante el establecimiento del indicador desde otro subproceso).

Mi pregunta es: ¿Necesito hacer algo especial para sincronizar el acceso a global_flag? En caso afirmativo, ¿cuál es exactamente el trabajo mínimo para realizar la sincronización de forma portátil?

He intentado resolver esto leyendo muchos artículos, pero todavía no estoy seguro de la respuesta correcta … Creo que es uno de los siguientes:

R: No es necesario sincronizar porque la configuración o la eliminación de la bandera no crea condiciones de carrera:

Solo necesitamos definir la bandera como volatile para asegurarnos de que realmente se lea de la memoria compartida cada vez que se verifique:

 volatile int global_flag; 

Puede que no se propague a otros núcleos de CPU de forma inmediata, pero tarde o temprano, se garantizará.

B: Se necesita una sincronización completa para asegurarse de que los cambios en el indicador se propagan entre los subprocesos:

Establecer el indicador compartido en un núcleo de CPU no necesariamente hace que sea visto por otro núcleo. Necesitamos usar un mutex para asegurarnos de que los cambios de marca siempre se propagan al invalidar las líneas de caché correspondientes en otras CPU. El código se convierte en el siguiente:

 volatile int global_flag; pthread_mutex_t flag_mutex; void flag_set() { pthread_mutex_lock(flag_mutex); global_flag = 1; pthread_mutex_unlock(flag_mutex); } void flag_clear() { pthread_mutex_lock(flag_mutex); global_flag = 0; pthread_mutex_unlock(flag_mutex); } int flag_isset() { int rc; pthread_mutex_lock(flag_mutex); rc = global_flag; pthread_mutex_unlock(flag_mutex); return rc; } 

C: la sincronización es necesaria para asegurarse de que los cambios en el indicador se propagan entre subprocesos:

Esto es lo mismo que B, pero en lugar de usar un mutex en ambos lados (lector y escritor), lo configuramos solo en el lado de escritura. Porque la lógica no requiere sincronización. solo necesitamos sincronizar (invalidar otros cachés) cuando se cambia la bandera:

 volatile int global_flag; pthread_mutex_t flag_mutex; void flag_set() { pthread_mutex_lock(flag_mutex); global_flag = 1; pthread_mutex_unlock(flag_mutex); } void flag_clear() { pthread_mutex_lock(flag_mutex); global_flag = 0; pthread_mutex_unlock(flag_mutex); } int flag_isset() { return global_flag; } 

Esto evitaría bloquear y desbloquear continuamente el mutex cuando sabemos que la bandera rara vez se cambia. Solo estamos usando un efecto secundario de Pthreads mutexes para asegurarnos de que el cambio se propague.

¿Cuál?

Creo que A y B son las opciones obvias, B es más seguro. Pero ¿qué hay de C?

Si C está bien, ¿hay alguna otra forma de forzar que el cambio de indicador sea visible en todas las CPU?

Hay una pregunta un tanto relacionada: ¿proteger una variable con un pthread mutex garantiza que tampoco se almacene en caché? … pero en realidad no responde esto.

La ‘cantidad mínima de trabajo’ es una barrera de memoria explícita. La syntax depende de tu comstackdor; en GCC podrías hacer:

 void flag_set() { global_flag = 1; __sync_synchronize(global_flag); } void flag_clear() { global_flag = 0; __sync_synchronize(global_flag); } int flag_isset() { int val; // Prevent the read from migrating backwards __sync_synchronize(global_flag); val = global_flag; // and prevent it from being propagated forwards as well __sync_synchronize(global_flag); return val; } 

Estas barreras de memoria logran dos objectives importantes:

  1. Forzan un comstackdor al ras. Considere un bucle como el siguiente:

      for (int i = 0; i < 1000000000; i++) { flag_set(); // assume this is inlined local_counter += i; } 

    Sin una barrera, un comstackdor podría elegir optimizar esto para:

      for (int i = 0; i < 1000000000; i++) { local_counter += i; } flag_set(); 

    Insertar una barrera obliga al comstackdor a escribir la variable inmediatamente.

  2. Obligan a la CPU a ordenar sus escrituras y lecturas. Esto no es tanto un problema con un solo indicador: la mayoría de las architectures de CPU finalmente verán un indicador que se establece sin barreras de nivel de CPU. Sin embargo, el orden puede cambiar. Si tenemos dos banderas, y en el hilo A:

      // start with only flag A set flag_set_B(); flag_clear_A(); 

    Y en el hilo B:

      a = flag_isset_A(); b = flag_isset_B(); assert(a || b); // can be false! 

    Algunas architectures de CPU permiten que estas escrituras se reordenen; es posible que vea que ambas banderas son falsas (es decir, la marca A se movió primero). Esto puede ser un problema si una bandera protege, por ejemplo, si un puntero es válido. Las barreras de memoria obligan a un pedido de escrituras para protegerse contra estos problemas.

Tenga en cuenta también que en algunas CPU, es posible utilizar la semántica de barrera de 'liberación de adquisición' para reducir aún más la sobrecarga. Sin embargo, tal distinción no existe en x86 y requeriría ensamblaje en línea en GCC.

En el directorio de documentación del kernel de Linux se puede encontrar una buena descripción de qué son las barreras de memoria y por qué se necesitan. Finalmente, tenga en cuenta que este código es suficiente para una sola bandera, pero si también desea sincronizar con otros valores, debe pisar con mucho cuidado. Una cerradura suele ser la forma más sencilla de hacer las cosas.

No debe causar casos de carrera de datos. Es un comportamiento indefinido y el comstackdor tiene permitido hacer cualquier cosa y todo lo que le plazca.

Un blog humorístico sobre el tema: http://software.intel.com/en-us/blogs/2013/01/06/benign-data-races-what-could-possibly-go-wrong

Caso 1: No hay sincronización en la bandera, por lo que cualquier cosa puede suceder. Por ejemplo, el comstackdor tiene permitido girar

 flag_set(); while(weArentBoredLoopingYet()) doSomethingVeryExpensive(); flag_clear() 

dentro

 while(weArentBoredLoopingYet()) doSomethingVeryExpensive(); flag_set(); flag_clear() 

Nota: este tipo de raza es en realidad muy popular. Su millage puede variar. Por un lado, la implementación de facto de pthread_call_once implica una carrera de datos como esta. Por otro lado, es un comportamiento indefinido. En la mayoría de las versiones de gcc, puede salirse con la suya porque gcc elige no ejercer su derecho de optimizar de esta manera en muchos casos, pero no es un código “específico”.

B: la sincronización completa es la llamada correcta. Esto es simplemente lo que tienes que hacer.

C: Sólo podría funcionar la sincronización en el escritor, si puede probar que nadie quiere leerlo mientras está escribiendo. La definición oficial de una carrera de datos (de la especificación C ++ 11) es un hilo que se escribe en una variable, mientras que otro hilo puede leer o escribir simultáneamente la misma variable. Si sus lectores y escritores se ejecutan todos a la vez, todavía tiene un caso de raza. Sin embargo, si puede probar que el escritor escribe una vez, hay alguna sincronización, y luego todos los lectores leen, entonces los lectores no necesitan sincronización.

En cuanto al almacenamiento en caché, la regla es que un locking / deslocking mutex se sincroniza con todos los subprocesos que bloquean / desbloquean el mismo mutex. Esto significa que no verá ningún efecto de almacenamiento en caché inusual (aunque bajo el capó, su procesador puede hacer cosas espectaculares para que esto funcione más rápido … solo está obligado a hacer que parezca que no estaba haciendo nada especial). Sin embargo, si no sincroniza, ¡no obtendrá garantías de que el otro hilo no tenga los cambios que necesita para empujar!

Dicho todo esto, la pregunta es realmente cuánto está dispuesto a confiar en el comportamiento específico del comstackdor. Si desea escribir el código correcto, necesita hacer la sincronización correcta. Si está dispuesto a confiar en que el comstackdor sea amable con usted, puede salirse con la suya con mucho menos.

Si tiene C ++ 11, la respuesta fácil es usar atomic_flag, que está diseñado para hacer exactamente lo que quiere Y está diseñado para sincronizarse correctamente en la mayoría de los casos.

Para el ejemplo que ha publicado, el caso A es suficiente siempre que …

  1. Obtener y configurar el indicador solo requiere una instrucción de CPU.
  2. do_something_else () no depende de la marca que se establece durante la ejecución de esa rutina.

Si obtener y / o configurar el indicador requiere más de una instrucción de CPU, debe realizar algún tipo de locking.

Si do_something_else () depende de la marca que se establece durante la ejecución de esa rutina, entonces debe bloquear como en el caso C, pero la exclusión mutua debe estar bloqueada antes de llamar a flag_isset ().

Espero que esto ayude.

La asignación de trabajo entrante a subprocesos de trabajo no requiere locking. Un ejemplo típico es el servidor web, donde la solicitud es atrapada por un hilo principal, y este hilo principal selecciona un trabajador. Estoy tratando de explicarlo con algún código de pesudo.

 main task { // do forever while (true) // wait for job while (x != null) { sleep(some); x = grabTheJob(); } // select worker bool found = false; for (n = 0; n < NUM_OF_WORKERS; n++) if (workerList[n].getFlag() != AVAILABLE) continue; workerList[n].setJob(x); workerList[n].setFlag(DO_IT_PLS); found = true; } if (!found) panic("no free worker task! ouch!"); } // while forever } // main task worker task { while (true) { while (getFlag() != DO_IT_PLS) sleep(some); setFlag(BUSY_DOING_THE_TASK); /// do it really setFlag(AVAILABLE); } // while forever } // worker task 

Entonces, si hay una bandera, que una parte establece en A y otra en B y C (la tarea principal la establece en DO_IT_PLS, y el trabajador la configura en OCUPADA y DISPONIBLE), no hay confilct. Juegue con el ejemplo de la "vida real", por ejemplo, cuando el maestro está asignando diferentes tareas a los estudiantes. El profesor selecciona a un alumno, le asigna una tarea. Luego, el profesor busca al siguiente alumno disponible. Cuando un estudiante está listo, él / ella regresa al grupo de estudiantes disponibles.

ACTUALIZACIÓN : solo aclare, solo hay un hilo principal () y varios - número configurable de - hilos de trabajo. Como main () ejecuta solo una instancia, no hay necesidad de sincronizar la selección y la ejecución de los trabajadores.