Cerramos el módulo 2 reconociendo una deuda. Cada vez que dos procesos compartían una página con MAP_SHARED, cada vez que una softirq y un proceso tocaban la misma cola de paquetes, cada vez que dos trabajadores de meteo-api escribían en /dev/shm/meteora-cache, dijimos "esto requiere sincronización" y seguimos adelante. Esta lección empieza a pagarla.

Y empieza por lo más importante: entender el problema antes de aprender las herramientas. Es un error muy común lanzarse a memorizar mutex, semáforos y variables de condición sin haber comprendido con precisión qué es exactamente lo que sale mal cuando no se usan. Quien no entiende el fallo, usa las herramientas por superstición: pone un candado "por si acaso" donde no hace falta y lo olvida donde sí. Aquí vamos a descomponer un contador++ hasta sus instrucciones máquina y ver, paso a paso, cómo se pierde un incremento.

Al terminar sabrás distinguir concurrencia de paralelismo con rigor, reconocer una condición de carrera leyendo código, formular con exactitud los tres requisitos que debe cumplir cualquier solución al problema de la sección crítica, y calcular con la ley de Amdahl cuánto puede acelerarse realmente el agregador si le añadimos núcleos.

Contenido

  1. Concurrencia y paralelismo no son lo mismo
  2. Por qué la concurrencia es inevitable
  3. El entrelazado de instrucciones como modelo mental
  4. La condición de carrera, descompuesta
  5. El caso real: el contador de peticiones de meteo-api
  6. La sección crítica y los tres requisitos
  7. Atomicidad: qué lo es de verdad y qué no
  8. No determinismo, heisenbugs y por qué no se reproducen
  9. Modelos de concurrencia comparados
  10. Escalabilidad y la ley de Amdahl aplicada al agregador

Concurrencia y paralelismo no son lo mismo

Son dos palabras que se usan como sinónimas y no lo son. La distinción es la base de todo el módulo, así que vamos con definiciones precisas.

Concurrencia es una propiedad de la estructura del programa: varias tareas están en curso durante el mismo intervalo de tiempo, y su avance se intercala. No exige que se ejecuten simultáneamente; exige que ninguna tenga que terminar antes de que otra empiece.

Paralelismo es una propiedad de la ejecución: varias tareas ejecutan instrucciones en el mismo instante físico, sobre unidades de cálculo distintas. Exige hardware con más de un núcleo (o más de una CPU, o una GPU, o un clúster).

La frase que mejor lo resume, atribuida a Rob Pike: la concurrencia es una forma de estructurar el programa; el paralelismo es una forma de ejecutarlo.

Concurrencia Paralelismo
Naturaleza Estructura del programa Forma de ejecución
Requiere varios núcleos No Sí
Pregunta que responde ¿Cómo organizo tareas que se solapan? ¿Cómo hago esto más rápido?
Objetivo típico Capacidad de respuesta, aprovechar esperas de E/S Reducir el tiempo total de cálculo
Puede darse sin la otra Sí (un núcleo, muchos hilos) Sí (SIMD, vectorización sobre un solo hilo lógico)
Introduce condiciones de carrera Sí Sí (y además más difíciles de ver)

El punto que casi todo el mundo pasa por alto y que conviene grabar: la concurrencia en un solo núcleo ya produce todos los problemas de este módulo. No hace falta paralelismo real. Si meteo-01 tuviera un único núcleo y ejecutara dos trabajadores de meteo-api, el planificador seguiría alternando entre ellos cada pocos milisegundos —o antes, si uno se bloquea en E/S— y esa alternancia puede caer en medio de un contador++. El resultado erróneo es exactamente el mismo.

El paralelismo real añade un agravante, no un problema nuevo: en un solo núcleo el entrelazado ocurre solo en los puntos donde el planificador expulsa a un hilo; con varios núcleos ocurre continuamente y además intervienen las cachés de cada núcleo, que pueden hacer que dos hilos vean valores distintos de la misma variable durante un instante. Ese segundo efecto lo trataremos en Sincronización y Exclusión Mutua, cuando hablemos de barreras de memoria.

gantt
    title Concurrencia sin paralelismo (1 núcleo) frente a paralelismo (2 núcleos)
    dateFormat X
    axisFormat %s
    section 1 núcleo
    Tarea A :0, 2
    Tarea B :2, 4
    Tarea A :4, 6
    Tarea B :6, 8
    section Núcleo 0
    Tarea A :0, 4
    section Núcleo 1
    Tarea B :0, 4

Arriba, un núcleo alterna: ambas tareas están en curso durante los 8 segundos, pero nunca corren a la vez. Abajo, dos núcleos: ambas terminan en 4 segundos porque corren de verdad simultáneamente. En los dos casos hay concurrencia; solo en el segundo hay paralelismo.

Por qué la concurrencia es inevitable

Podrías preguntarte si no sería más sencillo prohibirla. La respuesta es que un sistema operativo moderno no puede evitarla ni aunque quisiera, por cuatro razones acumulativas.

1. El hardware dejó de acelerarse en frecuencia hacia 2005. Durante tres décadas, un programa secuencial iba más rápido cada año sin tocar una línea de código: la frecuencia de reloj subía. Esa curva se rompió por disipación térmica: la potencia crece aproximadamente con el cubo de la frecuencia. La industria giró hacia añadir núcleos. Un servidor típico como meteo-01 tiene 8 o 16 núcleos; un programa estrictamente secuencial usa uno y desperdicia el 87 % o el 94 % de la máquina.

2. La E/S es entre mil y un millón de veces más lenta que la CPU. Recuperando las cifras del módulo 2:

Operación Latencia Ciclos de CPU equivalentes (a 3 GHz)
Acceso a caché L1 ~1 ns 3
Acceso a memoria principal ~80 ns 240
Lectura de 4 KB en NVMe ~80 µs 240.000
Ida y vuelta de red en LAN ~500 µs 1.500.000
Lectura de 4 KB en disco duro ~8 ms 24.000.000

Si el ingestor atendiera una estación cada vez y esperase su respuesta de forma secuencial, pasaría el 99,99 % del tiempo sin hacer nada. La concurrencia es lo que permite superponer esas esperas: mientras un flujo espera datos de la estación 41, otro procesa los de la 12.

3. El mundo real es concurrente. Meteora tiene 800 estaciones que envían cuando les da la gana, cientos de clientes HTTP consultando la API y un agregador que debe calcular medias cada hora. No hay ningún orden secuencial natural entre esos eventos. Modelarlos como una secuencia sería forzar una mentira.

4. El propio núcleo es concurrente por construcción. Aunque escribieras un programa de un solo hilo, por debajo se ejecutan interrupciones que lo expulsan sin avisar (módulo 2), hilos del núcleo como kswapd o los ksoftirqd, y otros procesos compitiendo por los mismos recursos. La concurrencia no es una opción que actives: es el medio en el que vive tu código.

El entrelazado de instrucciones como modelo mental

Todo el razonamiento sobre concurrencia se apoya en un único modelo mental. Vale la pena enunciarlo con claridad porque el resto de la lección lo usa constantemente.

Cuando varios flujos de ejecución (hilos o procesos) avanzan concurrentemente, la ejecución real es alguno de los entrelazados posibles de sus instrucciones, y tú no controlas cuál. Un programa concurrente es correcto solo si es correcto para todos los entrelazados posibles.

Dos consecuencias importantes:

  • El número de entrelazados crece de forma explosiva. Con dos hilos de m y n instrucciones respectivamente, hay C(m+n, m) entrelazados. Para dos hilos de solo 10 instrucciones cada uno: 184.756 órdenes posibles. Para tres hilos de 10, más de 5.500 millones. Probar "a ver si falla" no cubre ni una fracción del espacio.
  • Los puntos donde puede haber un cambio de flujo no son los que parecen en el código fuente. La unidad de entrelazado no es la línea de C ni la sentencia de Python: es la instrucción máquina, y una línea inocente puede ser varias.

Ese último punto es exactamente lo que hace que contador++ sea peligroso.

La condición de carrera, descompuesta

Una condición de carrera (race condition) es una situación en la que el resultado del programa depende del orden relativo en que se entrelazan las operaciones de varios flujos de ejecución, cuando ese orden no está controlado.

Vamos con el ejemplo canónico. Este código en C incrementa una variable compartida:

/* carrera.c — dos hilos incrementan la misma variable */
#include <stdio.h>
#include <pthread.h>

#define VUELTAS 1000000

long contador = 0;               /* compartida por ambos hilos */

void *trabajador(void *arg) {
    for (int i = 0; i < VUELTAS; i++) {
        contador++;              /* ← una línea, tres instrucciones */
    }
    return NULL;
}

int main(void) {
    pthread_t h1, h2;
    pthread_create(&h1, NULL, trabajador, NULL);
    pthread_create(&h2, NULL, trabajador, NULL);
    pthread_join(h1, NULL);
    pthread_join(h2, NULL);
    printf("Esperado: %d\n", 2 * VUELTAS);
    printf("Obtenido: %ld\n", contador);
    return 0;
}

Dos hilos, un millón de incrementos cada uno. Lo esperado son 2.000.000. Lo que ocurre:

$ gcc -O0 -pthread carrera.c -o carrera
$ ./carrera
Esperado: 2000000
Obtenido: 1298447
$ ./carrera
Esperado: 2000000
Obtenido: 1104923
$ ./carrera
Esperado: 2000000
Obtenido: 1523061

Se pierden entre 400.000 y 900.000 incrementos, y la cifra cambia en cada ejecución. Nunca sale de más; siempre de menos. Para entender por qué, hay que mirar qué compila realmente contador++:

$ gcc -O0 -pthread -S carrera.c -o - | grep -A3 "contador(%rip)"
    movq    contador(%rip), %rax     ; (1) LEER:    rax ← memoria
    addq    $1, %rax                 ; (2) MODIFICAR: rax ← rax + 1
    movq    %rax, contador(%rip)     ; (3) ESCRIBIR: memoria ← rax

Aquí está todo el problema. contador++ no es una operación, son tres: leer de memoria a un registro, sumar en el registro, escribir el registro a memoria. Es el patrón leer-modificar-escribir (read-modify-write), y es el origen de la inmensa mayoría de las condiciones de carrera que verás en tu vida profesional.

El registro %rax es privado de cada hilo: forma parte de su contexto y se guarda y restaura en cada cambio de contexto (módulo 2). La variable contador en memoria es compartida. Entre el paso (1) y el paso (3) de un hilo, el valor en memoria puede haber cambiado sin que ese hilo se entere.

Traza de un entrelazado que pierde un incremento, partiendo de contador = 41:

Tiempo Hilo A Hilo B %rax de A %rax de B contador en memoria
t1 movq contador,%rax 41 – 41
t2 addq $1,%rax 42 – 41
t3 (expulsado) movq contador,%rax 42 41 41
t4 addq $1,%rax 42 42 41
t5 movq %rax,contador 42 42 42
t6 movq %rax,contador 42 42 42

Resultado: dos incrementos ejecutados, contador vale 42 en lugar de 43. Se ha perdido uno. No porque falle el hardware ni el compilador: porque el hilo A leyó 41 en t1 y escribió su resultado en t6, ignorando que entre medias B había escrito 42. La escritura de A pisa la de B. En la literatura esto se llama actualización perdida (lost update).

La ventana de peligro es el intervalo entre (1) y (3): apenas 3 o 4 ciclos, alrededor de 1 nanosegundo. Parece imposible que dos hilos coincidan en una ventana tan estrecha. Pero con dos millones de iteraciones y un núcleo cada uno, esa ventana se abre dos millones de veces por segundo en cada hilo. Lo improbable, repetido lo suficiente, se vuelve inevitable. Ese es el segundo punto que hay que interiorizar: una condición de carrera con probabilidad ínfima por operación se convierte en un fallo diario cuando la operación ocurre millones de veces.

Y compilar con optimizaciones no arregla nada; a veces enmascara el problema y lo hace más traicionero:

$ gcc -O2 -pthread carrera.c -o carrera_opt
$ ./carrera_opt
Obtenido: 1000000

Con -O2, GCC saca contador del bucle, acumula en un registro y escribe una sola vez al final. Los dos hilos escriben 1.000.000 y el último gana. El resultado es igual de incorrecto, pero ahora es estable, lo cual es peor: parece un fallo determinista de lógica y no una carrera.

El caso real: el contador de peticiones de meteo-api

Llevemos esto a Meteora. meteo-api tiene cuatro trabajadores que atienden consultas HTTP y comparten un bloque de estadísticas en /dev/shm/meteora-cache, la memoria compartida que apareció en el módulo 2:

/* Cabecera de /dev/shm/meteora-cache, mapeada con MAP_SHARED
   por los 4 trabajadores de meteo-api */
struct cache_meteora {
    unsigned long peticiones_totales;   /* ← contador compartido */
    unsigned long peticiones_error;
    unsigned long ultima_agregacion;    /* timestamp del agregador */
    struct Lectura ultimas[1024];       /* caché de lecturas recientes */
};

Cada trabajador, al terminar de atender una consulta, hace:

cache->peticiones_totales++;    /* leer-modificar-escribir sobre memoria compartida */

Con 1.200 peticiones por segundo repartidas entre 4 trabajadores, la contabilidad al cabo de un día muestra esto:

$ grep -c "GET /api" /var/log/meteora/meteo-api.log
103680000
$ meteora-stats --campo peticiones_totales
103_612_884

Faltan 67.116 peticiones, un 0,065 %. Es una desviación pequeña, y ahí está el peligro real: no rompe nada visiblemente. Nadie mira un panel y piensa "esto está mal". Simplemente la facturación por uso sale un 0,065 % baja, las alertas por umbral saltan un poco tarde, y el informe mensual miente en una cifra que nadie contrasta.

Un matiz importante: aquí los flujos que compiten son procesos distintos, no hilos. Comparten la variable porque comparten la página física con MAP_SHARED, no porque compartan espacio de direcciones. La condición de carrera es exactamente la misma. Lo que hace peligroso a un dato no es dónde vive, sino que dos flujos lo escriban sin coordinación.

Ese mismo bloque tiene un segundo problema, más grave que perder cuentas. Cuando el agregador actualiza ultimas[] con nuevas medias horarias mientras un trabajador de meteo-api la está leyendo, el lector puede ver una estructura medio actualizada: el timestamp nuevo con la temperatura vieja. No se pierde un dato, se inventa uno que nunca existió. Eso ya no es una desviación estadística, es una respuesta HTTP incorrecta. La solución al caso general la construiremos en Sincronización y Exclusión Mutua; este patrón concreto de lectores y escritores lo trataremos en Problemas Clásicos de Concurrencia.

La sección crítica y los tres requisitos

Ya podemos nombrar el problema con precisión.

Una sección crítica es el fragmento de código de un flujo de ejecución que accede a un recurso compartido de forma que puede entrar en conflicto con los accesos de otros flujos.

En el ejemplo, la sección crítica de cada trabajador son las tres instrucciones de cache->peticiones_totales++. En el caso de ultimas[], es todo el bloque que escribe los campos de una Lectura completa.

Un detalle que se malinterpreta a menudo: la sección crítica no es el dato, es el código. Y el mismo dato puede tener varias secciones críticas repartidas por el programa. Todas deben protegerse; basta con que una se olvide para que la protección de las demás no sirva de nada.

El problema de la sección crítica consiste en diseñar un protocolo —un "quiero entrar" y un "he salido"— que garantice tres propiedades. Fueron formuladas por Dijkstra en 1965 y siguen siendo el criterio con el que se juzga cualquier solución:

1. Exclusión mutua. Si un flujo está ejecutando su sección crítica, ningún otro puede estar ejecutando la suya sobre el mismo recurso. Es la propiedad de seguridad: garantiza que nunca ocurre algo malo.

2. Progreso. Si ningún flujo está en su sección crítica y hay flujos que quieren entrar, solo ellos participan en la decisión de quién entra, y esa decisión no puede posponerse indefinidamente. Prohíbe que la sección crítica quede bloqueada por nadie, o que un flujo que ni siquiera quiere entrar impida hacerlo a otros. Es lo que evita el interbloqueo, que veremos en Interbloqueos.

3. Espera limitada (bounded waiting). Existe un límite al número de veces que otros flujos pueden entrar en su sección crítica después de que un flujo haya solicitado entrar y antes de que se le conceda. Es lo que evita la inanición: sin ella, un flujo puede quedar esperando para siempre mientras sus compañeros se turnan indefinidamente.

Requisito Qué evita Nombre del fallo si no se cumple
Exclusión mutua Que dos flujos toquen el dato a la vez Condición de carrera, corrupción
Progreso Que nadie pueda entrar aunque esté libre Interbloqueo
Espera limitada Que un flujo espere indefinidamente Inanición

Los tres son necesarios y son independientes: una solución puede cumplir dos y fallar en el tercero. Un candado que nunca se libera cumple exclusión mutua a la perfección y viola el progreso de forma catastrófica. Guarda esta lista, porque en Sincronización y Exclusión Mutua evaluaremos cada solución propuesta contra estos tres criterios exactos.

A esos tres, la práctica añade dos supuestos que conviene explicitar porque a veces se olvidan: no se puede suponer nada sobre la velocidad relativa de los flujos (uno puede ir mil veces más rápido que otro) ni sobre el número de núcleos.

Atomicidad: qué lo es de verdad y qué no

Una operación es atómica si, desde el punto de vista de cualquier otro flujo, ocurre por completo o no ocurre en absoluto: no hay ningún instante en el que se observe a medias.

La palabra viene del griego átomos, "indivisible". Y la pregunta práctica es: ¿qué es atómico de verdad en un sistema real?

Operación ¿Atómica? Por qué
x = 5; con x de 8 bytes alineado Sí en x86-64 Una sola instrucción mov, dato alineado en una línea de caché
x = 5; con x de 8 bytes no alineado a 8 No Cruza dos líneas de caché: dos accesos separados
long y = x; (lectura simple, alineada) Sí en x86-64 Un solo mov
x++ No Leer-modificar-escribir: 3 instrucciones
x += n No Igual que el anterior
if (x == 0) x = 1; No Comprobar y actuar: dos operaciones separadas
lock incq x (ensamblador con prefijo lock) Sí El hardware bloquea la línea de caché durante la operación
__atomic_fetch_add(&x, 1, ...) en C11 Sí El compilador emite la instrucción con lock
Escribir una struct Lectura de 24 bytes No Tres o más escrituras de 8 bytes
printf("...") No garantizada Función compleja con estado interno (búfer de stdio)
Una operación sobre un dict de Python Depende Un d[k] = v sí; un d[k] += 1 no

Cuatro conclusiones prácticas de esta tabla:

  • Una asignación simple de un tipo del tamaño de la palabra y alineado es atómica en las arquitecturas actuales. Por eso escribir un int compartido no corrompe el valor: verás el viejo o el nuevo, nunca una mezcla de bits.
  • Todo lo que sea leer-modificar-escribir no es atómico, y eso incluye ++, --, +=, y cualquier if que decida en función de un valor compartido que después modifica.
  • Nada compuesto de varias palabras es atómico. Actualizar una struct Lectura de 24 bytes son al menos tres escrituras; un lector puede colarse en medio.
  • Atómico no significa correcto. Aunque peticiones_totales++ fuera atómico, si tu lógica es "leer el contador, decidir según él, y luego escribirlo", la decisión sigue siendo una carrera. La atomicidad es a nivel de operación; la corrección es a nivel de invariante.

Esa última idea es sutil y merece un ejemplo. En Meteora, el agregador decide si rota el fichero del día:

if (cache->ultima_agregacion < ahora - 3600) {   /* comprobar */
    cache->ultima_agregacion = ahora;             /* actuar */
    ejecutar_agregacion();                        /* trabajo pesado */
}

Cada línea por separado es atómica. El conjunto no lo es: dos trabajadores pueden pasar la comprobación antes de que ninguno haya escrito, y ejecutar la agregación dos veces. Es el patrón check-then-act, y es la segunda gran familia de condiciones de carrera después de read-modify-write. Aprende a reconocer las dos leyendo código; te ahorrarán mucho tiempo de depuración.

No determinismo, heisenbugs y por qué no se reproducen

Un programa secuencial es determinista: con las mismas entradas produce siempre la misma salida y sigue el mismo camino. Es la propiedad que hace posible depurar de la manera habitual —reproducir, poner un punto de ruptura, mirar.

Un programa concurrente no es determinista. Con las mismas entradas puede producir salidas distintas, porque el entrelazado concreto depende de factores que ni el programa ni tú controláis:

  • Las decisiones del planificador CFS-EEVDF, que dependen del vruntime acumulado y por tanto de todo lo que haya pasado antes en la máquina (módulo 2).
  • Las interrupciones, que llegan cuando llegan y expulsan al hilo en curso.
  • El estado de las cachés y del TLB: un fallo de caché alarga una instrucción de 1 ns a 80 ns y desplaza la ventana de peligro.
  • La migración entre núcleos, la frecuencia dinámica de la CPU, la carga del resto del sistema.

De aquí viene el término heisenbug: un fallo que cambia de comportamiento o desaparece cuando intentas observarlo. El nombre es un juego con el principio de incertidumbre de Heisenberg, y describe una experiencia muy real:

$ ./carrera
Obtenido: 1298447                      ← falla

$ gdb ./carrera
(gdb) run
Obtenido: 2000000                      ← ¡correcto bajo el depurador!

$ strace -f ./carrera 2>/dev/null
Obtenido: 2000000                      ← correcto con strace

$ ./carrera                            ← sin instrumentar
Obtenido: 1445912                      ← vuelve a fallar

La explicación es directa: gdb y strace interceptan eventos y añaden decenas de microsegundos por operación. Eso cambia por completo la distribución temporal y hace que los hilos casi nunca coincidan en la ventana de 1 ns. El fallo no se ha arreglado; se ha vuelto improbable.

Esto tiene tres consecuencias muy prácticas para tu trabajo:

  1. No puedes demostrar la ausencia de carreras probando. Que 10.000 ejecuciones pasen no dice nada: has muestreado 10.000 entrelazados de miles de millones. La corrección concurrente se demuestra razonando sobre invariantes, no ejecutando.
  2. Las carreras aparecen cuando cambia el entorno. El código que llevaba dos años en producción falla al migrar de 4 a 32 núcleos, o cuando el tráfico se duplica, o al pasar de un disco duro a NVMe. No ha cambiado el código: ha cambiado la probabilidad del entrelazado malo.
  3. Necesitas herramientas de detección, no de reproducción. ThreadSanitizer (gcc -fsanitize=thread) instrumenta cada acceso a memoria y detecta accesos conflictivos aunque el fallo no llegue a manifestarse:
$ gcc -O0 -pthread -fsanitize=thread carrera.c -o carrera_tsan
$ ./carrera_tsan
WARNING: ThreadSanitizer: data race (pid=8814)
  Write of size 8 at 0x55d3f8a2e010 by thread T2:
    #0 trabajador carrera.c:11
  Previous write of size 8 at 0x55d3f8a2e010 by thread T1:
    #0 trabajador carrera.c:11
SUMMARY: ThreadSanitizer: data race carrera.c:11 in trabajador

Te da la línea exacta y los dos hilos implicados, en la primera ejecución. El coste es de 5 a 15 veces más lento y de 5 a 10 veces más memoria, así que se usa en pruebas, no en producción. Es, con diferencia, la herramienta más rentable de este módulo.

Modelos de concurrencia comparados

No hay una única forma de estructurar un programa concurrente. Hay cuatro grandes familias, y elegir bien entre ellas determina la mitad de los problemas que tendrás después.

Modelo Unidad Cómo comparte estado Coste de crear una unidad Aislamiento ante fallos Riesgo de carreras Ejemplos reales
Multiproceso Proceso Explícito: IPC, memoria compartida ~100-300 µs Alto: un fallo mata solo a uno Bajo (solo en lo compartido) Apache prefork, PostgreSQL, Chrome
Multihilo Hilo Implícito: toda la memoria ~10-30 µs Nulo: un fallo mata el proceso Muy alto MySQL, nginx (workers), JVM
Basado en eventos Callback / corrutina No hay: un solo hilo ~1 µs o menos Nulo Muy bajo nginx, Node.js, Redis, asyncio
Actores / mensajes Actor No se comparte: se envían copias ~1-10 µs Alto por diseño Muy bajo Erlang/Elixir, Akka, goroutines + canales

Merece la pena entender el compromiso de fondo de cada uno:

  • Multiproceso: aislamiento a cambio de coste. Los espacios de direcciones son independientes, así que un puntero corrupto en un proceso no puede tocar los demás. Chrome usa un proceso por pestaña exactamente por esto. El precio: crear un proceso cuesta un orden de magnitud más que un hilo y compartir datos exige un mecanismo explícito, que veremos en Comunicación entre Procesos (IPC).
  • Multihilo: rendimiento a cambio de peligro. Compartir memoria es gratis, y por eso es tan rápido... y por eso cualquier variable es una carrera potencial. Es el modelo que más disciplina exige. Lo desarrollamos en la siguiente lección, Hilos y Procesos.
  • Basado en eventos: elimina las carreras por construcción, porque solo hay un hilo y nada se ejecuta a la vez. A cambio, cualquier operación bloqueante congela todo el servidor, y un cálculo largo bloquea a todos los clientes. Es el modelo de nginx y de Redis, y explica por qué Redis, siendo monohilo, atiende cientos de miles de operaciones por segundo: todo lo que hace es trabajo de memoria, muy corto.
  • Actores: cada actor tiene estado privado y solo se comunica por mensajes; como nada se comparte, no hay nada que proteger. Es el modelo que mejor escala a sistemas distribuidos, porque un mensaje entre actores funciona igual dentro de una máquina que entre dos. El coste es la copia de datos y un cambio profundo de estilo de programación.

Un sistema real casi siempre mezcla. Meteora, sin ir más lejos, usa tres a la vez: ingestor, agregador y meteo-api son procesos separados (aislamiento); dentro de meteo-api hay varios hilos trabajadores (rendimiento); y el ingestor atiende sus 800 sockets con un bucle de eventos basado en epoll (escalabilidad con muchas conexiones lentas). Esa combinación no es casualidad, y en Hilos y Procesos veremos por qué cada pieza eligió lo que eligió.

Escalabilidad y la ley de Amdahl aplicada al agregador

Última pieza, y la más útil para tomar decisiones: ¿cuánto se puede acelerar un programa añadiendo núcleos?

La respuesta la dio Gene Amdahl en 1967 y es más pesimista de lo que casi nadie espera. Si una fracción P del tiempo de ejecución es paralelizable y el resto (1−P) es estrictamente secuencial, la aceleración con N unidades de proceso es:

                    1
S(N) = ─────────────────────────
        (1 − P) + P/N

Y en el límite, con infinitos núcleos:

S(∞) = 1 / (1 − P)

Es decir: la parte secuencial pone un techo absoluto, y ese techo no depende del hardware que compres.

Apliquémoslo al agregador de Meteora. Su trabajo diario, medido con perf sobre el fichero 2026-08-31.dat (17 MB, unas 700.000 lecturas):

Fase Tiempo ¿Paralelizable?
Leer el fichero del día y validar cabecera 0,9 s No: es una lectura secuencial
Calcular medias por estación y por hora 7,2 s Sí: cada estación es independiente
Fusionar los resultados parciales y ordenar 1,1 s No: necesita todos los parciales
Escribir el resultado y actualizar la caché 0,8 s No: escritura secuencial
Total 10,0 s

La fracción paralelizable es P = 7,2 / 10,0 = 0,72. Con esto ya podemos calcular:

Núcleos Cálculo Aceleración Tiempo total Eficiencia (S/N)
1 1 / (0,28 + 0,72) 1,00× 10,00 s 100 %
2 1 / (0,28 + 0,36) 1,56× 6,40 s 78 %
4 1 / (0,28 + 0,18) 2,17× 4,60 s 54 %
8 1 / (0,28 + 0,09) 2,70× 3,70 s 34 %
16 1 / (0,28 + 0,045) 3,08× 3,25 s 19 %
32 1 / (0,28 + 0,0225) 3,31× 3,02 s 10 %
∞ 1 / 0,28 3,57× 2,80 s 0 %

Lee la tabla despacio, porque tiene tres lecciones caras:

Primera: el techo es 3,57×, no 32×. Aunque Meteora comprase un servidor de 128 núcleos, el agregador no bajaría de 2,8 segundos. Los 2,8 s de partes secuenciales no se van a ninguna parte.

Segunda: la eficiencia se desploma. Pasar de 1 a 4 núcleos gana 5,4 segundos. Pasar de 4 a 16 gana solo 1,35 segundos más, usando doce núcleos adicionales. Esos doce núcleos podrían estar atendiendo peticiones de meteo-api; dedicarlos al agregador para ganar 1,35 s es probablemente una mala decisión de arquitectura.

Tercera, y la que casi todo el mundo olvida: la fórmula es optimista. Supone que paralelizar es gratis, y no lo es. Hay que repartir el trabajo, y sobre todo hay que sincronizar, y la sincronización tiene un coste que crece con el número de flujos. Si los 8 hilos del agregador compiten por un mutex sobre el resultado parcial, el tiempo real con 8 núcleos puede ser peor que el predicho —y en casos de contención alta, peor que con 4 núcleos. Es lo que se llama escalabilidad negativa, y es sorprendentemente frecuente. Mediremos ese coste con números concretos en Sincronización y Exclusión Mutua.

La conclusión operativa: antes de paralelizar, mide P. Si tu P es 0,72, el objetivo realista es 4 núcleos y 2,2×; comprar 32 es tirar dinero. Y muchas veces reducir la parte secuencial (aquí, 0,9 s de lectura: ¿se puede solapar con el cálculo?) da más que añadir núcleos.

Errores Comunes y Consejos

Creer que si el programa no falla, no hay carrera. Es el error más caro del módulo. Una carrera puede tener una probabilidad de 10⁻⁹ por operación y no manifestarse en dos años... hasta que el tráfico se multiplica por diez o migras a una máquina con el doble de núcleos. Usa -fsanitize=thread en tus pruebas: detecta el acceso conflictivo aunque el fallo no llegue a ocurrir.

Confundir "es una sola línea" con "es atómico". contador++, lista.append(x), if (p == NULL) p = crear(); son una línea cada uno y ninguno es atómico. La unidad de entrelazado es la instrucción máquina, y en lenguajes de alto nivel, la operación del intérprete. Pregúntate siempre: ¿esto lee y luego escribe? Si sí, es una carrera potencial.

Pensar que la concurrencia solo importa con varios núcleos. Es falso. Un núcleo con dos hilos ya sufre todas las condiciones de carrera de esta lección, porque el planificador expulsa en cualquier punto. Limitar el proceso a un núcleo con taskset -c 0 reduce la frecuencia del fallo, no lo elimina, y crea una falsa sensación de seguridad.

Añadir hilos esperando una mejora proporcional. La ley de Amdahl dice que no, y la sincronización empeora todavía más la predicción. Mide la fracción paralelizable antes de rediseñar.

Usar volatile en C creyendo que sirve para concurrencia. No sirve. volatile le dice al compilador que no cachee la variable en un registro, pero no impide la reordenación del procesador ni hace atómico un ++. Un volatile long contador; contador++; sigue perdiendo incrementos. Es un error tan extendido que le dedicaremos un apartado entero en Sincronización y Exclusión Mutua.

Consejo: identifica los datos compartidos antes que las secciones críticas. Haz una lista de qué variables toca más de un flujo y quién las escribe. En Meteora esa lista es corta: peticiones_totales, ultimas[], ultima_agregacion. Casi todo lo demás es privado de cada hilo y no necesita protección. Proteger lo que no hace falta cuesta rendimiento; olvidar lo que sí hace falta cuesta corrección.

Consejo: prefiere no compartir a sincronizar bien. Si cada trabajador de meteo-api lleva su propio contador y alguien los suma una vez por minuto, no hay sección crítica que proteger. La técnica se llama sharding del contador y suele ser mucho más rápida que un contador compartido perfectamente sincronizado.

Ejercicios

Ejercicio 1: reproducir y medir la carrera

Escribe un programa en C con dos hilos que incrementen una variable compartida un número configurable de veces (por argumento). Ejecútalo con 1.000, 100.000 y 10.000.000 de iteraciones, cinco veces cada una, y construye una tabla con el porcentaje medio de incrementos perdidos. Explica la tendencia. Después compílalo con ThreadSanitizer y comprueba si detecta la carrera con solo 1.000 iteraciones.

Ejercicio 2: clasificar secciones críticas

Para cada fragmento, indica si contiene una condición de carrera cuando lo ejecutan dos hilos, de qué tipo es (read-modify-write, check-then-act, escritura no atómica de estructura, o ninguna) y cuál es exactamente la sección crítica.

/* (a) */  cache->peticiones_totales++;

/* (b) */  long copia = cache->peticiones_totales;
           printf("Total: %ld\n", copia);

/* (c) */  if (cache->ultimas[i].timestamp == 0)
               cache->ultimas[i] = nueva_lectura;

/* (d) */  int local = 0;
           for (int j = 0; j < 1000; j++) local += datos[j];
           /* datos[] solo se lee, nadie la modifica */

/* (e) */  cache->ultimas[indice_hilo] = nueva_lectura;
           /* cada hilo usa su propio indice_hilo, distinto de los demás */

/* (f) */  cache->ultima_agregacion = ahora;   /* unsigned long alineado */

Ejercicio 3: decisión con la ley de Amdahl

El ingestor de Meteora tarda 4,0 s en procesar un lote de 100.000 lecturas: 0,5 s en leer del socket (secuencial), 3,0 s en validar y convertir cada lectura (paralelizable), 0,5 s en escribir el fichero (secuencial). Meteora se plantea dos inversiones que cuestan lo mismo:

  • Opción A: paralelizar la validación con 8 hilos.
  • Opción B: dejarlo monohilo pero optimizar la escritura de 0,5 s a 0,1 s y la lectura de 0,5 s a 0,2 s.

Calcula el tiempo final de cada opción y razona cuál elegirías. Después calcula qué pasa si se hacen las dos y cuál es el nuevo techo teórico.

Soluciones

Solución 1

/* medir_carrera.c */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

long contador = 0;
long vueltas;

void *trabajador(void *arg) {
    for (long i = 0; i < vueltas; i++) contador++;
    return NULL;
}

int main(int argc, char **argv) {
    vueltas = atol(argv[1]);
    pthread_t h1, h2;
    pthread_create(&h1, NULL, trabajador, NULL);
    pthread_create(&h2, NULL, trabajador, NULL);
    pthread_join(h1, NULL);  pthread_join(h2, NULL);
    long esperado = 2 * vueltas;
    printf("%ld %ld %.4f%%\n", esperado, contador,
           100.0 * (esperado - contador) / esperado);
    return 0;
}

Compilar con gcc -O0 -pthread medir_carrera.c -o medir_carrera (el -O0 es importante: con -O2 el compilador saca el contador del bucle y el fenómeno cambia de naturaleza).

Resultados típicos:

Iteraciones por hilo Pérdida media Rango observado
1.000 0,0 % siempre exacto
100.000 ~11 % 0 % – 28 %
10.000.000 ~48 % 41 % – 52 %

Explicación de la tendencia. Con 1.000 iteraciones el bucle dura unos 3 µs, menos que el tiempo que tarda el segundo hilo en arrancar (~20 µs): en la práctica se ejecutan uno tras otro y no hay solapamiento. Con 100.000 el solapamiento existe pero es parcial, y la pérdida es errática. Con 10 millones ambos hilos corren en paralelo casi todo el tiempo, en núcleos distintos, y la línea de caché con contador rebota entre ellos constantemente: cada hilo lee valores obsoletos casi siempre y la pérdida se acerca al 50 %, que es el máximo teórico (los dos hilos avanzan "en paralelo" sobre el mismo valor y solo cuenta uno).

Este resultado ilustra la lección clave: con pocas iteraciones el programa "funciona". Si tu prueba unitaria usa 1.000 y producción usa 10 millones, tu prueba pasa siempre y producción falla siempre.

Con ThreadSanitizer:

$ gcc -O0 -pthread -fsanitize=thread medir_carrera.c -o medir_tsan
$ ./medir_tsan 1000
WARNING: ThreadSanitizer: data race (pid=9012)
  Write of size 8 at 0x5581... by thread T2:
    #0 trabajador medir_carrera.c:8
2000 2000 0.0000%

Detecta la carrera con 1.000 iteraciones, donde el resultado es correcto. Eso es exactamente lo que lo hace valioso: no busca síntomas, busca la causa. TSan mantiene relojes vectoriales por hilo y detecta que dos accesos, uno de ellos de escritura, tocan la misma dirección sin ninguna relación de orden que los separe.

Solución 2

Caso ¿Carrera? Tipo Sección crítica
(a) Sí Read-modify-write Las tres instrucciones del ++
(b) No (con matiz) – Ninguna: solo lee, y la lectura de un long alineado es atómica. Puede leer un valor desactualizado, pero nunca corrupto. Si la lógica dependiera de ese valor para escribir después, sí habría check-then-act.
(c) Sí Check-then-act y escritura no atómica Desde el if hasta el final de la asignación. Dos hilos pueden ver timestamp == 0 y ambos escribir; además, la asignación de 24 bytes no es atómica y un lector puede ver la estructura a medias
(d) No – Ninguna: local es de pila (privada de cada hilo) y datos[] es de solo lectura. Los datos inmutables o privados nunca necesitan protección
(e) No, en principio – Cada hilo escribe una posición distinta del array. Ojo: es correcto, pero puede ser lento por false sharing si dos posiciones caen en la misma línea de caché de 64 bytes; con struct Lectura de 24 bytes, los índices 0, 1 y 2 comparten línea
(f) No para la escritura en sí – Escribir un unsigned long alineado es una sola instrucción. Otros hilos verán el valor viejo o el nuevo, nunca una mezcla. Pero si otro hilo hace check-then-act sobre este campo, la carrera está allí, no aquí

El caso (e) merece un comentario extra porque enseña algo importante: correcto y rápido son cosas distintas. No hay carrera, el resultado siempre es correcto, pero si los hilos escriben en posiciones contiguas del array, los núcleos se invalidan la línea de caché mutuamente y el rendimiento puede caer 5 o 10 veces. La solución es alinear cada elemento a 64 bytes. Es un problema de rendimiento causado por la concurrencia, no de corrección.

Solución 3

Estructura del tiempo original: secuencial = 0,5 + 0,5 = 1,0 s; paralelizable = 3,0 s; total 4,0 s. Por tanto P = 3,0/4,0 = 0,75.

Opción A (8 hilos en la validación):

S(8) = 1 / (0,25 + 0,75/8) = 1 / (0,25 + 0,09375) = 1 / 0,34375 = 2,91×
Tiempo = 4,0 / 2,91 = 1,375 s

Comprobación directa: 0,5 + 3,0/8 + 0,5 = 0,5 + 0,375 + 0,5 = 1,375 s.

Opción B (optimizar las partes secuenciales):

Tiempo = 0,2 + 3,0 + 0,1 = 3,3 s     →  aceleración 4,0/3,3 = 1,21×

Cuál elegir. La opción A es claramente mejor en tiempo bruto (1,375 s frente a 3,3 s, 2,4 veces más rápida). Pero la decisión de ingeniería tiene más aristas:

  • A consume 8 núcleos durante 0,375 s; B consume 1 durante 3,3 s. Si meteo-01 tiene 8 núcleos y meteo-api los necesita para atender clientes, A degrada la latencia de la API durante ese rato.
  • A introduce concurrencia sobre los datos: hay que repartir el lote, y los hilos escribirán resultados que después se fusionan. Eso abre la puerta a todas las carreras de esta lección. B no toca la estructura del programa y no puede introducir ningún fallo de concurrencia.
  • B reduce la parte secuencial, lo que sube el techo para futuras paralelizaciones.

Si el objetivo es la latencia del lote y hay núcleos libres, A. Si el sistema ya está saturado o el equipo tiene poca experiencia en concurrencia, B es una mejora segura y barata.

Haciendo las dos:

Tiempo = 0,2 + 3,0/8 + 0,1 = 0,2 + 0,375 + 0,1 = 0,675 s   →  5,93× sobre el original

Nuevo techo teórico con infinitos núcleos, ya optimizadas las partes secuenciales:

S(∞) = 4,0 / (0,2 + 0,1) = 4,0 / 0,3 = 13,3×   →  0,3 s

Fíjate en lo que ha pasado: el techo original era 4,0/1,0 = 4×. Reducir la parte secuencial de 1,0 s a 0,3 s lo ha subido a 13,3×. Optimizar la parte secuencial no solo mejora el tiempo actual: mejora el retorno de todo el paralelismo futuro. Es la lección menos intuitiva de la ley de Amdahl y la más útil en la práctica.

Conclusión

Concurrencia es una propiedad de la estructura de un programa —tareas en curso durante el mismo intervalo—; paralelismo es una propiedad de su ejecución —instrucciones en el mismo instante físico—. La distinción importa porque un solo núcleo con dos hilos ya produce todos los problemas de este módulo: basta con que el planificador expulse en el punto equivocado. Y la concurrencia no es opcional: el hardware dejó de acelerarse en frecuencia, la E/S es entre mil y un millón de veces más lenta que la CPU, el mundo que modelamos es concurrente, y el propio núcleo lo es por construcción.

El modelo mental que lo gobierna todo es el entrelazado: la ejecución real es uno de los muchos órdenes posibles de las instrucciones de cada flujo, no eliges cuál, y tu programa solo es correcto si lo es para todos. Con dos hilos de diez instrucciones hay 184.756 entrelazados; probar no cubre nada. De ahí nace la condición de carrera, que hemos descompuesto hasta el ensamblador: contador++ son tres instrucciones —leer, modificar, escribir— y la ventana de 1 ns entre la primera y la tercera es suficiente para perder medio millón de incrementos por ejecución. En Meteora eso se traduce en 67.116 peticiones no contabilizadas al día: un 0,065 % que no rompe nada visible y por eso es peligroso.

La zona a proteger es la sección crítica, que es código y no dato, y cualquier solución debe cumplir tres requisitos independientes: exclusión mutua (nunca dos dentro), progreso (si está libre, alguien entra) y espera limitada (nadie espera para siempre). Son el criterio con el que juzgaremos cada primitiva del módulo. Hemos visto también qué es atómico de verdad —una escritura alineada del tamaño de la palabra sí; ++, +=, un if que después escribe, o cualquier estructura de varias palabras, no— y las dos familias de carreras que reconocerás en el 90 % del código real: read-modify-write y check-then-act.

Estos fallos son no deterministas, dependen del planificador, de las interrupciones y de las cachés, y por eso desaparecen bajo gdb o strace: son heisenbugs. La consecuencia práctica es que no se depuran reproduciendo, sino con detectores como ThreadSanitizer, que encuentra la carrera aunque el resultado salga correcto. Los cuatro modelos de concurrencia —multiproceso, multihilo, eventos y actores— reparten de forma distinta el mismo compromiso entre aislamiento, coste y riesgo de carreras, y Meteora usa tres a la vez. Y la ley de Amdahl pone el límite: con P = 0,72, el agregador no bajará nunca de 2,8 s aunque tenga 128 núcleos, y pasar de 4 a 16 núcleos gana apenas 1,35 s.

Ya sabes qué sale mal y por qué. Ahora toca conocer a los protagonistas de cerca. Hemos hablado de "flujos de ejecución" sin comprometernos: procesos que comparten una página, hilos que comparten todo. ¿Qué es exactamente un hilo? ¿Qué comparte con sus hermanos y qué mantiene privado? ¿Por qué crear un hilo cuesta 20 µs y crear un proceso 200? ¿Y por qué en Linux, por dentro, un hilo y un proceso son la misma cosa llamada de otra manera?

Lo vemos en Hilos y Procesos.

Fundamentos de Sistemas Operativos

Módulo 1: Introducción a los Sistemas Operativos

Módulo 2: Gestión de Recursos

Módulo 3: Concurrencia

Módulo 4: Estructuras de Archivos

Módulo 5: Protección y Seguridad del Sistema

Módulo 6: Virtualización y Contenedores

Módulo 7: Administración y Diagnóstico en la Práctica

© Copyright 2026. Todos los derechos reservados