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:
- El código del programa (segmento de texto): todos los hilos ejecutan las mismas funciones.
- Las variables globales: cualquier variable declarada fuera de una función es visible y modificable por todos los hilos.
- El montón (heap): la memoria asignada con
malloces compartida. Si un hilo asigna memoria y pasa el puntero a otro, ambos acceden al mismo bloque. - Los descriptores de archivo: los archivos abiertos por un hilo son visibles para los demás.
- Las variables estáticas: las variables declaradas con
staticdentro de una función son compartidas entre todas las invocaciones, incluyendo las de distintos hilos.
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.
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:
- Pila (stack): cada hilo tiene su propia pila, donde se guardan las variables locales, los parámetros de las funciones y las direcciones de retorno. Una variable local de una función solo es visible para el hilo que ejecuta esa función.
- Registros de la CPU: cada hilo tiene su propio conjunto de registros (incluyendo el contador de programa y el puntero de pila). Cuando el planificador cambia de hilo, guarda y restaura los registros.
- Identificador de hilo (TID): cada hilo tiene un identificador único dentro del proceso.
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:
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:
- Un contador global:
int contador = 0;incrementado por varios hilos. - Una lista enlazada: insertar y eliminar elementos desde varios hilos.
- Un búfer: un productor escribe y un consumidor lee.
- Un caché: varios hilos leen y actualizan entradas.
- Un flag: un hilo lo activa y otro lo lee.
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.
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:
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:
- Hilo A lee counter (0). LOAD
- Hilo B lee counter (0). LOAD
- Hilo A suma 1 (R1 = 1). ADD
- Hilo B suma 1 (R1 = 1). ADD
- Hilo A escribe (counter = 1). STORE
- 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:
#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:
$ 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.
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:
| Modelo | Cómo funciona | Ventaja | Ejemplo |
|---|---|---|---|
| Memoria compartida | Los hilos comparten el espacio de direcciones (el modelo de este curso). | Rápido, flexible. | C con pthreads, Java threads. |
| Memoria por copia | Cada proceso/hilo tiene su propia copia de los datos. Se comunican por mensajes. | Sin condiciones de carrera. | Erlang, actores, Go channels. |
| Estado inmutable | Los 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:
# 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:
- 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 - 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
- Herramientas de análisis: ThreadSanitizer y Helgrind detectan carreras y mal uso de candados en programas reales. Linux
8. Resumen
- Los hilos de un proceso comparten el espacio de direcciones: código, globales, montón y descriptores.
- Cada hilo tiene su propia pila y registros.
- El estado mutable compartido es la fuente de casi todos los errores de concurrencia.
- Una operación como
counter++son tres instrucciones de máquina, y el planificador puede interrumpir entre ellas. - Los errores de concurrencia son no deterministas: dependen del intercalado.
volatileno es sincronización: no garantiza atomicidad ni visibilidad.- Otros modelos (memoria por copia, inmutabilidad, un solo hilo) evitan el problema a costa de restricciones.
- En Linux, todos los hilos comparten
/proc/PID/maps.
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
- Compila y ejecuta
compartir.cvarias veces. ¿El resultado es siempre el mismo? - Modifica
compartir.cpara usar una variable local en lugar de global. ¿El resultado cambia? - Añade
volatilea la variable y observa si el resultado mejora. - Usa
objdump -dpara ver las instrucciones de máquina decounter++. - Observa
/proc/PID/mapsde un proceso con varios hilos y busca las pilas. - Investiga por qué
volatileno es sincronización en C (a diferencia de Java).