BookinglyTech News
Software

Por qué atomic y mutex no garantizan el orden de ejecución de los hilos

Proteger datos compartidos no es ordenar la ejecución: el planificador del sistema operativo decide qué hilo corre antes, y ni atomic ni mutex pueden cambiarlo.

2 min de lecturaDev.to0 vistas

C++ pone en manos del programador dos herramientas clásicas para compartir estado entre hilos: std::atomic y std::mutex. Ninguna de las dos decide cuál de los hilos se ejecuta primero. Ese orden lo fija el planificador del sistema operativo y, por definición, sale desordenado. El error de base está en creer que sincronizar es lo mismo que ordenar.

Cachés privadas y el mito de la visibilidad inmediata

Cada núcleo de una CPU moderna tiene sus propias cachés L1 y L2 y comparte la L3. Cuando un hilo modifica un dato, el núcleo no lo escribe de inmediato en memoria principal: lo deja en el store buffer y lo vuelca después de forma asíncrona hacia la caché o la memoria. El núcleo donde corre el otro hilo no se entera de nada y sigue leyendo su copia antigua.

El protocolo MESI de coherencia de caché está ahí, pero solo garantiza coherencia eventual, no visibilidad inmediata. El store buffer existe precisamente para tapar la latencia de esa coherencia. A eso se suma el compilador: con -O2 puede cachear una variable de acceso frecuente en un registro y no volver a tocar memoria en todo el bucle. Si esa variable es un flag que otro hilo cambia, el cambio resulta invisible y el bucle que lo espera puede no salir nunca.

Qué garantiza cada primitiva

std::atomic impide que el compilador guarde el valor en un registro: cada lectura y cada escritura van a memoria. Además inserta las barreras de memoria del nivel que corresponda, fuerza el vaciado del store buffer e invalida la caché, de modo que la modificación sea visible para los demás hilos. Y asegura que una lectura o escritura simple sea atómica, sin valores rasgados a medio escribir.

std::mutex lleva la barrera incorporada: la semántica de adquisición se ejecuta al bloquear y la de liberación al desbloquear. Si los dos hilos bloquean y desbloquean correctamente alrededor del dato compartido, visibilidad y atomicidad vienen dadas. Es la opción natural cuando el estado compartido es una estructura compleja que exige varios pasos, a cambio de más sobrecarga y del coste de cambiar de hilo.

Queda la barrera de memoria, el mecanismo de más bajo nivel: un punto de sincronización insertado a mano que obliga a vaciar el store buffer y el read buffer de invalidación, y fija el orden y la visibilidad a un lado y otro de la barrera. En C++ estándar es std::atomic_thread_fence; en Windows, MemoryBarrier(); en GCC, __sync_synchronize().

Nada de esto es nuevo en el modelo de memoria del lenguaje, pero el fallo sigue apareciendo en código de producción: quien asume que dos hilos van a entrar en un orden concreto está confiando en el planificador y en el estado de la caché de cada núcleo. Con optimizaciones activadas, el problema se reproduce o no según la máquina y la carga, que es la peor forma de tener un bug de concurrencia.