Antes de empezar
- Los estados de un proceso y la interrupción del temporizador (lección Procesos y sus estados).
- Round Robin y el cuanto (lección Planificador de CPU).
- Leer un listado de tres líneas de pseudoensamblador. No necesitas saber programar.
Teoría
Un hilo es una secuencia de instrucciones en ejecución dentro de un proceso. Un proceso puede tener varios hilos, y todos comparten su memoria (el código, las variables globales, el heap) y sus archivos abiertos. Lo que cada hilo tiene para sí son sus registros, su contador de programa y su pila.
El sistema operativo planifica los hilos igual que los procesos: hay una sola CPU, un hilo la usa a la vez, y el temporizador puede quitársela al terminar su cuanto. En esta lección el cuanto se mide en instrucciones.
Una línea como counter++ sobre una variable global parece un solo paso, pero la CPU ejecuta tres instrucciones:
| Instrucción | Qué hace |
|---|---|
load |
reg = counter: copia el valor de la memoria a un registro del hilo. |
add |
reg = reg + 1: solo cambia el registro; la memoria sigue igual. |
store |
counter = reg: copia el registro de vuelta a la memoria. |
Cuando el sistema cambia de hilo, guarda los registros del que sale y los restaura cuando vuelve. La memoria no se guarda ni se restaura: es compartida.
Ejemplo: un solo hilo y
counter = 5. Trasload, el registro vale 5. Trasadd, el registro vale 6 ycountersigue en 5. Trasstore,countervale 6.
Predecir
El tablero de abajo empieza con el escenario Dos incrementos: dos hilos, T1 y T2, hacen counter++ una vez cada uno, partiendo de counter = 0. Hay una sola CPU y el temporizador cambia de hilo cada 2 instrucciones.
¿Cuánto valdrá counter cuando los dos terminen? Elige un valor en el panel Tu predicción antes de ejecutar.
Ejecutar
Hilos y programa
En tres instrucciones, otro hilo puede colarse entre ellas. Atómico, es una sola instrucción indivisible.
Quién decide el intercalado
Round Robin: cada hilo ejecuta como máximo un cuanto de instrucciones y luego le toca al siguiente.
Tu predicción
¿Cuánto valdrá counter cuando todos los hilos terminen? Empieza en 0.
El tablero antes de empezar
Una CPU. El temporizador cambia de hilo cada 2 instrucciones.
Memoria compartida
counter 0
- Espera turnoRegistro vacío
load reg = counter, siguiente instrucciónadd reg = reg + 1store counter = reg
- Espera turnoRegistro vacío
load reg = counter, siguiente instrucciónadd reg = reg + 1store counter = reg
Qué pasó en este turno
Pulsa Ejecutar o avanza instrucción a instrucción. Aquí verás qué hace cada hilo y con qué valor.
Escrituras en counter
Todavía nadie escribió en counter.
Una escritura es obsoleta cuando counter cambió entre la lectura del hilo y su escritura: pisa lo que otros escribieron mientras tanto, normalmente sus incrementos.
Mientras avanza, fíjate en tres momentos:
- Tras la instrucción 2, ¿cuánto vale el registro de T1? ¿Y
counter? - En la instrucción 3, T2 lee
counter. ¿Está al día el valor que lee? - En la instrucción 6, T2 escribe. ¿Qué había en
counterjusto antes?
Debajo del tablero, el registro de escrituras anota quién escribió, qué había leído y qué había en memoria justo antes.
Explicar
Esta etapa se abre cuando ejecutas la simulación hasta el final.
Ir a la simulaciónLo correcto era 1. Se pidieron dos incrementos y solo uno sobrevive.
counter++ no es un paso indivisible. Tras la instrucción 2, T1 tiene un 1 en su registro, pero todavía no lo ha escrito: counter sigue en 0. El temporizador lo interrumpe justo ahí. T2 lee ese 0, suma 1 y también se queda con un 1. Al final los dos escriben 1. La escritura de T2 es obsoleta: la calculó a partir de un valor que ya no estaba al día, y borra el incremento de T1.
Una sola CPU basta. No hizo falta que los hilos corrieran a la vez en dos núcleos. Bastó con que el temporizador cortara a T1 entre su load y su store.
Esto se llama una condición de carrera: el resultado depende del orden en que el planificador intercala los hilos. El fragmento de código que toca el dato compartido, aquí las tres instrucciones de load a store, es una sección crítica. Lo que necesitamos es exclusión mutua: que, como mucho, un hilo esté dentro de la sección crítica a la vez.
Que funcione en las pruebas no demuestra nada. Con cuanto 3, cada turno contiene un counter++ completo y el resultado es 2. Pero nada en el código lo garantiza: otro hilo, un fallo de página o una interrupción mueven los turnos, y la carrera vuelve. Por eso se dice que el resultado es indeterminado: el mismo programa, con la misma entrada, da 1 o 2.
Un cuanto más largo no lo arregla. En Tres hilos, con cuanto 4, los turnos cortan el segundo incremento entre load y store, y se pierden 2 de 6. Menos interrupciones hacen la carrera más rara, no imposible.
Puede salir muy mal. En Tres hilos con cuanto 2, seis incrementos dejan counter en 2. Una sola escritura obsoleta puede borrar varios incrementos a la vez: todos los que otros hicieron entre su lectura y su escritura. Con dos hilos que hacen n incrementos cada uno (n ≥ 2), el resultado puede bajar hasta 2.
Qué comparten los hilos. El 1 de T1 estaba a salvo en su registro mientras T2 corría, porque cada hilo tiene sus propios registros y el sistema los guarda en cada cambio. counter, en cambio, existe una sola vez en la memoria. Justamente por eso hay carrera.
La solución del hardware: instrucciones atómicas. Los procesadores ofrecen instrucciones de lectura-modificación-escritura que se completan sin que otro hilo toque esa memoria en medio: lock xadd en x86, atomic_fetch_add en C11, std::atomic<int>::fetch_add en C++, AtomicInteger en Java. Con ellas counter++ es una sola operación indivisible y ningún intercalado puede partirla. Para secciones críticas más largas que una instrucción hacen falta cerrojos, el tema del siguiente módulo.
En sistemas reales. Con varios núcleos los hilos corren de verdad en paralelo y las cachés añaden sorpresas (reordenamiento de memoria), pero la actualización perdida aparece ya con un núcleo. En C y C++, dos accesos sin sincronizar a la misma variable, con al menos una escritura, son una carrera de datos y el comportamiento queda indefinido; herramientas como ThreadSanitizer las detectan. En Python, counter += 1 son varias instrucciones de bytecode y tampoco es atómico; la versión sin GIL (con soporte oficial desde Python 3.14) solo hace que la carrera aparezca más a menudo.
Experimentar
Esta etapa se abre cuando ejecutas la simulación hasta el final.
Ir a la simulaciónUsa el tablero para resolver estos retos. Cada uno tiene una solución posible al desplegarlo.
1. Haz que Dos incrementos termine en 2 sin hacer atómico counter++.
Pon el cuanto en 3 (o más). Cada turno contiene un load, add, store completo, así que ningún hilo lee un valor desactualizado. Es correcto por suerte: depende de dónde caen los turnos.
2. Haz que Tres hilos pierda incrementos con un cuanto mayor que 3.
Cuanto 4 (o 5): counter termina en 4 y se pierden 2. Los turnos de 4 instrucciones cortan el segundo incremento entre load y store. La escritura obsoleta de T1 borra dos incrementos de golpe, el de T2 y el de T3.
3. Con dos hilos que hacen tres incrementos cada uno, consigue que counter termine en 2.
Elige Tú decides y construye este orden (o carga Peor caso): T1; T2 seis veces; T1 dos veces; T2; T1 seis veces; T2 dos veces. T1 lee 0 y lo guarda mientras T2 hace dos incrementos completos; al escribir 1, los borra. Luego T2 lee ese 1 y espera mientras T1 hace sus dos últimos incrementos; al escribir 2, los borra.
4. Consigue que cualquier intercalado dé el resultado correcto.
Elige Atómico. counter++ pasa a ser una sola instrucción indivisible: no hay ningún punto en el que otro hilo pueda colarse entre la lectura y la escritura.
Comprobar
Esta etapa se abre cuando ejecutas la simulación hasta el final.
Ir a la simulaciónProfundizar
- Cerrojos y semáforos. Para proteger secciones críticas de más de una instrucción, el siguiente módulo construye la exclusión mutua.
- Reordenamiento de memoria. En procesadores con varios núcleos, las escrituras de un hilo pueden verse en otro orden desde otro núcleo. Los modelos de memoria de C11 y de Java dicen qué órdenes están garantizados.
- Comprobar y luego actuar.
if (!existe(f)) crear(f)tiene una carrera aunque cada llamada sea atómica: otro hilo puede crear el archivo entre la comprobación y la creación. - Detectores de carreras. ThreadSanitizer (
-fsanitize=threaden GCC y Clang) observa los accesos a memoria y avisa de las carreras de datos que ocurren durante una ejecución.
Para leer más:
- Arpaci-Dusseau y Arpaci-Dusseau, Operating Systems: Three Easy Pieces, cap. 26, secciones 26.3 y 26.4. Gratis en línea.
- Silberschatz, Galvin y Gagne, Operating System Concepts, 10.ª ed., secciones 4.1 y 6.1.
- cppreference, Multi-threaded executions and data races: en.cppreference.com.
- PEP 779, criterios de soporte para Python sin GIL: peps.python.org.