Fundamentos de la coordinación de hilos
La ejecución paarlela de hilos dentro de un proceso permite aprovechar múltiples núcleos de CPU, pero introduce riesgos cuando varios flujos de ejecución acceden simultáneamente a memoria compartida o recursos globales. Sin un mecanismo de coordinación, se producen condiciones de carrera (race conditions), donde el estado final del sistema depende del orden de ejecución no determinista. Para garantizar consistencia y evitar fallos críticos como interbloqueos (deadlocks) o bloqueos progresivos (livelocks), el sistema operativo y las bibliotecas estándar proporcionan primitivas de sincronización que regulan el acceso a las regiones críticas.
Primitivas de sincronización en POSIX y C++
El estándar POSIX, implementado nativamente en Linux, ofrece un conjunto de herramientas de bajo y medio nivel para gestionar la concurrencia. Estas primitivas se agrupan según su modelo de exclusión y comportamiento ante la contención:
- Exclusión mutua (Mutex): Acceso serializado a una región crítica.
- Variables de condición: Notificación de eventos sin consumo activo de CPU.
- Semáforos: Control de capacidad concurrente mediante contadores.
- Cerrojos lectura-escritura: Optimización para patrones con predominio de lectura.
- Cerrojos de espera activa (Spinlocks): Bloqueo en usuario con bucle de sondeo.
- Barreras: Punto de encuentro para sincronizar fases de ejecución.
- Operaciones atómicas: Modificaciones indivisibles a nivel de hardware.
- Exclusión mutua (Mutex)
El mutex implementa un protocolo de propiedad exclusiva: solo el hilo que adquiere el cerrojo puede entrar en la sección crítica, mientras los demás se suspenden en el planificador del núcleo hasta su liberación. Es la base para proteger estructuras de datos simples.
Interfaces POSIX
pthread_mutex_init(): Configuración del cerrojo.pthread_mutex_lock(): Adquisición bloqueante.pthread_mutex_trylock(): Intento no bloqueante con retorno inmediato.pthread_mutex_unlock(): Liberación del recurso.pthread_mutex_destroy(): Limpieza de la estructura.
Ejemplo de implementación
#include <iostream>
#include <pthread.h>
static pthread_mutex_t seguro_dato = PTHREAD_MUTEX_INITIALIZER;
static int acumulador_global = 0;
void* tarea_sumadora(void* args) {
pthread_mutex_lock(&seguro_dato);
acumulador_global += 100;
pthread_mutex_unlock(&seguro_dato);
return nullptr;
}
int main() {
pthread_t hilo_a, hilo_b;
pthread_create(&hilo_a, nullptr, tarea_sumadora, nullptr);
pthread_create(&hilo_b, nullptr, tarea_sumadora, nullptr);
pthread_join(hilo_a, nullptr);
pthread_join(hilo_b, nullptr);
std::cout << "Total seguro: " << acumulador_global << std::endl;
return 0;
}
Características operativas
Ventajas: Implementación directa, alta fiabilidad para datos simples, evita corrupción por acceso simultáneo.
Limitaciones: Suspensión de hilos genera cambios de contexto; orden incorrecto de adquisición puede desencadenar interbloqueos.
Uso típico: Actualización de variables compartidas, escritura en archivos de registro, protección de cachés en memoria.
- Variables de condición
Las variables de condición permiten que los hilos suspendan su ejecución hasta que se cumpla un predicado específico. Deben usarse obligatoriamente asociadas a un mutex para evitar condiciones de carrera durante la evaluación del estado.
Interfaces POSIX
pthread_cond_init(): Preparación de la señal.pthread_cond_wait(): Suspensión atómica (libera mutex y entra en espera).pthread_cond_signal(): Despertar a un único hilo en espera.pthread_cond_broadcast(): Despertar a todos los hilos pendientes.pthread_cond_destroy(): Liberación de recursos.
Ejemplo de implementación
#include <iostream>
#include <pthread.h>
static pthread_mutex_t candado_sincro = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t aviso_proceso = PTHREAD_COND_INITIALIZER;
static bool recurso_disponible = false;
void* consumidor_esperando(void* arg) {
pthread_mutex_lock(&candado_sincro);
while (!recurso_disponible) {
pthread_cond_wait(&aviso_proceso, &candado_sincro);
}
std::cout << "Procesamiento iniciado tras señal." << std::endl;
pthread_mutex_unlock(&candado_sincro);
return nullptr;
}
void* productor_notificando(void* arg) {
pthread_mutex_lock(&candado_sincro);
recurso_disponible = true;
pthread_cond_signal(&aviso_proceso);
pthread_mutex_unlock(&candado_sincro);
return nullptr;
}
int main() {
pthread_t h_prod, h_cons;
pthread_create(&h_prod, nullptr, productor_notificando, nullptr);
pthread_create(&h_cons, nullptr, consumidor_esperando, nullptr);
pthread_join(h_prod, nullptr);
pthread_join(h_cons, nullptr);
return 0;
}
Características operativas
Ventajas: Elimina el consumo de CPU por sondeo, permite orquestación compleja entre productores y consumidores.
Limitaciones: Requiere mutex externo; puede sufrir despertares espurios, por lo que la evaluación debe realizarse siempre dentro de un bucle while.
Uso típico: Modelos productor-consumidor, colas de tareas, notificación de finalización de procesos asíncronos.
- Semáforos POSIX
Los semáforos operan mediante un contador interno que regula cuántos hilos pueden acceder simultáneamente a un conjunto de recursos. Cuando el contador alcanza cero, las peticiones adicionales se bloquean hasta que otro hilo incremente el valor.
Interfaces POSIX
sem_init(): Configuración con valor inicial.sem_wait(): Decremento bloqueante (adquisición).sem_post(): Incremento no bloqueante (liberación).sem_destroy(): Limpieza.
Ejemplo de implementación
#include <iostream>
#include <semaphore.h>
#include <pthread.h>
static sem_t cupos_pool;
static int conexiones_activas = 0;
void* solicitar_enlace(void* arg) {
sem_wait(&cupos_pool);
conexiones_activas++;
std::cout << "Conexión " << conexiones_activas << " establecida." << std::endl;
conexiones_activas--;
sem_post(&cupos_pool);
return nullptr;
}
int main() {
pthread_t t1, t2, t3;
sem_init(&cupos_pool, 0, 2);
pthread_create(&t1, nullptr, solicitar_enlace, nullptr);
pthread_create(&t2, nullptr, solicitar_enlace, nullptr);
pthread_create(&t3, nullptr, solicitar_enlace, nullptr);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
sem_destroy(&cupos_pool);
return 0;
}
Características operativas
Ventajas: Permite control de concurrencia >1, ideal para pools de recursos o limitación de tasa.
Limitaciones: Dificultad para depurar contadores compartidos; no gestiona prioridades, lo que puede causar inanición.
Uso típico: Pools de conexiones a bases de datos, limitación de hilos de E/S, gestión de búferes cíclicos.
- Cerrojos de lectura y escritura
Esta primitiva distingue entre operaciones de consulta y modificación. Múltiples hilos pueden mantener cerrojos de lectura simultáneamente, pero los de escritura son exclusivos. Optimiza rendimiento en cargas asimétricas.
Interfaces POSIX
pthread_rwlock_init(): Inicialización.pthread_rwlock_rdlock(): Adquisición de lectura.pthread_rwlock_wrlock(): Adquisición de escritura exclusiva.pthread_rwlock_unlock(): Liberación genérica.pthread_rwlock_destroy(): Destrucción.
Ejemplo de implementación
#include <iostream>
#include <pthread.h>
static pthread_rwlock_t cerrojo_datos = PTHREAD_RWLOCK_INITIALIZER;
static int valor_tabla = 42;
void* consultar_memoria(void* arg) {
pthread_rwlock_rdlock(&cerrojo_datos);
std::cout << "Lectura actual: " << valor_tabla << std::endl;
pthread_rwlock_unlock(&cerrojo_datos);
return nullptr;
}
void* modificar_memoria(void* arg) {
pthread_rwlock_wrlock(&cerrojo_datos);
valor_tabla = valor_tabla * 2;
std::cout << "Valor actualizado a: " << valor_tabla << std::endl;
pthread_rwlock_unlock(&cerrojo_datos);
return nullptr;
}
int main() {
pthread_t lectores[3], escritor;
pthread_create(&escritor, nullptr, modificar_memoria, nullptr);
for(int i = 0; i < 3; i++) {
pthread_create(&lectores[i], nullptr, consultar_memoria, nullptr);
}
pthread_join(escritor, nullptr);
for(int i = 0; i < 3; i++) {
pthread_join(lectores[i], nullptr);
}
pthread_rwlock_destroy(&cerrojo_datos);
return 0;
}
Características operativas
Ventajas: Maximiza rendimiento en escenarios read-heavy, evita bloqueo innecesario de lectores concurrentes.
Limitaciones: Riesgo de inanición de escritores si la carga de lectura es continua; sobrecarga mayor que un mutex simple en escrituras frecuentes.
Uso típico: Cachés de configuración, diccionarios de rutas, estadísticas en tiempo real.
- Cerrojos de espera activa (Spinlocks)
En lugar de delegar la espera al planificador del kernel, un spinlock mantiene al hilo en un bucle de instrucciones ocupando ciclos de CPU hasta que la varible de estado cambie. Eficaz cuando el tiempo de espera es inferior al coste de un cambio de contexto.
Interfaces POSIX
pthread_spin_init(): Preparación del cerrojo.pthread_spin_lock(): Bucle de sondeo hasta adquisición.pthread_spin_unlock(): Liberación.pthread_spin_destroy(): Limpieza.
Ejemplo de implementación
#include <iostream>
#include <pthread.h>
static pthread_spinlock_t cerrojo_rapido;
static int estado_bandera = 0;
void* ajustar_parametro(void* arg) {
pthread_spin_lock(&cerrojo_rapido);
estado_bandera = 1;
pthread_spin_unlock(&cerrojo_rapido);
return nullptr;
}
int main() {
pthread_spin_init(&cerrojo_rapido, PTHREAD_PROCESS_PRIVATE);
pthread_t h1, h2;
pthread_create(&h1, nullptr, ajustar_parametro, nullptr);
pthread_create(&h2, nullptr, ajustar_parametro, nullptr);
pthread_join(h1, nullptr);
pthread_join(h2, nullptr);
pthread_spin_destroy(&cerrojo_rapido);
std::cout << "Estado final: " << estado_bandera << std::endl;
return 0;
}
Características operativas
Ventajas: Latencia mínima, elimina cambios de contexto y sobrecarga del planificador.
Limitaciones: Consume ciclos de CPU en espera; degrada rendimiento severamente en contención prolongada o en núcleos compartidos (hyper-threading).
Uso típico: Rutas críticas en kernels, actualización de flags de control, interrupciones de software en entornos de tiempo real.
- Barreras de ejecución
Las barreras actúa como puntos de rendezvous. Cada hilo que alcanza la llamada wait se suspende hasta que el número total definido de hilos haya llegado al mismo punto, momento en el que todos se reactivan simultáneamente para la siguiente fase.
Interfaces POSIX
pthread_barrier_init(): Configuración con conteo objetivo.pthread_barrier_wait(): Sincronización en el punto de encuentro.pthread_barrier_destroy(): Liberación de recursos.
Ejemplo de implementación
#include <iostream>
#include <pthread.h>
static pthread_barrier_t fase_sincro;
static pthread_mutex_t pantalla = PTHREAD_MUTEX_INITIALIZER;
void* etapa_procesamiento(void* arg) {
{
std::lock_guard<pthread_mutex_t> guard(pantalla);
std::cout << "Hilo alcanzando fase inicial..." << std::endl;
}
pthread_barrier_wait(&fase_sincro);
{
std::lock_guard<pthread_mutex_t> guard(pantalla);
std::cout << "Hilo reanudando segunda fase." << std::endl;
}
return nullptr;
}
int main() {
const int NUM_HILOS = 4;
pthread_barrier_init(&fase_sincro, nullptr, NUM_HILOS);
pthread_t workers[NUM_HILOS];
for(int i = 0; i < NUM_HILOS; ++i) {
pthread_create(&workers[i], nullptr, etapa_procesamiento, nullptr);
}
for(int i = 0; i < NUM_HILOS; ++i) {
pthread_join(workers[i], nullptr);
}
pthread_barrier_destroy(&fase_sincro);
return 0;
}
Características operativas
Ventajas: Simplifica pipelines paralelos y algoritmos por fases, garantiza consistencia de datos entre etapas.
Limitaciones: Conteo estático en inicialización; un hilo retardado o bloqueado detiene todo el grupo, pudiendo causar deadlocks.
Uso típico: Procesamiento por lotes, renderizado paralelo, simulaciones de pasos discretos.
- Operaciones atómicas
Las operaciones atómicas aprovechan instrucciones del procesador (como CAS o LDADD) para modificar valores de forma indivisible. No requieren mutexes, eliminando por completo los cambios de contexto y proporcionando la vía más rápida para actualizar datos simples.
Interfaces C++11 (std::atomic)
std::atomic<T>: Envoltorio para tipos primitivos.fetch_add()/fetch_sub(): Incremento/decremento seguro.load()/store(): Lectura/escritura con consistencia garantizada.compare_exchange_weak(): Implementación básica de CAS para estructuras lock-free.
Ejemplo de implementación
#include <iostream>
#include <atomic>
#include <pthread.h>
static std::atomic<int> total_eventos(0);
void* generar_eventos(void* arg) {
for (int n = 0; n < 5000; ++n) {
total_eventos.fetch_add(1, std::memory_order_relaxed);
}
return nullptr;
}
int main() {
const int CANTIDAD = 6;
pthread_t generadores[CANTIDAD];
for (int i = 0; i < CANTIDAD; ++i) {
pthread_create(&generadores[i], nullptr, generar_eventos, nullptr);
}
for (int i = 0; i < CANTIDAD; ++i) {
pthread_join(generadores[i], nullptr);
}
std::cout << "Eventos registrados: " << total_eventos.load() << std::endl;
return 0;
}
Características operativas
Ventajas: Rendimiento óptimo, cero sobrecarga de bloqueo, código conciso, ideal para contadores y flags de estado.
Limitaciones: No aplicable a estructuras complejas o secciones críticas multi-paso; semántica de ordenamiento de memoria requiere conocimiento avanzado para evitar reordenamiento del compilador/CPU.
Uso típico: Contadores de métricas, banderas de terminación, cachés distribuidas, implementaciones lock-free de colas.