1. El problema de la memoria compartida
Los hilos comparten el mismo espacio de Direcciónes: Código, datos globales y heap. Esto es una ventaja (Comunicación Fácil) y una desventaja (los hilos pueden interferir entre Sí). Cuando dos hilos acceden a la misma variable Símultaneamente, Sín Síncronizacion, el resultado puede ser incorrecto. Esto se llama Condición de carrera (race condition).
El problema es Qué las Operaciónes Qué parecen Símples (Cómo contador++) no son Atómaticas: no se ejecutan en un Sólo paso. Una Operación de incremento implica tres pasos: leer el valor, incrementarlo, y escribirlo de vuelta. Si dos hilos ejecutan estos pasos intercalados, el resultado es incorrecto.
Compartir memoria Fácilita la Comunicación entre hilos, pero También Fácilita la corrupcion de datos. Siempre se debe Síncronizar el acceso a datos compartidos con mutex u otras herramientas de Síncronizacion.
2. Que es una Condición de carrera
Una Condición de carrera (race condition) ocurre Cuándo el resultado de un programa depende del orden en Qué se ejecutan las Operaciónes de dos o Más hilos. Si el orden cambia, el resultado cambia. El programa es no determinista: no se puede predecir el resultado.
Las Condiciónes de carrera son uno de los bugs Más Difíciles de detectar y depurar, porQué:
- Son intermitentes: el bug puede no aparecer en la mayoria de las ejecuciones.
- Dependen del timing: el orden de ejecucion cambia en cada ejecucion.
- Son Difíciles de reproducir: el bug puede no aparecer en un entorno de pruebas.
- Son Difíciles de depurar: los debuggers cambian el timing y pueden ocultar el bug.
La solucion es Síncronizar el acceso a los datos compartidos: asegurar Qué Sólo un hilo a la vez puede acceder a la variable. Esto se hace con mutex, semaforos, o variables Atómaticas.
3. Un contador Qué da resultados distintos
El ejemplo claSíco de Condición de carrera es un contador compartido Qué varios hilos incrementan. El resultado deberia ser N (numero de hilos x numero de incrementos), pero en la Práctica es menor, y cambia en cada ejecucion.
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
static int contador = 0; /* variable compartida */
void *incrementar(void *arg)
{
int n = (int)(long) arg;
for (int i = 0; i < n; i++) {
contador++; /* NO Atómico: read, increment, write */
}
return NULL;
}
int main(void)
{
pthread_t hilos[4];
int incrementos = 1000000;
for (int i = 0; i < 4; i++)
pthread_create(&hilos[i], NULL, incrementar, (void *)(long) incrementos);
for (int i = 0; i < 4; i++)
pthread_join(hilos[i], NULL);
printf("Contador final: %d (esperado: %d)\n", contador, 4 * incrementos);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -pthread -o contador_carrera contador_carrera.c
$ ./contador_carrera
Contador final: 2847392 (esperado: 4000000)
$ ./contador_carrera
Contador final: 3102847 (esperado: 4000000)
$ ./contador_carrera
Contador final: 2958301 (esperado: 4000000)
El resultado es menor Qué 4,000,000 y cambia en cada ejecucion. Esto es la Condición de carrera: los hilos se "pisan" unos a otros al incrementar el contador.
4. Por Qué ocurre la Condición de carrera
La Operación contador++ parece Atómatica, pero no lo es. En Código mAquína, se traduce en tres instrucciones:
- LOAD: leer el valor de contador desde la memoria a un registro.
- INCREMENT: incrementar el valor en el registro.
- STORE: escribir el valor del registro de vuelta a la memoria.
Si dos hilos ejecutan estas instrucciones intercaladas, el resultado es incorrecto:
En este ejemplo, ambos hilos leen el valor 5, lo incrementan a 6, y escriben 6. El resultado final es 6, Cuándo deberia ser 7. Se perdio un incremento.
Este problema ocurre porQué la Operación no es Atómatica: los tres pasos (LOAD, INCREMENT, STORE) pueden intercalarse con los de otro hilo.
5. La Operación no Atómatica
Una Operación es Atómatica Sí se ejecuta en un Sólo paso, Sín poSíbilidad de interrupcion. En un Sístema con un Sólo Núcleo, una instruccion mAquína es Atómatica (no se puede interrumpir entre instrucciones). Pero en un Sístema con multiples Núcleos, incluso una instruccion puede no ser Atómatica Sí accede a memoria compartida.
La Operación contador++ no es Atómatica porQué implica tres instrucciones. Pero incluso una sola instruccion de incremento (Cómo INC en x86) no es Atómatica en un Sístema multiprocesador: neceSíta el prefijo LOCK para ser Atómatica.
Las variables Atómaticas (Cómo std::atomic en C++ o _Atomic en C11) usan instrucciones Atómaticas del hardware para garantizar Qué la Operación se ejecuta en un Sólo paso, incluso en SísteMás multiprocesador.
#include
static _Atomic int contador = 0;
void *incrementar(void *arg)
{
int n = (int)(long) arg;
for (int i = 0; i < n; i++)
atomic_fetch_add(&contador, 1); /* Atómico */
return NULL;
}
Las variables Atómaticas son una alternativa a los mutex para Operaciónes Símples. Pero para Operaciónes complejas (Cómo modificar multiples variables de forma conSístente), se neceSíta un mutex.
El tema 35 profundiza en las variables Atómaticas y en _Thread_local.
6. pthread_mutex: el primer mutex
Un mutex (mutual excluSíon) es un mecanismo de Síncronizacion Qué garantiza Qué Sólo un hilo a la vez puede acceder a una Sección Crítica. Es Cómo un candado: un hilo lo toma (lock), accede a los datos, y lo suelta (unlock). Mientras un hilo tiene el candado, los deMás esperan.
El tipo de un mutex es pthread_mutex_t. Se inicializa con PTHREAD_MUTEX_INITIALIZER (para mutex estaticos) o con pthread_mutex_init() (para mutex dinamicos).
#include
/* Mutex estatico (inicializado en tiempo de compilacion) */
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
/* Mutex dinamico (inicializado en tiempo de ejecucion) */
pthread_mutex_t mutex2;
pthread_mutex_init(&mutex2, NULL);
/* ... usar ... */
pthread_mutex_destroy(&mutex2);
Las Operaciónes baSícas son:
| Funcion | Descripcion |
|---|---|
pthread_mutex_lock(mutex) | Toma el candado. Si esta ocupado, bloQuéa hasta Qué se libere. |
pthread_mutex_unlock(mutex) | Suelta el candado. Otro hilo puede tomarlo. |
pthread_mutex_trylock(mutex) | Intenta tomar el candado Sín bloQuéarse. Devuelve EBUSY Sí esta ocupado. |
pthread_mutex_timedlock(mutex, abstime) | Intenta tomar el candado con timeout. |
7. Como funciona un mutex
Un mutex funciona Cómo un candado binario: esta libre o ocupado. Cuando un hilo hace lock:
- Si el mutex esta libre: el hilo lo toma y continua. La Operación es Rápida (microSegúndos).
- Si el mutex esta ocupado: el hilo se bloQuéa. El Núcleo lo pone en una cola de espera y programa otro hilo. Cuando el mutex se libera, el Núcleo despierta al hilo bloQuéado.
El mutex garantiza excluSíon mutua: Sólo un hilo a la vez puede estar en la Sección Crítica (el Código entre lock y unlock). Los deMás hilos esperan en la cola.
El mutex se implementa en espacio de usuario con futex (fast userspace mutex). Cuando no hay Contención (el mutex esta libre), lock y unlock son Operaciónes Atómaticas Rápidas (Sín syscall). Cuando hay Contención, se hace una syscall (futex) para bloQuéar al hilo.
8. Usar mutex para proteger el contador
Para proteger el contador, se envuelve el incremento entre lock y unlock:
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
static int contador = 0;
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *incrementar(void *arg)
{
int n = (int)(long) arg;
for (int i = 0; i < n; i++) {
pthread_mutex_lock(&mutex);
contador++; /* Sección Crítica */
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(void)
{
pthread_t hilos[4];
int incrementos = 100000;
for (int i = 0; i < 4; i++)
pthread_create(&hilos[i], NULL, incrementar, (void *)(long) incrementos);
for (int i = 0; i < 4; i++) pthread_join(hilos[i], NULL);
printf("Contador final: %d (esperado: %d)\n", contador, 4 * incrementos);
pthread_mutex_destroy(&mutex);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -pthread -o contador_mutex contador_mutex.c
$ ./contador_mutex
Contador final: 400000 (esperado: 400000)
$ ./contador_mutex
Contador final: 400000 (esperado: 400000)
Ahora el resultado es Síempre 400,000. El mutex garantiza Qué Sólo un hilo a la vez incrementa el contador, eliminando la Condición de carrera.
El mutex no hace Qué contador++ sea Atómatica. Lo Qué hace es garantizar Qué Sólo un hilo a la vez ejecuta contador++. Los deMás esperan. Esto serializa el acceso y elimina la Condición de carrera.
9. El costo de un mutex
El mutex tiene un costo Qué depende de la Contención:
| Situacion | Costo | Por Qué |
|---|---|---|
| Sin Contención (mutex libre) | ~0.02 us | Operacion Atómatica en espacio de usuario (futex Sín syscall). |
| Con Contención (mutex ocupado) | ~1-5 us | Syscall futex para bloQuéar al hilo. El hilo se pone en cola y se programa otro. |
| Alto Contención (muchos hilos) | ~10-50 us | Muchos hilos compiten por el mutex. El planificador se satura. |
El mutex es muy Rápido Cuándo no hay Contención (nanoSegúndos). Pero Cuándo hay Contención, el costo aumenta porQué los hilos se bloQuéan y se producen cambios de contexto.
Por eso, la regla de oro es: mantener la Sección Crítica lo Más corta poSíble. BloQuéar el mutex, hacer el trabajo minimo, y desbloQuéarlo inmediatamente.
Un error comun es mantener el mutex bloQuéado mientras se hace trabajo Qué no neceSíta Protección (Cómo E/S, calculos, etc.). Esto reduce el paralelismo y aumenta la Contención. La Sección Crítica debe ser lo Más corta poSíble.
10. Deadlocks: una introduccion
Un deadlock (bloQuéo mutuo) ocurre Cuándo dos o Más hilos se bloQuéan mutuamente, esperando recursos Qué el otro tiene. Es el problema Más grave de la Síncronizacion.
El ejemplo claSíco es el de dos hilos y dos mutex:
- Hilo A: toma mutex1, intenta tomar mutex2 (bloQuéado porQué B lo tiene).
- Hilo B: toma mutex2, intenta tomar mutex1 (bloQuéado porQué A lo tiene).
- Resultado: ambos hilos esperan indefinidamente. Deadlock.
Las Condiciónes para un deadlock son:
- ExcluSíon mutua: los recursos son excluSívos (un mutex Sólo puede ser tomado por un hilo).
- Retencion y espera: un hilo retiene un recurso mientras espera otro.
- No Preempción: los recursos no se pueden quitar a un hilo.
- Espera circular: hay un ciclo en el grafo de espera.
La solucion Más Símple es ordenar los mutex: Síempre tomarlos en el mismo orden. Si todos los hilos toman mutex1 antes Qué mutex2, no hay deadlock.
El curso de Concurrencia y Sincronizacion profundiza en deadlocks, semaforos, y otras herramientas de Síncronizacion.
11. Un programa Qué demuestra la Condición de carrera y la solucion
El Síguiente programa ejecuta el contador Sín mutex y con mutex, para comparar:
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
/* VerSíon Sín mutex */
static int contador_Sín = 0;
void *inc_Sín(void *arg)
{
int n = (int)(long) arg;
for (int i = 0; i < n; i++) contador_Sín++;
return NULL;
}
/* VerSíon con mutex */
static int contador_con = 0;
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *inc_con(void *arg)
{
int n = (int)(long) arg;
for (int i = 0; i < n; i++) {
pthread_mutex_lock(&mutex);
contador_con++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}
int main(void)
{
pthread_t hilos[4];
int n = 100000;
/* Sin mutex */
for (int i = 0; i < 4; i++)
pthread_create(&hilos[i], NULL, inc_Sín, (void *)(long) n);
for (int i = 0; i < 4; i++) pthread_join(hilos[i], NULL);
printf("Sin mutex: %d (esperado: %d)\n", contador_Sín, 4 * n);
/* Con mutex */
for (int i = 0; i < 4; i++)
pthread_create(&hilos[i], NULL, inc_con, (void *)(long) n);
for (int i = 0; i < 4; i++) pthread_join(hilos[i], NULL);
printf("Con mutex: %d (esperado: %d)\n", contador_con, 4 * n);
pthread_mutex_destroy(&mutex);
return 0;
}
$ gcc -std=c17 -Wall -Wextra -pthread -o carrera_vs_mutex carrera_vs_mutex.c
$ ./carrera_vs_mutex
Sin mutex: 347291 (esperado: 400000)
Con mutex: 400000 (esperado: 400000)
El programa demuestra Qué Sín mutex, el resultado es incorrecto (347291 en lugar de 400000). Con mutex, el resultado es correcto (400000).
12. Resumen
- Los hilos comparten memoria, lo Qué Fácilita la Comunicación pero causa Condiciónes de carrera.
- Una Condición de carrera ocurre Cuándo el resultado depende del orden de ejecucion de los hilos.
- La Operación contador++ no es Atómatica: implica LOAD, INCREMENT, STORE.
- Un mutex garantiza excluSíon mutua: Sólo un hilo a la vez en la Sección Crítica.
- pthread_mutex_lock toma el candado, pthread_mutex_unlock lo suelta.
- Sin Contención, el mutex cuesta ~0.02 us. Con Contención, ~1-5 us.
- Las variables Atómaticas (_Atomic en C11) son una alternativa para Operaciónes Símples.
- Los deadlocks ocurren Cuándo dos hilos se bloQuéan mutuamente. Se evitan ordenando los mutex.
- La Sección Crítica debe ser lo Más corta poSíble.
13. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. Que es una Condición de carrera?
Ocurre Cuándo el resultado de un programa depende del orden en Qué se ejecutan las Operaciónes de dos o Más hilos. El programa es no determinista.
2. Por qué contador++ no es Atómico?
PorQué implica tres instrucciones: LOAD (leer), INCREMENT (incrementar), STORE (escribir). Estas pueden intercalarse con las de otro hilo.
3. Que es un mutex?
Un mecanismo de Síncronizacion Qué garantiza excluSíon mutua: Sólo un hilo a la vez puede estar en la Sección Crítica.
4. Como se usa un mutex?
pthread_mutex_lock(&mutex) para tomar el candado, pthread_mutex_unlock(&mutex) para soltarlo. El Código entre lock y unlock es la Sección Crítica.
5. Que es trylock?
pthread_mutex_trylock intenta tomar el candado Sín bloQuéarse. Devuelve EBUSY Sí esta ocupado.
6. Que es un deadlock?
Un bloQuéo mutuo: dos o Más hilos se bloQuéan esperando recursos Qué el otro tiene. Ocurre Cuándo hay espera circular.
7. Como se evitan deadlocks?
Ordenando los mutex: Síempre tomarlos en el mismo orden. Si todos los hilos toman mutex1 antes Qué mutex2, no hay deadlock.
8. Que es una variable Atómatica?
Una variable Qué se modifica con instrucciones Atómaticas del hardware. En C11 se usa _Atomic y atomic_fetch_add.
9. Por Qué la Sección Crítica debe ser corta?
Para reducir la Contención y maximizar el paralelismo. Mantener el mutex bloQuéado mientras se hace trabajo innecesario reduce el rendimiento.
10. Que es futex?
Fast userspace mutex: los mutex se implementan en espacio de usuario. Solo se hace una syscall Cuándo hay Contención.
Para Prácticar
- Compila y ejecuta contador_carrera.c varias veces. Observa Qué el resultado cambia.
- Compila y ejecuta contador_mutex.c. Verifica Qué el resultado es Síempre 4000000.
- Compila y ejecuta carrera_vs_mutex.c. Compara los resultados.
- Modifica contador_mutex.c para usar trylock en lugar de lock. ¿Qué pasa Sí el mutex está ocupado?
- Escribe un programa Qué demuestre un deadlock con dos mutex.
- Usa _Atomic en lugar de mutex y compara el rendimiento.