Ir al contenido

Mutexes, Locks y Variables de Condicion

Protege datos compartidos con mutexes, lock guards y unique locks. Usa variables de condicion para senalizacion entre hilos y evita trampas comunes de deadlock.

El Problema: Carreras de Datos

Una carrera de datos ocurre cuando dos o mas hilos acceden al mismo dato concurrentemente y al menos un acceso es una escritura, sin sincronizacion. Las carreras de datos son comportamiento indefinido — no solo datos incorrectos, sino UB genuino que puede causar fallos, datos corruptos o apariencia de funcionamiento correcto por pura suerte.

int counter = 0;
void increment() { counter++; }  // carrera de datos si se llama desde multiples hilos

counter++ parece atomico pero en realidad son tres operaciones: cargar, incrementar, almacenar. Dos hilos pueden cargar el mismo valor, incrementar a la misma respuesta y almacenar, perdiendo un incremento.

La solucion: sincronizacion — mutexes, locks, variables de condicion y atomicos aseguran que las secciones criticas se ejecuten de forma mutuamente exclusiva.

std::mutex y Envoltorios de Lock

Un std::mutex proporciona exclusion mutua — solo un hilo puede bloquearlo a la vez. std::lock_guard y std::unique_lock son envoltorios RAII que aseguran que el mutex siempre se desbloquee, incluso si se lanzan excepciones.

lock_guard_demo.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx;
int shared_counter = 0;

void increment(int times) {
    for (int i = 0; i < times; ++i) {
        std::lock_guard<std::mutex> lock(mtx);  // RAII lock
        ++shared_counter;  // safe: only one thread at a time
    }  // lock released here automatically
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment, 10000);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Counter: " << shared_counter << "\n";
    // Always prints 100000 — no data race
    return 0;
}

std::scoped_lock — Multi-Lock a Prueba de Deadlocks (C++17)

std::scoped_lock puede bloquear multiples mutexes simultaneamente usando un algoritmo que evita deadlocks. Siempre usa scoped_lock cuando necesites bloquear mas de un mutex.

unique_lock.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtx;

void try_lock_example() {
    // Deferred locking — don't lock yet
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);

    // Try to lock with a timeout
    if (lock.try_lock_for(std::chrono::milliseconds(100))) {
        std::cout << "Acquired lock\n";
        // ... do work ...
    } else {
        std::cout << "Could not acquire lock within 100ms\n";
    }
    // Automatically unlocks when lock goes out of scope
}

void manual_control() {
    std::unique_lock<std::mutex> lock(mtx);
    // ... critical section ...
    lock.unlock();  // release early if needed
    // ... non-critical work ...
    lock.lock();    // re-acquire
    // ... another critical section ...
}

Variables de Condicion: Esperar Eventos

Una std::condition_variable permite que un hilo espere hasta que otro hilo senalice que alguna condicion es verdadera. Siempre usa una variable de condicion con un mutex y un predicado (para proteger contra despertares espureos).

scoped_lock_bank.cpp
#include <mutex>
#include <thread>
#include <iostream>

struct BankAccount {
    std::mutex mtx;
    double balance = 1000.0;
};

void transfer(BankAccount& from, BankAccount& to, double amount) {
    // Lock both mutexes without risk of deadlock
    std::scoped_lock lock(from.mtx, to.mtx);

    if (from.balance >= amount) {
        from.balance -= amount;
        to.balance += amount;
        std::cout << "Transferred " << amount << "\n";
    }
}  // both mutexes released

int main() {
    BankAccount alice, bob;

    // These two transfers lock accounts in different order,
    // but scoped_lock prevents deadlock.
    std::thread t1(transfer, std::ref(alice), std::ref(bob), 100.0);
    std::thread t2(transfer, std::ref(bob), std::ref(alice), 50.0);

    t1.join();
    t2.join();

    std::cout << "Alice: " << alice.balance
              << ", Bob: " << bob.balance << "\n";
    return 0;
}

Tipos de Mutex y Patrones de Lock

La STL proporciona varios tipos de mutex:

- std::mutex — Lock basico, no recursivo. Bloquear dos veces desde el mismo hilo es UB.
- std::recursive_mutex — El mismo hilo puede bloquearlo multiples veces; debe desbloquearlo el mismo numero de veces.
- std::timed_mutex / std::recursive_timed_mutex — Soportan try_lock_for(duration) y try_lock_until(time_point) para bloqueo temporizado.
- std::shared_mutex (C++17) — Lock de lectores-escritor. Multiples hilos pueden mantener un lock compartido (lectura) simultaneamente, pero solo un hilo puede mantener un lock exclusivo (escritura).

Envoltorios de lock: Usa std::lock_guard para el caso simple (bloquear en construccion, desbloquear en destruccion). Usa std::unique_lock cuando necesites desbloqueo manual, bloqueo diferido, transferencia o uso con variables de condicion. Usa std::shared_lock para lectores con std::shared_mutex.

Error común

Un deadlock ocurre cuando dos o mas hilos se bloquean mutuamente, cada uno esperando que el otro libere un lock.

// Hilo 1: bloquea A, luego intenta bloquear B
// Hilo 2: bloquea B, luego intenta bloquear A
// -> DEADLOCK — ambos hilos esperan para siempre

Reglas para prevenir deadlocks:

1. Bloquea mutexes en un orden consistente a traves de todos los hilos
2. Usa std::scoped_lock(m1, m2) — adquiere ambos locks sin riesgo de deadlock usando un algoritmo especializado
3. Nunca llames a funciones externas mientras mantienes un lock — la funcion externa podria intentar adquirir otro lock
4. Mantén los locks el menor tiempo posible — bloquea solo la seccion critica, no calculos completos
5. Nunca esperes en una variable de condicion mientras mantienes multiples locks — esto frecuentemente causa deadlocks

Buena práctica

Prefiere std::scoped_lock para secciones criticas simples y std::unique_lock cuando necesites variables de condicion o transferencia de lock. Usa std::shared_mutex cuando las lecturas superan ampliamente a las escrituras. Mantén los datos protegidos y su mutex juntos en un struct — esto hace obvia la relacion. Siempre pasa un predicado a condition_variable::wait() para manejar despertares espureos. Considera alternativas sin lock (std::atomic) para datos compartidos simples como contadores.

Puntos clave
  • Una carrera de datos (lectura/escritura concurrente sin sincronizacion) es comportamiento indefinido — no solo datos incorrectos
  • std::lock_guard y std::scoped_lock son envoltorios RAII que aseguran que los mutexes siempre se desbloqueen
  • std::scoped_lock bloquea multiples mutexes sin riesgo de deadlock — siempre usalo para multi-lock
  • Las variables de condicion necesitan un mutex + un predicado — el predicado protege contra despertares espureos
  • std::shared_mutex habilita locks de lectores-escritor — multiples lectores concurrentes, escritores exclusivos
  • Previene deadlocks con orden consistente de locks, std::scoped_lock y secciones criticas minimas

Quiz — Pon a prueba tus conocimientos

(20 XP)

1. Que sucede si dos hilos incrementan un `int` compartido sin sincronizacion?

2. Como previene `std::scoped_lock` deadlocks al bloquear multiples mutexes?

3. Por que las variables de condicion requieren un predicado en la llamada a `wait()`?