¿Tengo que adquirir el bloqueo antes de llamar a condition_variable.notify_one ()?

90

Estoy un poco confundido sobre el uso de std::condition_variable. Entiendo que tengo que crear un unique_lockon a mutexantes de llamar condition_variable.wait(). Lo que no puedo encontrar es si también debería adquirir un bloqueo único antes de llamar notify_one()o notify_all().

Los ejemplos de cppreference.com son contradictorios. Por ejemplo, la página notify_one ofrece este ejemplo:

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

std::condition_variable cv;
std::mutex cv_m;
int i = 0;
bool done = false;

void waits()
{
    std::unique_lock<std::mutex> lk(cv_m);
    std::cout << "Waiting... \n";
    cv.wait(lk, []{return i == 1;});
    std::cout << "...finished waiting. i == 1\n";
    done = true;
}

void signals()
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::cout << "Notifying...\n";
    cv.notify_one();

    std::unique_lock<std::mutex> lk(cv_m);
    i = 1;
    while (!done) {
        lk.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
        lk.lock();
        std::cerr << "Notifying again...\n";
        cv.notify_one();
    }
}

int main()
{
    std::thread t1(waits), t2(signals);
    t1.join(); t2.join();
}

Aquí el bloqueo no se adquiere para el primero notify_one(), sino para el segundo notify_one(). Al mirar otras páginas con ejemplos, veo cosas diferentes, en su mayoría sin adquirir el candado.

  • ¿Puedo elegir bloquear el mutex antes de llamar notify_one(), y por qué elegiría bloquearlo?
  • En el ejemplo dado, ¿por qué no hay bloqueo para el primero notify_one(), pero sí para llamadas posteriores? ¿Este ejemplo es incorrecto o hay alguna justificación?
Peter Smit
fuente

Respuestas:

77

No es necesario mantener un candado al llamar condition_variable::notify_one(), pero no está mal en el sentido de que sigue siendo un comportamiento bien definido y no un error.

Sin embargo, podría ser una "pesimización", ya que cualquier hilo de espera que se haga ejecutable (si lo hay) intentará adquirir inmediatamente el bloqueo que mantiene el hilo de notificación. Creo que es una buena regla para evitar mantener el bloqueo asociado con una variable de condición mientras se llama a notify_one()o notify_all(). Consulte Pthread Mutex: pthread_mutex_unlock () consume mucho tiempo para ver un ejemplo en el que liberar un bloqueo antes de llamar al equivalente pthread de notify_one()rendimiento mejorado de forma medible.

Tenga en cuenta que la lock()llamada en el whilebucle es necesaria en algún momento, porque el bloqueo debe mantenerse durante la while (!done)verificación de la condición del bucle. Pero no es necesario retenerlo para que la llamada notify_one().


2016-02-27 : Actualización grande para abordar algunas preguntas en los comentarios sobre si hay una condición de carrera si el bloqueo no ayuda para la notify_one()llamada. Sé que esta actualización llega tarde porque la pregunta se hizo hace casi dos años, pero me gustaría abordar la pregunta de @ Cookie sobre una posible condición de carrera si el productor ( signals()en este ejemplo) llama notify_one()justo antes que el consumidor ( waits()en este ejemplo). capaz de llamar wait().

La clave es lo que le sucede i, ese es el objeto que realmente indica si el consumidor tiene "trabajo" que hacer o no. El condition_variablees simplemente un mecanismo para que el consumidor espera de manera eficiente para que un cambio i.

El productor debe mantener el bloqueo al actualizar i, y el consumidor debe mantener el bloqueo mientras verifica iy llama condition_variable::wait()(si es que necesita esperar). En este caso, la clave es que debe ser la misma instancia de mantener la cerradura (a menudo llamada sección crítica) cuando el consumidor realiza esta verificación y espera. Dado que la sección crítica se lleva a cabo cuando el productor actualiza iy cuando el consumidor verifica y espera i, no hay oportunidad de icambiar entre el momento en que el consumidor verifica iy cuando llama condition_variable::wait(). Este es el quid de un uso adecuado de las variables de condición.

El estándar de C ++ dice que condition_variable :: wait () se comporta de la siguiente manera cuando se llama con un predicado (como en este caso):

while (!pred())
    wait(lock);

Hay dos situaciones que pueden ocurrir cuando el consumidor verifica i:

  • si ies 0, entonces el consumidor llama cv.wait(), entonces iseguirá siendo 0 cuando wait(lock)se llame a la parte de la implementación; el uso adecuado de los bloqueos lo garantiza. En este caso, el productor no tiene la oportunidad de llamar al condition_variable::notify_one()en su whilebucle hasta después de que el consumidor haya llamado cv.wait(lk, []{return i == 1;})(y la wait()llamada haya hecho todo lo necesario para 'capturar' correctamente una notificación; wait()no liberará el bloqueo hasta que lo haya hecho ). Entonces, en este caso, el consumidor no puede perderse la notificación.

  • si iya es 1 cuando el consumidor llama cv.wait(), la wait(lock)parte de la implementación nunca se llamará porque la while (!pred())prueba hará que el ciclo interno termine. En esta situación, no importa cuándo ocurre la llamada a notify_one (), el consumidor no bloqueará.

El ejemplo aquí tiene la complejidad adicional de usar la donevariable para indicarle al hilo del productor que el consumidor ha reconocido eso i == 1, pero no creo que esto cambie el análisis en absoluto porque todo el acceso a done(tanto para leer como para modificar ) se realizan mientras se encuentran en las mismas secciones críticas que involucran iy el condition_variable.

Si nos fijamos en la cuestión de que @ EH9 señaló, la sincronización no es fiable utilizando std :: atómica y std :: condition_variable , que van a ver una condición de carrera. Sin embargo, el código publicado en esa pregunta viola una de las reglas fundamentales del uso de una variable de condición: no contiene una sola sección crítica cuando se realiza una verificación y espera.

En ese ejemplo, el código se ve así:

if (--f->counter == 0)      // (1)
    // we have zeroed this fence's counter, wake up everyone that waits
    f->resume.notify_all(); // (2)
else
{
    unique_lock<mutex> lock(f->resume_mutex);
    f->resume.wait(lock);   // (3)
}

Notará que wait()en # 3 se realiza mientras se sostiene f->resume_mutex. Pero la verificación de si wait()es necesario o no en el paso n. ° 1 no se realiza mientras se mantiene ese bloqueo (mucho menos de forma continua para verificar y esperar), que es un requisito para el uso adecuado de las variables de condición). Creo que la persona que tiene el problema con ese fragmento de código pensó que, dado que f->counterera un std::atomictipo, cumpliría con el requisito. Sin embargo, la atomicidad proporcionada por std::atomicno se extiende a la siguiente llamada a f->resume.wait(lock). En este ejemplo, hay una carrera entre cuándo f->counterse marca (paso n. ° 1) y cuándo wait()se llama (paso n. ° 3).

Esa raza no existe en el ejemplo de esta pregunta.

Michael Burr
fuente
2
tiene implicaciones más profundas: domaigne.com/blog/computing/… Notablemente, el problema de pthread que mencionas debería resolverse con una versión más reciente o una versión construida con los indicadores correctos. (para habilitar la wait morphingoptimización) Regla de oro explicada en este enlace: notificar CON bloqueo es mejor en situaciones con más de 2 hilos para obtener resultados más predecibles.
v.oddou
6
@Michael: Según tengo entendido, el consumidor debe llamar finalmente the_condition_variable.wait(lock);. Si no se necesita un bloqueo para sincronizar el productor y el consumidor (digamos que el subyacente es una cola spsc sin bloqueo), entonces ese bloqueo no sirve para nada si el productor no lo bloquea. Bien por mi. ¿Pero no existe el riesgo de una raza rara? Si el productor no mantiene el candado, ¿no podría llamar a notify_one mientras el consumidor está justo antes de la espera? Entonces, el consumidor espera y no se despierta ...
Cookie
1
Por ejemplo, digamos en el código anterior que el consumidor está std::cout << "Waiting... \n";mientras que el productor lo hace cv.notify_one();, luego la llamada de atención desaparece ... ¿O me estoy perdiendo algo aquí?
Cookie
1
@Galleta. Sí, hay una condición de carrera allí. Ver stackoverflow.com/questions/20982270/…
eh9
1
@ eh9: Maldita sea, acabo de encontrar la causa de un error que congela mi código de vez en cuando gracias a tu comentario. Fue debido a este caso exacto de condición de carrera. Desbloquear el mutex después de la notificación resolvió completamente el problema ... ¡Muchas gracias!
Galinette
10

Situación

Usando vc10 y Boost 1.56, implementé una cola concurrente muy parecida a como sugiere esta publicación de blog . El autor desbloquea el mutex para minimizar la contención, es decir, notify_one()se llama con el mutex desbloqueado:

void push(const T& item)
{
  std::unique_lock<std::mutex> mlock(mutex_);
  queue_.push(item);
  mlock.unlock();     // unlock before notificiation to minimize mutex contention
  cond_.notify_one(); // notify one waiting thread
}

El desbloqueo del mutex está respaldado por un ejemplo en la documentación de Boost :

void prepare_data_for_processing()
{
    retrieve_data();
    prepare_data();
    {
        boost::lock_guard<boost::mutex> lock(mut);
        data_ready=true;
    }
    cond.notify_one();
}

Problema

Aún así, esto llevó al siguiente comportamiento errático:

  • aunque notify_one()aún no se ha llamado todavía cond_.wait()se puede interrumpir a través deboost::thread::interrupt()
  • una vez notify_one()se llamó por primera vez cond_.wait()puntos muertos; la espera no puede terminar boost::thread::interrupt()ni boost::condition_variable::notify_*()más.

Solución

Eliminar la línea mlock.unlock()hizo que el código funcionara como se esperaba (las notificaciones y las interrupciones terminan la espera). Tenga en cuenta que notify_one()se llama con el mutex aún bloqueado, se desbloquea inmediatamente después de salir del alcance:

void push(const T& item)
{
  std::lock_guard<std::mutex> mlock(mutex_);
  queue_.push(item);
  cond_.notify_one(); // notify one waiting thread
}

Eso significa que al menos con mi implementación de hilo particular, el mutex no debe desbloquearse antes de llamar boost::condition_variable::notify_one(), aunque ambas formas parecen correctas.

Matthäus Brandl
fuente
¿Le informó este problema a Boost.Thread? No puedo encontrar una tarea similar allí svn.boost.org/trac/boost/…
magras
@magras Lamentablemente no lo hice, no tengo idea de por qué no consideré esto. Y desafortunadamente no logro reproducir este error usando la cola mencionada.
Matthäus Brandl
No estoy seguro de ver cómo el despertar temprano podría causar un punto muerto. Específicamente, si sale de cond_.wait () en pop () después de que push () libera el mutex de la cola pero antes de que se llame a notify_one (), Pop () debería ver la cola no vacía y consumir la nueva entrada en lugar de esperando. Si sale de cond_.wait () mientras push () está actualizando la cola, el bloqueo debe mantenerse con push (), por lo que pop () debe bloquearse esperando a que se libere el bloqueo. Cualquier otro despertador temprano mantendría el bloqueo, evitando que push () modifique la cola antes de que pop () llame a la siguiente espera (). ¿Qué me perdí?
Kevin
4

Como han señalado otros, no es necesario mantener el candado al llamar notify_one(), en términos de condiciones de carrera y problemas relacionados con el subproceso. Sin embargo, en algunos casos, es posible que sea necesario sujetar el candado para evitar condition_variableque se destruya antes de notify_one()llamar. Considere el siguiente ejemplo:

thread t;

void foo() {
    std::mutex m;
    std::condition_variable cv;
    bool done = false;

    t = std::thread([&]() {
        {
            std::lock_guard<std::mutex> l(m);  // (1)
            done = true;  // (2)
        }  // (3)
        cv.notify_one();  // (4)
    });  // (5)

    std::unique_lock<std::mutex> lock(m);  // (6)
    cv.wait(lock, [&done]() { return done; });  // (7)
}

void main() {
    foo();  // (8)
    t.join();  // (9)
}

Supongamos que hay un cambio de contexto al hilo recién creado tdespués de que lo creamos, pero antes de que comencemos a esperar en la variable de condición (en algún lugar entre (5) y (6)). El hilo tadquiere el bloqueo (1), establece la variable de predicado (2) y luego libera el bloqueo (3). Suponga que hay otro cambio de contexto justo en este punto antes notify_one()de que se ejecute (4). El hilo principal adquiere el bloqueo (6) y ejecuta la línea (7), momento en el que el predicado regresa truey no hay razón para esperar, por lo que libera el bloqueo y continúa. foodevuelve (8) y las variables en su alcance (incluidas cv) se destruyen. Antes de que el hilo tpueda unirse al hilo principal (9), tiene que terminar su ejecución, por lo que continúa desde donde lo dejó para ejecutarsecv.notify_one()(4), momento en el que cvya está destruido!

La posible solución en este caso es mantener el candado al llamar notify_one(es decir, eliminar el alcance que termina en la línea (3)). Al hacerlo, nos aseguramos de que las tllamadas a subprocesos notify_oneanteriores cv.waitpuedan verificar la variable de predicado recién configurada y continuar, ya que necesitaría adquirir el bloqueo, que t actualmente se mantiene, para hacer la verificación. Por lo tanto, nos aseguramos de que cvno se acceda por hilo tdespués de las foodevoluciones.

En resumen, el problema en este caso específico no se trata realmente de subprocesos, sino de la vida útil de las variables capturadas por referencia. cvse captura por referencia a través de un hilo t, por lo tanto, debe asegurarse de que se cvmantenga activo durante la ejecución del hilo. Los otros ejemplos presentados aquí no sufren este problema, porque los objetos condition_variabley mutexse definen en el alcance global, por lo que se garantiza que se mantendrán vivos hasta que el programa salga.

cantunca
fuente
1

@Michael Burr tiene razón. condition_variable::notify_oneno requiere un bloqueo en la variable. Sin embargo, nada le impide usar un candado en esa situación, como lo ilustra el ejemplo.

En el ejemplo dado, el bloqueo está motivado por el uso concurrente de la variable i. Debido a que el signalshilo modifica la variable, debe asegurarse de que ningún otro hilo tenga acceso a él durante ese tiempo.

Los bloqueos se utilizan para cualquier situación que requiera sincronización , no creo que podamos expresarlo de una manera más general.

didierc
fuente
por supuesto, pero además de eso, también deben usarse en unión con variables de condición para que todo el patrón realmente funcione. en particular, la waitfunción de variable de condición está liberando el bloqueo dentro de la llamada, y regresa solo después de haber vuelto a adquirir el bloqueo. después de lo cual puede verificar su condición de manera segura porque ha adquirido los "derechos de lectura", digamos. si aún no es lo que estás esperando, vuelve a wait. este es el patrón. por cierto, este ejemplo NO lo respeta.
v.oddou
1

En algún caso, cuando el CV puede estar ocupado (bloqueado) por otros hilos. Debe bloquearlo y liberarlo antes de notificar a _ * ().
De lo contrario, la notificación _ * () tal vez no se ejecute en absoluto.

Fan Jing
fuente
1

Solo agrego esta respuesta porque creo que la respuesta aceptada podría ser engañosa. En todos los casos, deberá bloquear el mutex, antes de llamar a notify_one () en algún lugar para que su código sea seguro para subprocesos, aunque puede desbloquearlo nuevamente antes de llamar a notify_ * ().

Para aclarar, DEBE tomar el bloqueo antes de ingresar a wait (lk) porque wait () desbloquea lk y sería un comportamiento indefinido si el bloqueo no estuviera bloqueado. Este no es el caso de notify_one (), pero debe asegurarse de no llamar a notify _ * () antes de ingresar wait () y hacer que esa llamada desbloquee el mutex; lo que obviamente solo se puede hacer bloqueando ese mismo mutex antes de llamar a notify _ * ().

Por ejemplo, considere el siguiente caso:

std::atomic_int count;
std::mutex cancel_mutex;
std::condition_variable cancel_cv;

void stop()
{
  if (count.fetch_sub(1) == -999) // Reached -1000 ?
    cv.notify_one();
}

bool start()
{
  if (count.fetch_add(1) >= 0)
    return true;
  // Failure.
  stop();
  return false;
}

void cancel()
{
  if (count.fetch_sub(1000) == 0)  // Reached -1000?
    return;
  // Wait till count reached -1000.
  std::unique_lock<std::mutex> lk(cancel_mutex);
  cancel_cv.wait(lk);
}

Advertencia : este código contiene un error.

La idea es la siguiente: los hilos llaman a start () y stop () en pares, pero solo mientras start () devuelva verdadero. Por ejemplo:

if (start())
{
  // Do stuff
  stop();
}

Un (otro) hilo en algún momento llamará a cancel () y después de regresar de cancel () destruirá los objetos que se necesitan en 'Hacer cosas'. Sin embargo, se supone que cancel () no regresará mientras haya subprocesos entre start () y stop (), y una vez que cancel () ejecutó su primera línea, start () siempre devolverá falso, por lo que ningún subproceso nuevo ingresará al 'Do área de cosas.

¿Funciona bien?

El razonamiento es como sigue:

1) Si algún hilo ejecuta con éxito la primera línea de start () (y por lo tanto devolverá verdadero) entonces ningún hilo ejecutó la primera línea de cancel () todavía (asumimos que el número total de hilos es mucho menor que 1000 por el camino).

2) Además, mientras un hilo ejecutó con éxito la primera línea de start (), pero aún no la primera línea de stop (), entonces es imposible que cualquier hilo ejecute con éxito la primera línea de cancel () (tenga en cuenta que solo un hilo siempre llama a cancel ()): el valor devuelto por fetch_sub (1000) será mayor que 0.

3) Una vez que un hilo ejecutó la primera línea de cancel (), la primera línea de start () siempre devolverá falso y un hilo que llame a start () ya no entrará en el área 'Hacer cosas'.

4) El número de llamadas a start () y stop () siempre está equilibrado, por lo que después de que la primera línea de cancel () se ejecute sin éxito, siempre habrá un momento en el que una (última) llamada a stop () provoque la cuenta para llegar a -1000 y, por lo tanto, notificar a uno () para ser llamado. Tenga en cuenta que eso solo puede suceder cuando la primera línea de cancelación dio como resultado que ese hilo se cayera.

Aparte de un problema de inanición en el que tantos subprocesos llaman a start () / stop () que el recuento nunca llega a -1000 y cancel () nunca regresa, lo que uno podría aceptar como "improbable y que nunca durará mucho", hay otro error:

Es posible que haya un hilo dentro del área 'Hacer cosas', digamos que solo está llamando a stop (); en ese momento, un hilo ejecuta la primera línea de cancel () leyendo el valor 1 con fetch_sub (1000) y cayendo. ¡Pero antes de tomar el mutex y / o hacer la llamada a esperar (lk), el primer hilo ejecuta la primera línea de stop (), lee -999 y llama a cv.notify_one ()!

¡Entonces esta llamada a notify_one () se hace ANTES de que estemos esperando () - en la variable de condición! Y el programa se bloqueará indefinidamente.

Por esta razón no deberíamos poder llamar a notify_one () hasta que llamemos a wait (). Tenga en cuenta que el poder de una variable de condición radica en que puede desbloquear atómicamente el mutex, verificar si ocurrió una llamada a notify_one () e irse a dormir o no. No se puede engañar, pero que hacerlo necesidad de mantener el mutex bloqueado cada vez que se realizan cambios en las variables que podrían cambiar la condición de falso a verdadero y mantener bajo llave mientras llama notify_one () a causa de las condiciones de carrera, como se describe aquí.

Sin embargo, en este ejemplo no existe ninguna condición. ¿Por qué no utilicé como condición 'count == -1000'? Porque eso no es nada interesante aquí: tan pronto como se alcance -1000, estamos seguros de que ningún hilo nuevo ingresará al área 'Hacer cosas'. Además, los subprocesos aún pueden llamar a start () e incrementarán el recuento (a -999 y -998, etc.) pero eso no nos importa. Lo único que importa es que se alcanzó -1000, para que sepamos con certeza que ya no hay hilos en el área 'Hacer cosas'. Estamos seguros de que este es el caso cuando se llama a notify_one (), pero ¿cómo asegurarnos de no llamar a notify_one () antes de que cancel () bloquee su mutex? Por supuesto, bloquear cancel_mutex poco antes de notify_one () no ayudará.

El problema es que, a pesar de que no estamos esperando una condición, todavía existe una condición y necesitamos bloquear el mutex

1) antes de que se alcance esa condición 2) antes de llamar a notify_one.

Por tanto, el código correcto se convierte en:

void stop()
{
  if (count.fetch_sub(1) == -999) // Reached -1000 ?
  {
    cancel_mutex.lock();
    cancel_mutex.unlock();
    cv.notify_one();
  }
}

[... mismo comienzo () ...]

void cancel()
{
  std::unique_lock<std::mutex> lk(cancel_mutex);
  if (count.fetch_sub(1000) == 0)
    return;
  cancel_cv.wait(lk);
}

Por supuesto, este es solo un ejemplo, pero otros casos son muy parecidos; en casi todos los casos en los que use una variable condicional, necesitará tener ese mutex bloqueado (en breve) antes de llamar a notify_one (), o de lo contrario es posible que lo llame antes de llamar a wait ().

Tenga en cuenta que desbloqueé el mutex antes de llamar a notify_one () en este caso, porque de lo contrario existe la (pequeña) posibilidad de que la llamada a notify_one () despierte el hilo esperando la variable de condición que luego intentará tomar el mutex y block, antes de que liberemos el mutex nuevamente. Eso es un poco más lento de lo necesario.

Este ejemplo fue un poco especial porque la línea que cambia la condición es ejecutada por el mismo hilo que llama a wait ().

Más habitual es el caso en el que un subproceso simplemente espera a que una condición se convierta en verdadera y otro subproceso toma el bloqueo antes de cambiar las variables involucradas en esa condición (haciendo que posiblemente se convierta en verdadera). En ese caso, el mutex se bloquea inmediatamente antes (y después) de que la condición se cumpla, por lo que está totalmente bien desbloquear el mutex antes de llamar a notify _ * () en ese caso.

Carlo Wood
fuente