2009-07-09 12 views
8

Vengo en gran parte de un fondo de C++, pero creo que esta pregunta se aplica a enhebrar en cualquier idioma. Este es el escenario:¿Cómo hacen frente los sistemas con hilos a los datos compartidos que están siendo almacenados en caché por diferentes CPU?

  1. tenemos dos hilos (ThreadA y ThreadB), y un valor de x en la memoria compartida

  2. Suponga que el acceso a x está controlado apropiadamente por un mutex (u otro control de sincronización adecuado)

  3. Si los subprocesos se ejecutan en diferentes procesadores, ¿qué sucede si ThreadA realiza una operación de escritura, pero su procesador coloca el resultado en su caché L2 en lugar de en la memoria principal? Entonces, si ThreadB intenta leer el valor, ¿no solo buscará en su propio caché L1/L2/memoria principal y luego trabajará con cualquier valor antiguo que haya allí?

Si ese no es el caso, ¿cómo se maneja este problema?

Si ese es el caso, ¿qué se puede hacer al respecto?

Respuesta

9

Su ejemplo funcionaría muy bien.

Múltiples procesadores utilizan un coherency protocol como MESI para garantizar que los datos permanecen sincronizados entre las memorias caché. Con MESI, cada línea de caché se considera modificada, mantenida exclusivamente, compartida entre CPU o no válida. Escribir una línea de caché que se comparte entre los procesadores lo fuerza a convertirse en inválido en las otras CPU, manteniendo las cachés sincronizadas.

Sin embargo, esto no es suficiente. Los diferentes procesadores tienen diferentes memory models, y la mayoría de los procesadores modernos admiten algún nivel de reordenamiento de los accesos a la memoria. En estos casos, se necesitan memory barriers.

Por ejemplo, si usted tiene Tema A:

DoWork(); 
workDone = true; 

Y Tema B:

while (!workDone) {} 
DoSomethingWithResults() 

Con ambos se ejecutan en procesadores separados, no hay ninguna garantía de que las escrituras hechas dentro DoWork() se ser visible para el subproceso B antes de escribir en el trabajo y DoSomethingWithResults() procedería con un estado potencialmente inconsistente. Las barreras de memoria garantizan que se ordenen las lecturas y escrituras, agregar una barrera de memoria después de DoWork() en el Subproceso A obligará a todas las lecturas/escrituras hechas por DoWork a completarse antes de la escritura en workDone, de modo que el subproceso B obtenga una vista consistente. Los mutexes proporcionan intrínsecamente una barrera de memoria, por lo que las lecturas/escrituras no pueden pasar una llamada para bloquear y desbloquear.

En su caso, un procesador señalaría a los demás que ensuciaba una línea de caché y obligaba a los otros procesadores a recargarse desde la memoria. Adquirir el mutex para leer y escribir el valor garantiza que el cambio en la memoria sea visible para el otro procesador en el orden esperado.

+0

Muchas gracias por esta respuesta. Me he preguntado si algún tipo de mecanismo de nivel de hardware debe entrar en juego aquí, porque parecía que había límites prácticos sobre lo que podría lograrse a nivel de lenguaje/compilador. – csj

1

La mayoría de las primitivas de bloqueo, como mutexes, implican memory barriers. Estos obligan a que se realice un vaciado y recarga de la memoria caché.

Por ejemplo,

ThreadA { 
    x = 5;   // probably writes to cache 
    unlock mutex; // forcibly writes local CPU cache to global memory 
} 
ThreadB { 
    lock mutex; // discards data in local cache 
    y = x;   // x must read from global memory 
} 
+1

No creo que las barreras obliguen a una descarga de caché, sino que fuerzan las restricciones en el orden de las operaciones de memoria. Una descarga de caché no lo ayudará si la escritura en X puede pasar desbloqueando el mutex. – Michael

+0

Las barreras serían inútiles si el compilador reordena las operaciones de memoria a través de ellas, ¿eh? Al menos para GCC, creo que esto generalmente se implementa con un golpe de memoria, que le dice a GCC "invalidar cualquier suposición acerca de la memoria". – ephemient

+0

Oh, ya veo lo que dices. No es necesario realizar un vaciado de caché, siempre que el orden se respete correctamente entre los procesadores. Entonces, supongo que esta explicación es una vista simplificada, y la tuya profundiza más en los detalles del hardware. – ephemient

0

En general, el compilador entiende la memoria compartida, y toma un esfuerzo considerable para asegurar que la memoria compartida se coloca en un lugar compartible. Los compiladores modernos son muy complicados en la forma en que ordenan las operaciones y los accesos a la memoria; ellos tienden a entender la naturaleza del enhebrado y la memoria compartida. Eso no quiere decir que sean perfectos, pero en general, el compilador se ocupa de gran parte de la preocupación.

0

C# tiene algo de compatibilidad para este tipo de problemas. Puede marcar una variable con la palabra clave volatile, lo que obliga a sincronizarla en todas las CPU.

public static volatile int loggedUsers; 

La otra parte es una wrappper sintáctica alrededor de los métodos .NET llamados Threading.Monitor.Enter (x) y Threading.Monitor.Exit (x), donde x es una variable para bloquear. Esto hace que otros hilos que intenten bloquear x tengan que esperar hasta que el hilo de bloqueo llame a Exit (x).

public list users; 
// In some function: 
System.Threading.Monitor.Enter(users); 
try { 
    // do something with users 
} 
finally { 
    System.Threading.Monitor.Exit(users); 
} 
Cuestiones relacionadas