← Concurrencia y Sincronización Bloque 1 · El problema de la concurrencia · Tema 4 de 47

Tema 4

Qué hay detrás de counter++

A nivel de código fuente, counter++ parece una sola operación. A nivel de máquina, son tres instrucciones separadas: leer el valor, incrementarlo y escribirlo de vuelta. En este tema verás exactamente qué instrucciones genera el compilador, cómo el planificador puede intercalarlas entre hilos, cuáles son todos los intercalados posibles y por qué el resultado final es casi siempre menor que el esperado. También verás cómo el hardware puede hacer la operación atómica y por qué, a pesar de eso, no basta con eso.

1. La ilusión de la atomicidad

A nivel de código fuente, counter++ parece una sola operación. El programador la escribe como una línea, la lee como una instrucción y espera que se ejecute de forma indivisible. Pero esta es una ilusión: el compilador traduce esa línea en varias instrucciones de máquina, y el procesador las ejecuta una por una.

La atomicidad no es una propiedad del código fuente: es una propiedad de la ejecución. Una operación es atómica si se ejecuta de forma indivisible, sin que otro hilo pueda observar un estado intermedio. counter++ no cumple esta propiedad en la mayoría de los procesadores.

Esta ilusión es la causa de la mayoría de las condiciones de carrera: el programador asume que counter++ es atómico, pero no lo es. Cuando dos hilos ejecutan counter++ simultáneamente, sus instrucciones se intercalan y el resultado es incorrecto.

La atomicidad es una propiedad de la ejecución, no del código

Una operación es atómica si se ejecuta de forma indivisible. El código fuente no determina la atomicidad; la determina el hardware y el compilador. counter++ es una sola línea de código pero tres instrucciones de máquina.

2. Las tres instrucciones de máquina

A nivel de máquina, counter++ se descompone en tres instrucciones:

  1. LOAD (leer): leer el valor de counter de la memoria a un registro de la CPU. leer
  2. ADD (modificar): incrementar el valor en el registro. modificar
  3. STORE (escribir): escribir el valor del registro de vuelta a la memoria. escribir

Estas tres instrucciones son independientes: el procesador las ejecuta una por una, y el planificador puede interrumpir entre ellas. Si dos hilos ejecutan counter++ simultáneamente, sus tres instrucciones se pueden intercalar de formas que producen resultados incorrectos.

El problema no es que las instrucciones sean lentas: es que no son indivisibles. Entre el LOAD y el STORE, otro hilo puede leer el mismo valor y escribir el mismo resultado, perdiendo un incremento.

3. Lo que genera el compilador

El compilador traduce counter++ en instrucciones de máquina específicas del procesador. En x86-64, con GCC, el código generado es:

assembly · GCC con -O0 (sin optimización)
movl    counter(%rip), %eax    ; LOAD: leer counter a eax
addl    $1, %eax              ; ADD: incrementar eax
movl    %eax, counter(%rip)    ; STORE: escribir eax a counter

Con optimización (-O2), el compilador puede optimizar el código, pero las tres instrucciones siguen estando presentes (a menos que el compilador pueda probar que no hay concurrencia, lo que no puede hacer en general):

assembly · GCC con -O2
movl    counter(%rip), %eax
incl    %eax
movl    %eax, counter(%rip)

El compilador puede usar incl (increment) en lugar de addl $1, pero sigue siendo una instrucción separada de LOAD y STORE. La operación sigue sin ser atómica.

Si el compilador puede probar que counter es local (no compartido), puede optimizar más agresivamente. Pero si counter es global o compartido, el compilador debe generar las tres instrucciones.

El compilador no sabe de concurrencia

El compilador traduce counter++ en tres instrucciones porque así lo define el lenguaje C. No sabe que el dato es compartido entre hilos. Incluso con optimización, no puede eliminar las instrucciones de LOAD y STORE porque el dato puede cambiar entre ellas.

4. Cómo se intercalan las instrucciones

Cuando dos hilos ejecutan counter++ simultáneamente, el planificador puede intercalar sus instrucciones de cualquier forma. El espacio de intercalados es enorme: con 2 hilos y 3 instrucciones cada uno, hay C(6,3) = 20 formas de intercalar, aunque muchas son equivalentes.

Los intercalados que producen un resultado incorrecto son aquellos en que ambos hilos leen el mismo valor antes de que alguno escriba. En ese caso, ambos suman 1 al mismo valor y ambos escriben el mismo resultado, perdiendo un incremento.

El número de intercalados incorrectos crece exponencialmente con el número de hilos y de incrementos. Con 4 hilos y 100.000 incrementos, el número de intercalados posibles es astronómico, y solo unos pocos producen el resultado correcto.

5. Los casos posibles para dos hilos

Para dos hilos que ejecutan counter++ una vez cada uno (counter empieza en 0), hay tres casos posibles:

CasoIntercaladoResultado¿Correcto?
1A: LOAD, ADD, STORE → B: LOAD, ADD, STORE2Sí
2B: LOAD, ADD, STORE → A: LOAD, ADD, STORE2Sí
3A: LOAD → B: LOAD → A: ADD → B: ADD → A: STORE → B: STORE1No (se pierde un incremento)

El caso 3 es el que produce el error: ambos hilos leen 0, ambos suman 1, y ambos escriben 1. El resultado es 1 en lugar de 2.

Hay más casos intermedios (por ejemplo, A: LOAD → A: ADD → B: LOAD → A: STORE → B: ADD → B: STORE, que también da 1), pero todos los incorrectos tienen en común que ambos hilos leen el mismo valor antes de que alguno escriba.

De los 20 intercalados posibles, solo 2 producen el resultado correcto (2). Los otros 18 producen 1. La probabilidad de obtener el resultado correcto es del 10 %.

6. Generalización a N hilos

Con N hilos y M incrementos por hilo, el número de intercalados posibles es (3NM)! / (3!)^(NM), que es astronómico. Solo unos pocos producen el resultado correcto (N×M).

La probabilidad de obtener el resultado correcto disminuye exponencialmente con el número de hilos y de incrementos. Con 4 hilos y 100.000 incrementos, la probabilidad de obtener el resultado correcto es prácticamente cero.

Esto explica por qué el resultado del experimento del tema 3 es siempre incorrecto: con 4 hilos y 100.000 incrementos, la probabilidad de obtener 400.000 es prácticamente nula.

7. Por qué el resultado siempre es menor o igual

En una condición de carrera de incremento, el resultado siempre es menor o igual al esperado, nunca mayor. Esto es porque:

Esta propiedad es útil para detectar el problema: si un contador compartido da resultados menores que los esperados, probablemente hay una condición de carrera. Si diera resultados mayores, el problema sería diferente (por ejemplo, una doble escritura).

8. Cómo el hardware puede hacerlo atómico

Los procesadores modernos tienen instrucciones que ejecutan counter++ de forma atómica, en una sola operación indivisible. En x86, la instrucción LOCK INC (o LOCK ADD) hace esto:

assembly · instrucción atómica en x86
lock incl counter(%rip)    ; LOCK INC: incrementar counter de forma atómica

El prefijo LOCK le dice al procesador que la instrucción debe ser atómica: ningún otro procesador puede acceder a la memoria mientras se ejecuta. Esto se logra bloqueando el bus de memoria (en procesadores antiguos) o usando protocolos de coherencia de caché (en procesadores modernos).

Con lock incl, la operación de incremento es atómica: no hay punto intermedio en el que otro hilo pueda leer el valor parcial. El resultado siempre es correcto, sin importar el intercalado.

LOCK no es gratis

El prefijo LOCK tiene un costo: bloquea el bus de memoria (en procesadores antiguos) o invalida la caché de otros procesadores (en procesadores modernos). Esto es más lento que una instrucción normal. Por eso, no se debe usar LOCK para todas las operaciones: solo para las que necesitan atomicidad.

9. El prefijo LOCK en x86

El prefijo LOCK se puede aplicar a varias instrucciones de x86:

InstrucciónCon LOCKSignificado
INCLOCK INCIncrementar de forma atómica.
DECLOCK DECDecrementar de forma atómica.
ADDLOCK ADDSumar de forma atómica.
SUBLOCK SUBRestar de forma atómica.
AND/OR/XORLOCK AND/OR/XOROperaciones lógicas atómicas.
CMPXCHGLOCK CMPXCHGCompare-and-swap atómico.
XCHGXCHG (implícito LOCK)Intercambio atómico.

No todas las instrucciones soportan LOCK. Las instrucciones de lectura/escritura simples (MOV) no lo soportan porque no son operaciones de read-modify-write.

En procesadores modernos (desde Pentium), el prefijo LOCK no bloquea el bus de memoria completo: usa el protocolo de coherencia de caché (MESI) para invalidar la línea de caché en otros procesadores. Esto es más eficiente que bloquear el bus.

10. Los límites de la atomicidad por hardware

Aunque el hardware puede hacer counter++ atómico, hay limitaciones:

Por eso, las operaciones atómicas del hardware son la base para construir mecanismos de sincronización de más alto nivel (mutex, semáforos), pero no son la solución completa.

Atomicidad no es sincronización

Hacer counter++ atómico resuelve el problema del contador, pero no el problema general de la sección crítica. Para proteger una sección de código con múltiples operaciones, se necesitan mutex o semáforos, que se estudian en los bloques 4 y 5.

11. Simulador de intercalados

Este simulador permite explorar los intercalados de dos hilos que ejecutan counter++. Cada hilo tiene 3 instrucciones (LOAD, ADD, STORE). El simulador genera intercalados aleatorios y muestra el resultado:

Simulador · intercalados de counter++

MedidaValor
Valor inicial0
Incrementos totales
Resultado esperado
Resultado obtenido
Incrementos perdidos

12. Experimento: ver el assembly

Este experimento muestra el código assembly generado por el compilador:

terminal · ver el assembly de counter++
$ cat > asm_test.c << 'EOF'
volatile int counter = 0;
void inc(void) { counter++; }
EOF
$ gcc -std=c17 -O0 -S -o asm_test.s asm_test.c
$ cat asm_test.s

inc:
    pushq   %rbp
    movq    %rsp, %rbp
    movl    counter(%rip), %eax    # LOAD
    addl    $1, %eax              # ADD
    movl    %eax, counter(%rip)    # STORE
    popq    %rbp
    ret
$ gcc -std=c17 -O2 -S -o asm_test2.s asm_test.c
$ cat asm_test2.s

inc:
    movl    counter(%rip), %eax
    addl    $1, %eax
    movl    %eax, counter(%rip)
    ret

El compilador genera las tres instrucciones (LOAD, ADD, STORE) tanto con como sin optimización. Con -O2 se eliminan las instrucciones de prólogo y epílogo (push/pop), pero las tres instrucciones de la operación siguen presentes.

Para generar la instrucción atómica, se usa __atomic_fetch_add o _Atomic en C11:

terminal · instrucción atómica
$ cat > atomic_test.c << 'EOF'
#include <stdatomic.h>
_Atomic int counter = 0;
void inc(void) { counter++; }
EOF
$ gcc -std=c17 -O2 -S -o atomic_test.s atomic_test.c
$ cat atomic_test.s

inc:
    lock addl   $1, counter(%rip)    # LOCK ADD: atómico
    ret

Con _Atomic, el compilador genera lock addl, que es atómico. Una sola instrucción en lugar de tres.

13. Resumen

14. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuántas instrucciones de máquina son counter++?

Tres: LOAD, ADD, STORE.

2. ¿Qué instrucción carga el valor de counter a un registro?

LOAD (o movl counter, %eax en x86).

3. ¿Qué instrucción incrementa el valor?

ADD (o addl $1, %eax en x86).

4. ¿Qué instrucción escribe el valor de vuelta a memoria?

STORE (o movl %eax, counter en x86).

5. ¿Por qué el resultado de una condición de carrera de incremento es siempre menor o igual?

Porque los incrementos se pierden (cuando dos hilos leen el mismo valor), no se crean.

6. ¿Cómo hace el hardware la operación atómica?

Con el prefijo LOCK, que bloquea el bus o invalida la caché de otros procesadores.

7. ¿Qué instrucción atómica genera el compilador con _Atomic?

lock addl (o lock incl).

8. ¿volatile hace la operación atómica?

No. Solo evita la optimización del compilador.

9. ¿El prefijo LOCK tiene costo?

Sí. Bloquea el bus de memoria o invalida la caché de otros procesadores.

10. ¿Las operaciones atómicas del hardware resuelven todos los problemas de concurrencia?

No. Solo resuelven operaciones simples. Para secciones críticas complejas se necesitan mutex o semáforos.

Para practicar

  1. Compila asm_test.c con -O0 y -O2 y compara el assembly.
  2. Compila atomic_test.c y verifica que se genera lock addl.
  3. Usa objdump -d para ver las instrucciones de counter++ en un programa compilado.
  4. Modifica el simulador para 3 hilos y observa los resultados.
  5. Investiga qué instrucciones de x86 soportan el prefijo LOCK.
  6. Explica por qué las operaciones atómicas por hardware no resuelven todos los problemas de concurrencia.