CodeGym /Cursos /C++ SELF /Shutdown protocol — cómo detener un worker thread

Shutdown protocol — cómo detener un worker thread

C++ SELF
Nivel 70 , Lección 4
Disponible

1. El worker puede esperar eternamente

Si ya has escrito alguna vez un consumer que hace honradamente cv.wait(lock, []{ return !q.empty(); }), estás muy cerca del éxito… pero no del cierre del programa.

El problema es que la espera en wait puede ser eterna, y esto no es un bug condition_variable: el hilo hace exactamente lo que le pediste — espera hasta que haya trabajo. Si el trabajo nunca volverá, el hilo esperará hasta el fin de los tiempos (o hasta el fin de tu sesión en el IDE, lo que ocurra antes).

Imagina la situación: el producer terminó su trabajo, todas las tareas se añadieron, la cola quedó vacía, el consumer se durmió y está esperando. El programa quiere salir, pero el join() del worker se queda colgado porque el worker «está dormido» y no va a despertarse por sí solo. Este es un «primer deadlock multihilo» muy común, y suele aparecer como: «todo funciona… pero a veces la aplicación no se cierra».

Mini-antiejemplo (puede quedarse colgado para siempre):

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> q;

int pop_blocking_forever() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return !q.empty(); }); // si nadie más hace push — esperamos eternamente
    int x = q.front();
    q.pop();
    return x;
}

Aquí no hay «error de sincronización» — falta un protocolo de finalización. Más abajo añadiremos ese protocolo.

2. Estado de la cola: «cerrada» como parte del protocolo

Idea: añadimos un flag “no habrá nuevas tareas”

Para terminar correctamente el worker, necesitamos añadir al estado compartido (aquel protegido por el mutex) un hecho adicional: el producer ya nunca añadirá tareas. Normalmente es un flag booleano que se llama closed, done, stop, shutdown.

La idea clave es: el consumer debe despertarse no solo cuando «hay trabajo», sino también cuando «no habrá más trabajo nunca». Son dos eventos diferentes, pero en nuestro código se expresan de la misma manera: el estado protegido cambió, así que hay que despertar a los que esperan para que reevalúen el predicado.

Esquemáticamente el estado de la cola se puede representar así:

stateDiagram-v2
    [*] --> OpenEmpty: start
    OpenEmpty --> OpenNonEmpty: producer push
    OpenNonEmpty --> OpenEmpty: consumer pop
    OpenEmpty --> ClosedEmpty: close()
    OpenNonEmpty --> ClosedNonEmpty: close()
    ClosedNonEmpty --> ClosedEmpty: consumer pop (hasta el final)
    ClosedEmpty --> [*]: consumer exits

Aquí es importante que «cerrada» no significa «la cola desapareció». Significa «no habrá nuevas tareas». Las tareas antiguas (si existen) todavía pueden (y normalmente deben) procesarse.

Predicado correcto de espera: closed || !q.empty()

El lugar más importante de todo el shutdown protocol es el predicado de espera. Es él quien determina cuándo el consumer se despierta y qué considera «motivo suficiente» para salir del sueño. Si el predicado es incorrecto, obtendrás o bien un bloqueo, o una salida temprana con pérdida de tareas, o un intento de llamar a front() sobre una cola vacía.

La forma correcta de esperar suele ser: esperamos hasta que (la cola no esté vacía) o (la cola esté cerrada). Suena a: «despiértate si puedes hacer algo — o bien coger trabajo, o bien terminar correctamente».


cv.wait(lock, [] { return closed || !q.empty(); });

Fíjate: esto es exactamente wait(lock, predicate), y no wait(lock) ni if (...) wait(lock). El despertar puede ser spurious, y la notificación no es un «evento almacenado», sino simplemente la señal para «revisar el estado».

Ahora, cuando wait ha vuelto, el consumer debe decidir cuidadosamente qué ocurrió. Puede pasar: la cola no está vacía — genial, cogemos la tarea. La cola está vacía, pero closed == true — es un escenario legal de «el trabajo terminó», hay que salir.

Cómo cerrar la cola: closed bajo mutex + notify_all

La operación de shutdown (cerrar la cola) casi siempre consta de dos pasos, y el orden es más importante de lo que parece.

Primero, bajo el mismo mutex que protege la cola, cambiamos el flag closed = true. Esto es fundamental: closed es parte del mismo estado protegido que la cola, si no pierdes la garantía de consistencia.

Luego despertamos a los que están esperando. Y aquí casi siempre se necesita notify_all(), porque puede haber varios esperando y todos deben despertarse, ver closed y terminar correctamente. Incluso si solo hay un consumer, notify_all() no hará daño, pero notify_one() con varios consumers puede dejar a alguien dormido para siempre.

Mini-ejemplo de cierre:

#include <condition_variable>
#include <mutex>

std::mutex m;
std::condition_variable cv;
bool closed = false;

void close_queue() {
    {
        std::lock_guard<std::mutex> lock(m);
        closed = true;
    }
    cv.notify_all(); // despertamos a todos: alguno debe salir de wait
}

¿Por qué suelen llamar a notify_all() después de salir del lock_guard? No es magia ni «es más bonito». Simplemente reduces el tiempo en que el mutex está ocupado y das a los hilos despertados oportunidad de tomar el mutex más rápido y continuar. Notificar «bajo el lock» es correcto, pero suele ser menos eficiente y a veces empeora el rendimiento bajo carga.

3. Lógica del consumer: «cerrado y vacío» como señal de parada

Regla de salida tras wait

Ahora ensamblamos el «corazón» del shutdown protocol: la lógica que ejecuta el consumer tras wait. Aquí se decide el destino del worker thread: si continúa trabajando, termina correctamente o intenta sacar un elemento de una cola vacía.

El patrón clásico se ve así:

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> q;
bool closed = false;

bool try_pop(int& out) {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return closed || !q.empty(); });

    if (q.empty() && closed) {
        return false; // señal: no habrá más tareas
    }

    out = q.front();
    q.pop();
    return true;
}

Aquí false significa «cerrada y vacía», es decir «se puede salir del worker loop».

En este punto suele surgir la duda del novato: «¿Por qué no salir simplemente cuando closed == true?» Porque closed significa «no habrá nuevas tareas», pero las que ya están en la cola aún pueden existir. Si sales inmediatamente cuando closed, perderás las tareas que entraron en la cola antes del cierre.

std::optional como señal más limpia

Devolver un bool más un parámetro out funciona, pero huele un poco a C procedural. Aquí encaja bien std::optional<T> como modelo «valor hay / no hay valor»: pop() devuelve una tarea, o devuelve std::nullopt si la cola está cerrada y vacía.

Ventaja: desaparecen valores «mágicos» como -1 («¿y si una tarea legítima es -1?»), desaparecen punteros nulos y otros trucos.

Mini-versión:

#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> q;
bool closed = false;

std::optional<int> pop() {
    std::unique_lock<std::mutex> lock(m);
    cv.wait(lock, [] { return closed || !q.empty(); });

    if (q.empty()) {
        return std::nullopt; // cerrada y vacía
    }

    int x = q.front();
    q.pop();
    return x;
}

Fíjate en la sutileza: después de wait comprobamos simplemente if (q.empty()). ¿Por qué es suficiente? Porque wait ha devuelto solo si (closed || !q.empty()). Si la cola está vacía, la única razón es que closed == true. Es decir, «vacía» aquí equivale a «vacía y cerrada», y eso hace el código más corto.

4. Encapsulación y ejemplo: TaskQueue + bucle del worker

Por qué es mejor envolver el protocolo en una clase

Cuando mutex, cv, queue y closed están en variables globales separadas, mantienes la disciplina en la cabeza. Funciona hasta que te cansas, o añades un segundo fichero, o pides a un compañero «que lo termine un poco». Entonces el código empieza a vivir por su cuenta: alguien lee closed sin mutex, alguien llama a notify_one() al cerrar, alguien hace push tras close y se sorprende.

Así que un paso práctico útil es envolver el protocolo en una pequeña clase. No hacemos «OOP por OOP», sino «un lugar donde viven las reglas».

Esqueleto de TaskQueue (solo lo esencial):

#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>

class TaskQueue {
public:
    void push(int x) {
        {
            std::lock_guard<std::mutex> lock(m_);
            q_.push(x);
        }
        cv_.notify_one();
    }

    std::optional<int> pop() {
        std::unique_lock<std::mutex> lock(m_);
        cv_.wait(lock, [&] { return closed_ || !q_.empty(); });

        if (q_.empty()) return std::nullopt;

        int x = q_.front();
        q_.pop();
        return x;
    }

    void close() {
        {
            std::lock_guard<std::mutex> lock(m_);
            closed_ = true;
        }
        cv_.notify_all();
    }

private:
    std::mutex m_;
    std::condition_variable cv_;
    std::queue<int> q_;
    bool closed_ = false;
};

Fíjate en la captura del predicado: [&]. Es normal porque el predicado se llama con m_ tomado y lee los campos del objeto. Y el predicado es «puro»: no cambia el estado, solo lo comprueba. Esto es importante porque wait(lock, predicate) puede invocar el predicado varias veces.

¿Se puede hacer push después de close? Técnicamente sí — nuestro código no lo impide. Pero como política de proyecto normalmente se prohíbe: tras el cierre no se aceptan nuevas tareas. Podemos añadir protección, pero no queremos complicar: aquí lo importante es el protocolo de parada del worker.

Bucle del worker: «mientras pop() da tarea — trabajamos»

Ahora hacemos el worker “canónico”: hilo que toma tareas de la cola y las procesa mientras pop() no devuelva nullopt. Es importante procesar fuera de la sección crítica, porque pop() devuelve una copia de la tarea (int), y el mutex dentro de TaskQueue ya se ha liberado.

Ejemplo de la función del worker:

#include <iostream>
#include <optional>

void worker_main(TaskQueue& tasks) {
    while (true) {
        std::optional<int> task = tasks.pop();
        if (!task) break;

        std::cout << "Processing task " << *task << '\n';
        // Procesando tarea 1
        // Procesando tarea 2
    }
    std::cout << "Worker stopped\n"; // Worker stopped
}

Aquí la condición de terminación es honesta: «no hay más tareas y no las habrá».

Mini-aplicación completa: producer → close()join()

Montemos una pequeña demo que muestre todo el shutdown protocol de principio a fin. No es un «thread pool» ni un scheduler: un producer (main) y un consumer (worker).

#include <chrono>
#include <iostream>
#include <thread>

int main() {
    TaskQueue tasks;

    std::thread worker([&] {
        worker_main(tasks);
    });

    tasks.push(1);
    tasks.push(2);

    std::this_thread::sleep_for(std::chrono::milliseconds{50});
    tasks.close();          // importante: cerramos, si no el worker puede esperar eternamente

    worker.join();          // terminamos correctamente
    std::cout << "Main done\n"; // Main done
}

Lo importante en términos prácticos (sin teoría seca). close() es el «final oficial»: el producer comunica al consumer que no habrá nuevas tareas. notify_all() dentro de close() garantiza que el worker se despierte, incluso si estaba durmiendo en wait. Luego pop() devolverá nullopt, y el worker saldrá del bucle correctamente.

Si quitas tasks.close(), con alta probabilidad tendrás espera eterna, y join() se quedará colgado. Así que close() no es un botón opcional: forma parte del protocolo de ciclo de vida.

5. Alternativa: por qué la poison pill suele ser peor

A veces en lugar del flag closed la gente intenta usar una «tarea especial» (poison pill): por ejemplo, poner en la cola -1, que significa «stop». Y a veces incluso funciona, mientras las tareas sean int y nadie envíe accidentalmente -1 como tarea legítima. Pero el diseño resulta frágil.

Comparación compacta:

Enfoque Cómo se ve el stop Fortalezas Debilidades
Poison pill (valor especial)
q.push(-1)
Rápido de escribir en ejemplos didácticos Necesita un rango «prohibido» de valores; no escala bien a tipos reales de tareas
Flag closed
closed = true; notify_all()
Modelo explícito del estado de la cola Hay que pensar cuidadosamente el predicado y la salida
optional en pop()
return nullopt;
Muy legible en el worker loop Requiere algo más de código en la cola (pero normalmente es una ventaja)

En esta lección elegimos closed + optional, porque expresa directamente la idea: «ya no hay valor». Y no tienes que explicar al equipo por qué -1 de repente significa «fin del mundo».

6. Errores típicos

Error nº1: cerraste la cola pero olvidaste notify_all().
Es la situación clásica: pusiste closed = true, estás contento, pero el worker sigue colgado en wait. La razón es simple: wait por sí mismo no se despierta por el hecho de que «alguien cambió un bool en otro hilo». Se despierta cuando le notifican (notify_*) o cuando ocurre un spurious wakeup (en el que no conviene confiar). Por eso cerrar siempre es «cambiar el estado bajo el mutex» más «despertar a los que esperan», y en el shutdown suele usarse notify_all().

Error nº2: usaste notify_one() al cerrar con varios consumers.
Si tienes dos workers y despiertas solo a uno, el otro puede quedarse esperando para siempre. Aunque parezca que «algún día también se despertará», eso no es un protocolo, es una esperanza. En el shutdown despierta a todos para que cada uno vea closed y decida salir.

Error nº3: leen o escriben closed sin el mismo mutex que protege la cola.
closed forma parte del mismo estado que la cola. Si proteges q con un mutex y cambias closed sin él, rompes la disciplina unificada de acceso al estado. El predicado puede ver una imagen «vieja» del mundo y tu código dependerá de timings aleatorios. Aunque «funcione en mi máquina», simplemente no has encontrado el timing correcto aún.

Error nº4: el predicado de espera solo espera !q.empty().
Ese consumer no sabe cómo terminar. Esperará «a que aparezca trabajo» para siempre, y join() se quedará colgado. El predicado debe incluir la condición de cierre: closed || !q.empty().

Error nº5: salen del worker loop inmediatamente al ver closed == true y pierden tareas.
Si haces «si está cerrado — salgo», descartas tareas que ya estaban en la cola. La regla correcta suele ser: mientras la cola no esté vacía — procesa; cuando la cola esté vacía y cerrada — sal. Por eso la comprobación tras wait suele ser «si está vacía — significa cerrada y vacía».

1
Tarea
C++ SELF, nivel 70, lección 4
Bloqueada
Cola cerrada
Cola cerrada
1
Tarea
C++ SELF, nivel 70, lección 4
Bloqueada
Cola como clase
Cola como clase
1
Tarea
C++ SELF, nivel 70, lección 4
Bloqueada
Brigada de workers
Brigada de workers
1
Tarea
C++ SELF, nivel 70, lección 4
Bloqueada
Productor de comandos
Productor de comandos
1
Cuestionario/control
condition_variable, nivel 70, lección 4
No disponible
condition_variable
condition_variable
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION