Skip to content

Latest commit

 

History

History
169 lines (121 loc) · 17.3 KB

File metadata and controls

169 lines (121 loc) · 17.3 KB

Condition Variable

Содержание:


Wait

Для начала приведем семантику работы wait(lock, Predicate) с использованием предиката:

while (!pred()) {
    wait(lock); // <- здесь вызывается обычный wait(lock) без предиката
}

Тогда можем сделать следующие выводы:

  1. Когда тред дошел до строчки с wait(lock, Predicate) в первый раз, то сначала проверяется предикат:
    • если true, то тред просто продолжает работу
    • если false, то тред вызывает wait(lock), который атомарно [разблокирует мьютекс + помещает тред в заблокированное состояние] в ожидании оповещения

Это важный момент: тред не ждет получения оповещения в самый первый вызов wait(lock, Predicate), так как возможен случай, когда предикат был изменён, но оповещение просто потерялось.

Теперь, если condition variable находится в состоянии ожидания (то есть проверка предиката была неуспешной и тред заблокировался) и приходит оповещение:

  1. Тред атомарно [разблокируется + захватывает мьютекс]
  2. Проверяется предикат:
    • Если true: тред продолжает работу (при этом, мьютекс остаётся заблокированным)
    • Если false: атомарно [разблокирует мьютекс + помещает тред в заблокированное состояние]

Как пример, рассмотрим работу двух следующих функций - receiver (Thread 1) и sender (Thread 2):

// T1: receiver
void waitingForWork() {
    std::cout << "Waiting " << std::endl;
    std::unique_lock<std::mutex> lck(mutex_);
    condVar.wait(lck, []{ return dataReady; });
    std::cout << "Running " << std::endl;
}

// T2: sender
void setDataReady() {
    {
        std::lock_guard<std::mutex> lck(mutex_);
        dataReady = true;
    }
    std::cout << "Data prepared" << std::endl;
    condVar.notify_one();
}

Рассмотрим 2 ситуации: если первый тред захватил лок первым, и если второй тред захватил лок первым

Receiver захватил лок первый:

Тогда sender блокируется в ожидании разблокировки мьютекса

  1. Первый тред, захватив лок, продолжает работу и доходит до строчки с wait
  2. wait сразу, без ожидания оповещений, проверяет предикат, видит, что предикат равен false (так как второй тред в это время был заблокирован и не мог изменяться), атомарно разблокирует мьютекс и тред блокируется в ожидании оповещений на condVar
  3. Второй поток дождался освобождения мьютекса, тред разблокируется и предиката устанавливается в значение true
  4. Второй поток разблокирует мьютекс и продолжает работу, далее посылая оповещение командой notify_one()
  5. Первый тред получает оповещение и проверяет предикат, видит что предикат равен true, и тред продолжает работу

Sender захватил лок первый:

Тогда receiver блокируется в ожидании разблокировки мьютекса

  1. Второй тред устанавливает значение предиката в true и разблокирует мьютекс

    Здесь возможны 2 ситуации:

    1. Первый тред может продолжить работу в этот же момент, проверяет, что предикат равен true и продолжает работу
    2. Первый тред не продолжает работу в этот же момент, тогда второй тред продолжает работу и посылает оповещение первому потоку. Первый поток возвращается к работе, не отслеживает оповещение, а сразу проверяет что предикат равен true и продолжает работу.

Зачем нужен предикат

Если не использовать предикат, то:

  • Возможна ситуация, когда сигнал был послан до того, как поток начал ожидание сигнала, а значит сигнал может потеряться (lost wakeup)
  • Также возможна ситуация, когда произошло ложное оповещение (spurious wakeup), и тред был разблокирован, однако на самом деле ожидаемого ивента не происходило

Поэтому мы заручаемся поддержкой не только сигналов, но и памяти. По состоянию памяти (т.е. переменной) мы можем гарантировать правильное происхождение ивентов


Почему нужно использовать мьютекс в sender потоке? Что, если просто сделать предикат атомарной переменной?

Допустим, что код потоков будет таким:

void waitingForWork(){
    std::cout << "Waiting " << std::endl;
    std::unique_lock<std::mutex> lck(mutex_);
    condVar.wait(lck, []{ return dataReady.load(); });
    std::cout << "Running " << std::endl;
}

void setDataReady(){
     dataReady.store(true);
     std::cout << "Data prepared" << std::endl;
     condVar.notify_one();
}

Тогда, еще раз рассмотрим работу двух потоков:

  1. Если первый поток доходит до строчки с wait(), он проверяет первый раз:
    1. Если второй поток успел положить значение true в предикат до того, как первый поток начал проверять его, то первый поток убеждается в происхождении ивента и успешно продолжает работу, все проходит нормально
    2. Если первый поток видит значение false, то тред вызывает wait() и поток блокируется. НО, именно между моментом проверки предиката и блокировки треда в ожидании оповещений появляется временное окно, т.е. эти действия не происходят атомарно. Именно в этот промежуток времени мог произойти context switch и второй тред отправил сигнал до того, как первый тред начал ожидать оповещений.

Итог: сигнал, посланный вторым потоком (sender) теряется, первый поток (receiver) больше никогда не получит оповещение, а значит, первый поток блокируется навсегда. Такая ситуация известна как Deadlock.

Данная проблема решается применением мьютекса во втором треде. Если первый поток захватил лок, то второй поток не может работать с предикатом и посылать оповещения. Первый поток увидит значение false, пройдет какой-то промежуток времени, но даже если произойдёт Context Switch, второй поток не сможет получить доступ к работе над предикатом, так как он заблокируется на мьютексе, далее первый поток атомарно разблокирует мьютекс и входит в заблокированное состояние в ожидании оповещения, после этого начинает работать второй поток и посылать оповещения.

Без использования мьютекса во втором потоке (sender) могла возникнуть такая ситуация:

            Receiver            |           Sender
    ----------------------------|-----------------------------
    unique_lock                 |
    while (!condition)          |
    <временное окно,            |
    происходит context switch>  |
                                |      condition = true
                                |      condVar.notify_one()
                                |
    condVar.wait()              |

Почему нужно использовать mutex в вызове wait()?

Для избежания race condition.

Важная особенность wait заключается в том, что он атомарно разблокирует мьютекс и помещает тред в заблокированное состояние в ожидании оповещений. Если бы данная операция не была атомарной, то между моментом разблокировки мьютекса и началом ожидания оповещения сигнал мог послаться другим тредом и потеряться, так как первый тред не успел войти в состояние ожидания сигнала. Как итог, происходит deadlock. То есть, передавая мьютекс как аргумент, мы исключаем данную ситуацию.

Кроме того, команда wait() не только атомарно разблокирует мьютекс и помещает тред в заблокированное состояние, но также, когда произойдет wakeup, то есть придет сигнал, атомарно разблокирует тред и повторно блокирует мьютекс. Дело в том, что wakeup мог быть ложным (spurious wakeup), и второй тред еще даже не приступал к работе, или же еще не успел послать настоящий сигнал. Если бы разблокировка треда и повторная блокировка мьютекса не были неделимыми действиями, то второй тред мог вступить в работу и послать сигнал, который потеряется

Если бы повторной блокировки мьютекса (переданного как аргумент) не было, то второй тред смог бы изменять состояние памяти в то время, как память проверяется первым потоком, и возник бы race condition.


Выводы

  • Всегда нужно помнить о том, что может произойти context switch на любой строчке исполнения кода. Мы используем мьютекс в sender треде, так как не хотим, чтобы между моментом проверки предиката и вызовом wait() (началом ожидания сигнала) второй тред послал сигнал, который потерялся бы, так как первый поток еще не успел начать ожидать оповещения. Применением мьютекса в sender треде мы решаем первый случай проблемы Lost Wakeup
  • Вызов wait() после получения сигнала и последующей ложной проверки (в случае истинной проверки предиката мьютекс остается блокированным и тред просто продолжает работу дальше) предиката производит операции [разблокировки мьютекса и повторного помещения тред в состояние ожидания сигналов] атомарно - это исключает другую ситуацию, когда предикат был проверен, и производится разблокировка мьютекса, чтобы дать работать другим тредам sender'ам, сразу бы произошел Context Switch, и второй тред мог вступить в работу (так как мьютекс уже разблокирован) и послать сигнал, который потерялся бы, так как первый тред еще не успел войти в состояние ожидания сигналов. Этим решается второй случай возможного Lost Wakeup
  • Вызов wait() при получении сигнала производит операции [пробуждения треда и блокировки мьютекса] атомарно, потому что, если бы эти операции не были неделимыми, то после пробуждения треда сразу мог произойти Context Switch и второй тред вступить в работу, и послать сигнал, который потеряется. Дело в том, что пробуждение треда могло быть ложным (spurious wakeup), и на самом деле сигнала от второго треда не было вообще. Но чтобы проверить, было ли пробуждение правильным, необходимо проверить состояние предиката, а для этого требуется блокировать на время проверки мьютекс, чтобы треды sender'ы не смогли послать настоящие сигналы, которые обязательно потеряются, пока receiver не вступит снова в состояние ожидания. Итак, этим решается 3 случай проблемы Lost Wakeup
  • Мы применяем предикат для решения проблемы Spurious Wakeup

Предикаты

Нам нужен предикат, так как сигналы могут теряться - мы заручаемся дополнительной гарантией в виде памяти. С помощью отслеживания состояния памяти мы можем исключить lost wakeup и spurious wakeup. Ведь ложных и потерянных обновлений не бывает, верно?

Spurious wakeup

Spurious wakeup - ложное пробуждение condition variable, а значит и ожидающего треда. То есть сигнала от треда, который должен был послать оповещение ожидающему потоку, на самом деле не было.

Lost wakeup

Lost wakeup - ситуация, когда сигнал теряется. То есть оповещение произошло тогда, когда сигнал никто не ожидал. Другими словами, вызов notify() произошел до вызова wait()