En el desarrollo de sistemas concurrentes, la gestión manual de la adquisición y liberación de mutexes es una receta para el desastre. Los objetos de gestión de recursos (RAII) aplicados a la sincronización son envoltorios que vinculan el ciclo de vida de un bloqueo al tiempo de vida de un objeto en el stack. Estos objetos adquieren el std::mutex en su constructor y, lo más importante, lo liberan en su destructor. Esto garantiza que, incluso si una función termina prematuramente debido a una excepción o un return inesperado, el mutex se liberará, evitando bloqueos infinitos.
Existen tres herramientas principales para este propósito. std::lock_guard [C++11] es la opción más sencilla y ligera cuando solo necesitas bloquear un mutex durante toda la duración de un bloque de código. Si necesitas flexibilidad, como desbloquear el mutex manualmente antes de que el objeto muera o utilizarlo con variables de condición, std::unique_lock [C++11] es la elección necesaria, ya que permite operaciones de bloqueo diferido o intentos de bloqueo sin bloquear (try_lock). Finalmente, std::scoped_lock [C++17] es la solución definitiva para evitar el deadlock (bloqueo mutuo) cuando necesitas adquirir múltiples mutexes simultáneamente, utilizando un algoritmo interno para asegurar que se bloqueen en un orden seguro.
Si utilizas la herramienta incorrecta, el problema suele ser uno de dos: o bien el código no compila (como intentar usar lock_guard con una std::condition_variable) o entras en un estado de deadlock donde tus hilos se quedan esperando eternamente por recursos que nunca se liberarán.
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <condition_variable>
#include <chrono>
class DataStore {
private:
std::mutex mtx_a;
std::mutex mtx_b;
int value_a = 0;
int value_b = 0;
// Para el ejemplo de std::unique_lock
std::condition_variable cv;
bool ready = false;
public:
// Uso de std::lock_guard para una sección crítica simple y rápida
void increment_a() {
std::lock_guard<std::mutex> lock(mtx_a);
value_a++;
std::cout << "Value A incrementado a: " << value_a << "\n";
}
// Uso de std::scoped_lock [C++17] para evitar deadlock al adquirir dos mutexes
void transfer_all() {
// std::scoped_lock adquiere mtx_a y mtx_b de forma atómica respecto a otros hilos
std::scoped_lock lock(mtx_a, mtx_b);
value_a = 0;
value_b = 0;
std::cout << "Transferencia completa: A y B reseteados.\n";
}
// Uso de std::unique_lock para interactuar con std::condition_variable
void wait_for_ready() {
// std::unique_lock es obligatorio para usar con std::condition_variable
std::unique_lock<std::mutex> lock(mtx_a);
std::cout << "Hilo de espera: aguardando señal...\n";
cv.wait(lock, [this] { return ready; });
std::cout << "Hilo de espera: señal recibida. Valor A: " << value_a << "\n";
}
void set_ready() {
{
std::lock_guard<std::mutex> lock(mtx_a);
ready = true;
}
cv.notify_all(); // Notifica a los hilos que esperan
}
void update_b(int val) {
std::lock_guard<std::mutex> lock(mtx_b);
value_b = val;
}
int get_b() {
std::lock_guard<std::mutex> lock(mtx_b);
return value_b;
}
};
int main() {
DataStore ds;
// Hilo que espera una condición
std::thread t1(&DataStore::wait_for_ready, &ds);
// Hilo que realiza operaciones de escritura
std::thread t2([&ds]() {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
ds.update_b(42);
ds.increment_a();
ds.set_ready();
});
// Hilo que realiza la transferencia atómica
std::thread t3([&ds]() {
std::this_thread::sleep_for(std::chrono::milliseconds(150));
ds.transfer_all();
});
t1.join();
t2.join();
t3.join();
return 0;
}
Análisis del código
En increment_a, empleamos std::lock_guard<std::mutex> lock(mtx_a). Aquí, el constructor de lock llama a mtx_a.lock(). Como es una sección muy corta, no necesitamos más flexibilidad que el bloqueo automático al entrar y la liberación al salir del ámbito.
La función wait_for_ready es crucial para entender la diferencia con unique_lock. La llamada a cv.wait(lock, ...) requiere que el objeto de bloqueo sea un std::unique_lock. Esto se debe a que std::condition_variable necesita la capacidad de liberar el mutex de forma atómica mientras el hilo duerme y volver a adquirirlo cuando se despierta. Un std::lock_guard no tiene método unlock() público, por lo que no cumpliría este requisito de la API.
En transfer_all, el uso de std::scoped_lock lock(mtx_a, mtx_b) es la forma más segura de gestionar múltiples recursos. Si intentáramos bloquear mtx_a y luego mtx_b manualmente en diferentes hilos en órdenes distintos, causaríamos un deadlock. std::scoped_lock evita esto internamente utilizando un algoritmo de ordenación de bloqueos (similar al de std::lock de la biblioteca estándar), garantizando que siempre se bloqueen en un orden que no genere ciclos de espera.
El error frecuente
Un error común en sistemas legacy o código mal migrado es la gestión manual de la liberación. Fíjate en este patrón peligroso:
void peligroso(std::mutex& m) {
m.lock();
if (algun_error_critico()) {
// Si lanzamos una excepción o hacemos un return aquí...
return; // ...el mutex se queda bloqueado para siempre (Deadlock)
}
m.unlock();
}
Si una excepción es lanzada antes de m.unlock(), el flujo de ejecución salta al bloque catch o termina la función sin haber liberado el recurso. Esto no es un error de compilación, es un error de lógica que congelará cualquier otro hilo que intente adquirir ese mutex. AddressSanitizer no siempre detectará esto como un error de memoria, pero las herramientas de análisis de deadlock sí. La solución es siempre usar RAII (lock_guard o unique_lock).
N° 64