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

Tema 2

Hilos que comparten datos

Los hilos de un proceso no son procesos independientes: comparten casi toda la memoria. Eso es lo que los hace útiles (pueden colaborar sin copiar datos) y lo que los hace peligrosos (cualquier hilo puede modificar lo que otro está leyendo). En este tema verás exactamente qué comparten y qué no, qué es el estado mutable compartido, por qué es la fuente de casi todos los errores de concurrencia y cómo se observa en un programa real.

1. Qué comparten los hilos

Cuando se crea un hilo con pthread_create, el nuevo hilo recibe su propio contexto de ejecución (registros, contador de programa, pila), pero comparte con el hilo principal y con los demás hilos del mismo proceso todo el espacio de direcciones. Eso significa que comparten:

Esta compartición es lo que hace a los hilos ligeros y rápidos de crear: no hay que copiar datos ni configurar un nuevo espacio de direcciones. Un proceso nuevo (con fork) recibe una copia del espacio de direcciones; un hilo nuevo (con pthread_create) comparte el mismo.

Compartir es la razón de ser de los hilos

Si los hilos no compartieran memoria, serían como procesos independientes y no tendrían ventaja sobre ellos. La compartición es la fuente de su potencia y de su peligro.

2. Qué no comparten

A pesar de compartir el espacio de direcciones, cada hilo tiene su propio:

La pila separada es fundamental: si dos hilos ejecutaran la misma función recursiva, cada uno tendría su propia pila de llamadas. Sin pila separada, la recursión de un hilo corrompería la de otro.

Este diagrama resume qué se comparte y qué no:

Diagrama de lo que comparten los hilos: código, datos globales, montón y descriptores de archivo son compartidos; la pila y los registros son privados. PROCESO (espacio de direcciones compartido) COMPARTIDO por todos los hilos Código Globales Montón (heap) Descriptores Cualquier hilo puede leer y modificar estos datos en cualquier momento. No hay protección automática entre hilos del mismo proceso. CADA HILO (privado) Hilo 1 Pila Registros Hilo 2 Pila Registros Hilo 3 Pila Registros
Lo compartido es el espacio de direcciones (código, globales, montón, descriptores). Lo privado de cada hilo es su pila y sus registros.

3. El estado mutable compartido

El estado mutable compartido es cualquier dato que varios hilos pueden leer y modificar. Es la fuente de casi todos los errores de concurrencia: cuando dos hilos acceden al mismo dato y al menos uno lo modifica, el resultado depende del orden de ejecución.

No todo lo compartido es peligroso. Las constantes, los datos de solo lectura y las variables locales (pila) no causan problemas. El peligro está en el estado mutable: datos que se modifican durante la ejecución y que son accesibles por más de un hilo.

Ejemplos de estado mutable compartido:

La regla práctica es: si un dato se comparte entre hilos y al menos uno lo modifica, hay que protegerlo. Los datos de solo lectura no necesitan protección.

Estado mutable compartido = peligro

Si un dato es compartido y mutable, y no se protege, el programa tiene una condición de carrera. La protección se hace con operaciones atómicas, mutex, semáforos u otras herramientas de sincronización, que se estudian en los bloques siguientes.

4. Por qué es peligroso

El estado mutable compartido es peligroso porque el planificador puede interrumpir un hilo en cualquier punto de su ejecución. Cuando un hilo modifica un dato, la operación suele comprender varias instrucciones de máquina (leer, modificar, escribir). Si el planificador interrumpe al hilo entre la lectura y la escritura, otro hilo puede leer o modificar el mismo dato, causando un resultado incorrecto.

El ejemplo clásico es counter++. A nivel de código fuente parece una sola operación, pero a nivel de máquina son tres instrucciones:

assembly · lo que hace counter++
LOAD  R1, [counter]     ; leer el valor de counter a R1
ADD   R1, R1, 1          ; incrementar R1
STORE R1, [counter]      ; escribir R1 a counter

Si dos hilos ejecutan counter++ simultáneamente, los siguientes intercalados son posibles:

  1. Hilo A lee counter (0). LOAD
  2. Hilo B lee counter (0). LOAD
  3. Hilo A suma 1 (R1 = 1). ADD
  4. Hilo B suma 1 (R1 = 1). ADD
  5. Hilo A escribe (counter = 1). STORE
  6. Hilo B escribe (counter = 1). STORE

El resultado es 1, pero deberían ser 2. Un incremento se perdió. Este es el error más simple de concurrencia y el que verás en el tema 3.

Lo importante es que el error no ocurre siempre: depende del intercalado exacto, que cambia en cada ejecución. Por eso los errores de concurrencia son no deterministas y difíciles de reproducir.

5. Experimento: compartir una variable

El siguiente programa demuestra el problema. Dos hilos incrementan una variable global counter un millón de veces cada uno. El resultado esperado es 2.000.000, pero rara vez se obtiene:

compartir.c
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

static volatile int counter = 0;          /* compartido entre los dos hilos */

static void *incrementar(void *arg)
{
    int n = *(int *)arg;
    for (int i = 0; i < n; i++)
        counter++;                          /* no es atómico */
    return NULL;
}

int main(int argc, char *argv[])
{
    int n = argc > 1 ? atoi(argv[1]) : 1000000;
    pthread_t h1, h2;

    pthread_create(&h1, NULL, incrementar, &n);
    pthread_create(&h2, NULL, incrementar, &n);
    pthread_join(h1, NULL);
    pthread_join(h2, NULL);

    printf("esperado: %d   obtenido: %d\n", 2 * n, counter);
    return 0;
}

Compilamos y ejecutamos varias veces:

terminal · el resultado cambia en cada ejecución
$ gcc -std=c17 -Wall -Wextra -pthread -o compartir compartir.c
$ for i in 1 2 3 4 5; do ./compartir 1000000; done
esperado: 2000000   obtenido: 1384721
esperado: 2000000   obtenido: 1523890
esperado: 2000000   obtenido: 1209453
esperado: 2000000   obtenido: 1456782
esperado: 2000000   obtenido: 1312094

El resultado es siempre menor que 2.000.000 y diferente en cada ejecución. La pérdida se debe a que los incrementos se pisan: cuando dos hilos leen el mismo valor, ambos escriben el mismo resultado, perdiendo un incremento.

Fíjate en el volatile: se usó para evitar que el compilador optimice el acceso a counter, pero volatile no es sincronización. No evita la condición de carrera; solo asegura que el compilador no cachee el valor en un registro. En C, volatile no garantiza atomicidad ni visibilidad entre hilos.

volatile no es sincronización

En C, volatile no garantiza que un acceso sea atómico ni que sea visible para otros hilos. Solo evita la optimización del compilador. Para sincronización real se necesitan operaciones atómicas o mutex, que se estudian en los bloques 3 y 4.

5. Otros modelos de concurrencia

No todos los lenguajes usan el modelo de hilos que comparten memoria. Existen otros modelos que evitan o reducen el problema:

ModeloCómo funcionaVentajaEjemplo
Memoria compartidaLos hilos comparten el espacio de direcciones (el modelo de este curso).Rápido, flexible.C con pthreads, Java threads.
Memoria por copiaCada proceso/hilo tiene su propia copia de los datos. Se comunican por mensajes.Sin condiciones de carrera.Erlang, actores, Go channels.
Estado inmutableLos datos no se modifican; se crean nuevos. Los hilos comparten referencias de solo lectura.Sin condiciones de carrera.Programación funcional (Haskell, Clojure).
Un solo hilo (event loop)Un solo hilo ejecuta todo el código. La concurrencia se logra con E/S asíncrona.Sin condiciones de carrera (en el código de usuario).Node.js, JavaScript en el navegador.

El modelo de memoria compartida es el más usado en sistemas operativos y el que más errores produce. Los otros modelos evitan el problema a costa de restricciones (inmutabilidad, copia, un solo hilo).

En este curso nos centramos en el modelo de memoria compartida porque es el que usa C con pthreads y el que usan los sistemas operativos. Los principios aprendidos aquí se aplican a los demás modelos.

6. Observar en Linux

Para observar cómo los hilos comparten memoria en Linux:

terminal · memoria compartida entre hilos
# Ver el espacio de direcciones de un proceso
$ cat /proc/PID/maps | head -20

# Ver las variables compartidas (si se exportan símbolos)
$ nm ./compartir | grep counter

# Ver los hilos del proceso
$ ps -L -o pid,lwp,nlwp,stat,pcpu,comm -p PID

# Ver la memoria compartida entre hilos (misma entrada en /proc/PID/maps)
$ ls /proc/PID/task/

Todos los hilos de un proceso tienen el mismo /proc/PID/maps: comparten el mismo espacio de direcciones. La pila de cada hilo aparece como una región separada en /proc/PID/maps (con el flag [stack:tid]).

7. Cómo trabajaremos

El curso se apoya en lo que sabes de Procesos e Hilos y en el C de Programación en C para Sistemas Operativos. Combina tres tipos de actividad:

  1. Programas con pthreads en C: cada herramienta de sincronización se prueba con un programa real, primero mostrando el problema y después la solución. Compilan con gcc -std=c17 -Wall -Wextra -pthread. C
  2. Simuladores en JavaScript: como los errores de concurrencia no se reproducen a voluntad, los simuladores permiten recorrer los intercalados paso a paso, forzar el peor caso y ver el estado de cada hilo. JavaScript
  3. Herramientas de análisis: ThreadSanitizer y Helgrind detectan carreras y mal uso de candados en programas reales. Linux

8. Resumen

9. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué comparten los hilos de un proceso?

El espacio de direcciones: código, variables globales, montón (heap) y descriptores de archivo.

2. ¿Qué no comparten los hilos?

La pila (stack), los registros de la CPU y el identificador de hilo (TID).

3. ¿Qué es el estado mutable compartido?

Datos que varios hilos pueden leer y modificar. Es la fuente de casi todos los errores de concurrencia.

4. ¿Por qué es peligroso el estado mutable compartido?

Porque el planificador puede interrumpir un hilo entre las instrucciones de lectura, modificación y escritura, causando resultados incorrectos.

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

Tres: LOAD, ADD, STORE.

6. ¿volatile evita las condiciones de carrera?

No. Solo evita la optimización del compilador. No garantiza atomicidad ni visibilidad entre hilos.

7. ¿Por qué los errores de concurrencia son difíciles de reproducir?

Porque dependen del intercalado exacto de las instrucciones, que cambia en cada ejecución.

8. ¿Qué modelo de concurrencia evita el estado mutable compartido?

La memoria por copia (actores, canales), el estado inmutable (programación funcional) y el event loop (un solo hilo).

9. ¿Cómo se observa que los hilos comparten memoria en Linux?

Todos los hilos tienen el mismo /proc/PID/maps.

10. ¿Qué es una variable estática en C y por qué es compartida?

Una variable declarada con static dentro de una función. Es compartida entre todas las invocaciones, incluyendo las de distintos hilos.

Para practicar

  1. Compila y ejecuta compartir.c varias veces. ¿El resultado es siempre el mismo?
  2. Modifica compartir.c para usar una variable local en lugar de global. ¿El resultado cambia?
  3. Añade volatile a la variable y observa si el resultado mejora.
  4. Usa objdump -d para ver las instrucciones de máquina de counter++.
  5. Observa /proc/PID/maps de un proceso con varios hilos y busca las pilas.
  6. Investiga por qué volatile no es sincronización en C (a diferencia de Java).