Чи потрібно отримувати блокування перед викликом condition_variable.notify_one ()?


90

Я трохи заплутаний щодо використання std::condition_variable. Я розумію , що я повинен створити unique_lockна mutexперед викликом condition_variable.wait(). Я не можу знайти, чи слід мені також придбати унікальний замок перед дзвінком notify_one()або notify_all().

Приклади на cppreference.com суперечливі. Наприклад, на сторінці notify_one подано такий приклад:

#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();
}

Тут замок набувається не для першого notify_one(), а для другого notify_one(). Переглядаючи інші сторінки з прикладами, я бачу різні речі, в основному не отримуючи замок.

  • Чи можу я вибрати, щоб заблокувати мьютекс перед викликом notify_one(), і чому я б вирішив його заблокувати?
  • У наведеному прикладі, чому немає блокування для першого notify_one(), а є для наступних дзвінків. Цей приклад помилковий чи є якесь обґрунтування?

Відповіді:


77

Вам не потрібно тримати замок під час дзвінка condition_variable::notify_one(), але це не помилка в тому сенсі, що це все одно чітко визначена поведінка, а не помилка.

Однак це може бути "песимізацією", оскільки будь-який потік очікування, що робиться запущеним (якщо такий є), негайно спробує отримати блокування, яке містить сповіщувальний потік. Я думаю, що це хороше емпіричне правило, щоб уникнути утримання блокування, пов’язаного зі змінною умови, під час виклику notify_one()або notify_all(). Див. Pthread Mutex: pthread_mutex_unlock () забирає багато часу для прикладу, коли звільнення блокування відбувається перед викликом notify_one()вимірюваного еквівалента покращеної продуктивності pthread.

Майте на увазі, що lock()виклик у whileциклі в якийсь момент необхідний, оскільки блокування потрібно утримувати під час while (!done)перевірки стану циклу. Але його не потрібно тримати для дзвінка notify_one().


27.02.2016 : Велике оновлення для вирішення деяких питань у коментарях щодо того, чи є умова перегони - це блокування - це не допомога для notify_one()дзвінка. Я знаю, що це оновлення запізнюється, оскільки питання було задано майже два роки тому, але я хотів би звернутися до питання @ Cookie щодо можливого стану гонки, якщо виробник ( signals()у цьому прикладі) зателефонує notify_one()безпосередньо перед споживачем ( waits()у цьому прикладі) змогла зателефонувати wait().

Ключовим є те, що відбувається i- це об’єкт, який фактично вказує, чи є у споживача «робота» чи ні. Це condition_variableпросто механізм, який дозволяє споживачеві ефективно чекати змін i.

Виробник повинен тримати замок під час оновлення i, а споживач повинен тримати замок під час перевірки iта дзвінка condition_variable::wait()(якщо йому взагалі потрібно зачекати). У цьому випадку головне полягає в тому, що це повинен бути той самий екземпляр утримання замка (який часто називають критичним розділом), коли споживач робить цю перевірку та очікування. Оскільки критичний розділ проводиться, коли виробник оновлює iі коли споживач перевіряє і чекає i, немає можливості iзмінити час, коли споживач перевіряє, iі коли він телефонує condition_variable::wait(). Це суть правильного використання змінних умов.

Стандарт C ++ говорить, що condition_variable :: wait () поводиться так, як показано нижче, коли викликається з предикатом (як у цьому випадку):

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

Під час перевірки споживача можуть виникнути дві ситуації i:

  • якщо iдорівнює 0, тоді споживач дзвонить cv.wait(), тоді iвсе одно буде 0 при виклику wait(lock)частини реалізації - належне використання блокувань гарантує це. У цьому випадку виробник не має можливості зателефонувати condition_variable::notify_one()у свій whileцикл, доки споживач не зателефонує cv.wait(lk, []{return i == 1;})wait()дзвінок зробив все, що йому потрібно, щоб правильно «зловити» сповіщення - wait()не відпустить блокування, поки цього не зробить ). Тож у цьому випадку споживач не може пропустити повідомлення.

  • якщо iвже 1, коли споживач дзвонить cv.wait(), wait(lock)частина реалізації ніколи не буде викликана, оскільки while (!pred())тест призведе до завершення внутрішнього циклу. У цій ситуації не має значення, коли відбудеться виклик notify_one () - споживач не заблокує.

Наведений тут приклад має додаткову складність використання doneзмінної для передачі сигналу до потоку виробника, про що споживач це визнав i == 1, але я не думаю, що це взагалі змінює аналіз, оскільки весь доступ до done(як для читання, так і для модифікації ) виконуються в тих самих критичних розділах, де задіяні iта condition_variable.

Якщо подивитися на питання , що @ EH9 вказав, синхронізація ненадійна з допомогою станд :: атомне і станд :: condition_variable , ви будете бачити стан гонки. Однак код, розміщений у цьому питанні, порушує одне з основних правил використання змінної умови: він не містить жодного критичного розділу при виконанні перевірки та очікування.

У цьому прикладі код виглядає так:

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)
}

Ви помітите, що wait()на # 3 виконується під час утримання f->resume_mutex. Але перевірка того, чи є wait()це необхідним на кроці # 1, не проводиться, утримуючи цей замок взагалі (набагато менше постійно для перевірки та очікування), що є вимогою для правильного використання змінних умов). Я вважаю, що людина, яка має проблему з цим фрагментом коду, думала, що оскільки f->counterце std::atomicтип, це буде виконувати вимогу. Однак атомність, надана параметром, std::atomicне поширюється на наступний виклик f->resume.wait(lock). У цьому прикладі існує гонка між тим, коли f->counterперевіряється (крок # 1) і коли wait()викликається (крок # 3).

У прикладі цього питання цієї раси не існує.


2
це має більш глибокі наслідки: domaigne.com/blog/computing/... Примітно, що згадувана вами проблема pthread повинна бути вирішена або новішою версією, або версією, побудованою з правильними прапорами. (щоб увімкнути wait morphingоптимізацію) Основне правило, пояснене за цим посиланням: сповіщати блокування краще в ситуаціях із більш ніж 2 потоками для отримання більш передбачуваних результатів.
v.oddou

6
@Michael: Наскільки я розумію, споживачеві з часом потрібно зателефонувати the_condition_variable.wait(lock);. Якщо блокування не потрібне для синхронізації виробника та споживача (скажімо, що в основі лежить черга без замовлення spsc), тоді цей замок не має жодної мети, якщо виробник його не заблокує. Чудово мені. Але хіба не існує ризику для рідкісних перегонів? Якщо виробник не тримає замок, чи не міг він зателефонувати до notify_one, поки споживач знаходиться безпосередньо перед очікуванням? Тоді споживач натискає очікування і не прокидається ...
Cookie

1
наприклад, скажімо в наведеному вище коді споживач, std::cout << "Waiting... \n";поки виробник це робить cv.notify_one();, тоді дзвінок пробудження зникає ... Або я чогось тут пропускаю?
Cookie

1
@Cookie. Так, там перегони. Див stackoverflow.com/questions/20982270 / ...
EH9

1
@ eh9: Блін, я щойно знайшов причину помилки, яка час від часу заморожувала мій код завдяки вашому коментарю. Це було пов’язано з цим точним випадком перегонового стану. Розблокування мьютексу після того, як сповіщення повністю вирішило проблему ... Велике спасибі!
galinette

10

Ситуація

Використовуючи vc10 та Boost 1.56, я реалізував одночасну чергу майже так, як пропонується у цій публікації в блозі . Автор розблоковує мьютекс, щоб мінімізувати суперечку, тобто notify_one()викликається з розблокованим мьютексом:

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
}

Розблокування мьютексу підтверджено прикладом у документації Boost :

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

Проблема

Однак це призвело до такої непостійної поведінки:

  • поки notify_one()ще не був викликаний, все ще cond_.wait()може бути перерваний черезboost::thread::interrupt()
  • колись notify_one()вперше було закликано до cond_.wait()глухих кутів; очікування не може бути закінчено до boost::thread::interrupt()або boost::condition_variable::notify_*()більше.

Рішення

Видалення рядка mlock.unlock()змусило код працювати належним чином (сповіщення та переривання закінчують очікування). Зверніть увагу, що notify_one()виклик, коли мьютекс все ще заблокований, він розблокується відразу після виходу з області дії:

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

Це означає, що принаймні з моєю конкретною реалізацією потоку мьютекс не повинен бути розблокований перед викликом boost::condition_variable::notify_one(), хоча обидва способи здаються правильними.


Ви повідомляли про цю проблему Boost.Thread? Я не можу знайти там подібне завдання svn.boost.org/trac/boost/…
magras

@magras На жаль, я цього не зробив, не маючи уявлення, чому я це не врахував. І, на жаль, мені не вдається відтворити цю помилку за допомогою згаданої черги.
Matthäus Brandl

Я не впевнений, що розумію, як раннє пробудження може призвести до глухого кута. Зокрема, якщо ви вийшли з cond_.wait () у pop () після того, як push () звільнив мьютекс черги, але перед тим, як викликати notify_one () - Pop () повинен бачити чергу не пустою, а споживати новий запис, а не wait () ing. якщо ви вийшли з cond_.wait (), поки push () оновлює чергу, блокування слід утримувати натисканням (), таким чином pop () повинен заблокувати очікування звільнення блокування. Будь-які інші ранні пробудження утримують блокування, не дозволяючи push () змінювати чергу перед тим, як pop () викликає наступне очікування (). Що я пропустив?
Кевін,

4

Як зазначали інші, вам не потрібно тримати замок під час дзвінків notify_one(), з точки зору умов перегонів та проблем, пов'язаних з різьбою. Однак у деяких випадках може знадобитися утримання замка, щоб запобігти condition_variableруйнуванню до того, як notify_one()буде викликано. Розглянемо наступний приклад:

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)
}

Припустимо, що є контекстний перемикач для новоствореного потоку tпісля того, як ми створили його, але перед тим, як почати очікувати на змінну умови (десь між (5) і (6)). Потік tотримує замок (1), встановлює предикатну змінну (2), а потім звільняє замок (3). Припустимо, що в цей момент є ще один перемикач контексту перед тим, notify_one()як виконати (4). Основний потік отримує замок (6) і виконує рядок (7), після чого предикат повертається, trueі немає причин чекати, тому він звільняє замок і продовжує. fooповертається (8), а змінні в його обсязі (включаючи cv) знищуються. Перш ніж потік tзміг приєднатися до основного потоку (9), він повинен закінчити своє виконання, тому продовжує з того місця, де зупинився для виконанняcv.notify_one()(4), в цей момент cvвже зруйнований!

Можливим виправленням у цьому випадку є утримання фіксатора під час дзвінка notify_one(тобто видалення області, що закінчується рядком (3)). Роблячи це, ми гарантуємо, що tвиклики потоків notify_oneраніше cv.waitможуть перевіряти щойно встановлену змінну предиката та продовжувати, оскільки для перевірки йому потрібно буде отримати блокування, яке t наразі утримується. Отже, ми гарантуємо, що після повернення cvне буде доступний потік .tfoo

Підводячи підсумок, проблема в цьому конкретному випадку насправді полягає не в потоковості, а в житті змінних, зафіксованих посиланням. cvзахоплюється посиланням через потік t, отже, вам слід переконатися, що він cvзалишається живим протягом усього періоду виконання потоку. Інші приклади, представлені тут, не страждають від цієї проблеми, оскільки condition_variableі mutexоб’єкти визначені в глобальному масштабі, отже, вони гарантовано зберігатимуться в живих до виходу програми.


1

@ Michael Burr правильний. condition_variable::notify_oneне вимагає блокування змінної. Ніщо не заважає вам використовувати замок у цій ситуації, як це показує приклад.

У наведеному прикладі блокування мотивовано одночасним використанням змінної i. Оскільки signalsпотік модифікує змінну, їй потрібно забезпечити, щоб жоден інший потік не отримував до неї доступ протягом цього часу.

Блокування використовуються для будь-якої ситуації, яка вимагає синхронізації , я не думаю, що ми можемо заявити про це більш загальним чином.


звичайно, але крім цього їх також потрібно використовувати в поєднанні зі змінними стану, щоб весь шаблон насправді працював. примітно, waitфункція змінної умови звільняє блокування всередині виклику і повертається лише після того, як вона знову придбала блокування. після цього ви можете сміливо перевіряти свій стан, бо, скажімо, ви набули "права на читання". якщо це все ще не те, чого ви чекаєте, ви повертаєтесь до wait. це шаблон. До речі, цей приклад НЕ поважає його.
v.oddou

1

У деяких випадках, коли cv може бути зайнятий (заблокований) іншими потоками. Вам потрібно отримати блокування та відпустити його, перш ніж повідомляти _ * ().
Якщо ні, сповіщення _ * (), можливо, взагалі не виконується.


1

Просто додавши цю відповідь, оскільки я вважаю, що прийнята відповідь може ввести в оману. У всіх випадках вам потрібно буде заблокувати мьютекс, перш ніж десь викликати notify_one (), щоб ваш код був потокобезпечним, хоча ви можете розблокувати його ще раз перед тим, як фактично викликати notify _ * ().

Для уточнення, ви ПОВИННІ взяти замок перед тим, як ввести wait (lk), оскільки wait () розблоковує lk, і це буде невизначеною поведінкою, якщо замок не буде заблокований. Це не так з notify_one (), але вам потрібно переконатися, що ви не будете викликати notify _ * (), перш ніж вводити функцію wait () і розблокувати мьютекс; що, очевидно, можна зробити лише заблокувавши той самий мьютекс перед тим, як зателефонувати до сповіщення _ * ().

Наприклад, розглянемо такий випадок:

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);
}

Попередження : цей код містить помилку.

Ідея полягає в наступному: потоки викликають start () і stop () попарно, але лише до тих пір, поки start () повертає true. Наприклад:

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

Один (інший) потік у певний момент викличе cancel (), а після повернення з cancel () знищить об’єкти, які потрібні в пункті «Здійснювати речі». Однак, cancel () не повинен повертатися, поки між start () і stop () є потоки, а після того, як cancel () виконає свій перший рядок, start () завжди поверне false, тому жодні нові потоки не будуть входити в 'Do зона 'речі'.

Працює правильно?

Міркування такі:

1) Якщо будь-який потік успішно виконує перший рядок start () (і, отже, поверне true), тоді жоден потік ще не виконав перший рядок cancel () (ми вважаємо, що загальна кількість потоків набагато менше 1000 на спосіб).

2) Крім того, хоча потік успішно виконав перший рядок start (), але ще не перший рядок stop (), тоді неможливо, щоб будь-який потік успішно виконав перший рядок cancel () (зверніть увагу, що лише один потік коли-небудь викликати cancel ()): значення, яке повертає fetch_sub (1000), буде більшим за 0.

3) Як тільки потік виконує перший рядок cancel (), перший рядок start () завжди повертає false, а потік, що викликає start (), більше не буде входити в область 'Do stuff'.

4) Кількість дзвінків для запуску () та зупинки () завжди збалансовано, тому після того, як перший рядок cancel () буде невдало виконаний, завжди буде момент, коли (останній) дзвінок для зупинки () викликає підрахунок досягти -1000 і, отже, notify_one () для виклику. Зауважте, що це може статися лише тоді, коли перший рядок скасування призвів до того, що ця нитка пропала.

Окрім проблеми голоду, коли стільки потоків викликає start () / stop (), що кількість ніколи не досягає -1000, а cancel () ніколи не повертається, що можна прийняти як "малоймовірне і ніколи не триває довго", є ще одна помилка:

Цілком можливо, що в області «Здійснити» є одна нитка, скажімо, це просто виклик stop (); в цей момент потік виконує перший рядок cancel (), зчитуючи значення 1 за допомогою fetch_sub (1000) і потрапляючи. Але перед тим, як взяти мьютекс і / або зробити виклик для очікування (lk), перший потік виконує перший рядок stop (), читає -999 і викликає cv.notify_one ()!

Тоді цей виклик notify_one () виконується ДО того, як ми зачекаємо () - внесення змінної умови! І програма буде на невизначений час безвихідна.

З цієї причини ми не можемо викликати notify_one (), поки ми не зателефонуємо wait (). Зверніть увагу, що потужність змінної умови полягає в тому, що вона здатна атомно розблокувати мьютекс, перевірити, чи відбувся виклик notify_one () і перейти в режим сну чи ні. Ви не можете обдурити його, але робити треба тримати м'ютекс заблокований щоразу , коли ви вносите зміни в змінних , які можуть змінити стан від помилкового до істини і тримати його під замком при виклику notify_one () з - за умов гонки , як описано тут.

У цьому прикладі немає жодної умови. Чому я не використав як умову 'count == -1000'? Оскільки тут це зовсім не цікаво: як тільки взагалі буде досягнуто значення -1000, ми впевнені, що жоден новий потік не потрапить у область "Здійснювати речі". Більше того, потоки все ще можуть викликати start () і збільшуватимуть кількість (до -999 та -998 тощо), але нас це не хвилює. Важливо лише те, що було досягнуто -1000 - щоб ми точно знали, що в області "Здійснювати речі" більше немає потоків. Ми впевнені, що це так, коли викликається notify_one (), але як переконатися, що ми не викликаємо notify_one () до того, як cancel () заблокував його мьютекс? Просто блокування cancel_mutex незадовго до notify_one () не допоможе, звичайно.

Проблема полягає в тому, що, незважаючи на те, що ми не чекаємо умови, умова все ще є , і нам потрібно заблокувати мьютекс

1) до досягнення цієї умови 2) перед тим, як ми зателефонуємо до notify_one.

Тому правильним кодом стає:

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

[... той самий старт () ...]

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

Звичайно, це лише один приклад, але інші випадки дуже схожі; майже у всіх випадках, коли ви використовуєте умовну змінну, вам потрібно буде заблокувати цей мьютекс (незабаром) перед викликом notify_one (), інакше можливо, що ви викликаєте його перед викликом wait ().

Зверніть увагу, що я розблокував мьютекс перед викликом notify_one (), оскільки в іншому випадку існує (мала) ймовірність того, що виклик notify_one () пробудить потік, чекаючи змінної умови, яка потім спробує взяти мьютекс і блок, перш ніж ми знову звільнимо мьютекс. Це лише трохи повільніше, ніж потрібно.

Цей приклад був якось особливим у тому, що рядок, що змінює умову, виконується тим самим потоком, який викликає wait ().

Більш звичним є випадок, коли один потік просто чекає, поки умова стане істинним, а інший потік бере блокування перед зміною змінних, що беруть участь у цій умові (змушуючи її стати істинною). В цьому випадку м'ютекс буде негайно заблокований до того (і після) умови збулося - так це абсолютно нормально , щоб просто розблокувати м'ютекс перед викликом повідомить _ * () в цьому випадку.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.