Содержание:
- Wait
- Зачем нужен предикат
- Почему нужно использовать мьютекс в sender потоке? Что, если просто сделать предикат атомарной переменной?
- Почему нужно использовать mutex в вызове wait()?
- Выводы
Для начала приведем семантику работы wait(lock, Predicate) с использованием предиката:
while (!pred()) {
wait(lock); // <- здесь вызывается обычный wait(lock) без предиката
}Тогда можем сделать следующие выводы:
- Когда тред дошел до строчки с
wait(lock, Predicate)в первый раз, то сначала проверяется предикат:- если true, то тред просто продолжает работу
- если false, то тред вызывает
wait(lock), который атомарно [разблокирует мьютекс + помещает тред в заблокированное состояние] в ожидании оповещения
Это важный момент: тред не ждет получения оповещения в самый первый вызов wait(lock, Predicate), так как возможен случай, когда предикат был изменён, но оповещение просто потерялось.
Теперь, если condition variable находится в состоянии ожидания (то есть проверка предиката была неуспешной и тред заблокировался) и приходит оповещение:
- Тред атомарно [разблокируется + захватывает мьютекс]
- Проверяется предикат:
- Если 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 блокируется в ожидании разблокировки мьютекса
- Первый тред, захватив лок, продолжает работу и доходит до строчки с
wait waitсразу, без ожидания оповещений, проверяет предикат, видит, что предикат равен false (так как второй тред в это время был заблокирован и не мог изменяться), атомарно разблокирует мьютекс и тред блокируется в ожидании оповещений на condVar- Второй поток дождался освобождения мьютекса, тред разблокируется и предиката устанавливается в значение true
- Второй поток разблокирует мьютекс и продолжает работу, далее посылая оповещение командой
notify_one() - Первый тред получает оповещение и проверяет предикат, видит что предикат равен true, и тред продолжает работу
Sender захватил лок первый:
Тогда receiver блокируется в ожидании разблокировки мьютекса
-
Второй тред устанавливает значение предиката в true и разблокирует мьютекс
Здесь возможны 2 ситуации:
- Первый тред может продолжить работу в этот же момент, проверяет, что предикат равен true и продолжает работу
- Первый тред не продолжает работу в этот же момент, тогда второй тред продолжает работу и посылает оповещение первому потоку. Первый поток возвращается к работе, не отслеживает оповещение, а сразу проверяет что предикат равен 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();
}Тогда, еще раз рассмотрим работу двух потоков:
- Если первый поток доходит до строчки с
wait(), он проверяет первый раз:- Если второй поток успел положить значение true в предикат до того, как первый поток начал проверять его, то первый поток убеждается в происхождении ивента и успешно продолжает работу, все проходит нормально
- Если первый поток видит значение 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() |
Для избежания 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 - ложное пробуждение condition variable, а значит и ожидающего треда. То есть сигнала от треда, который должен был послать оповещение ожидающему потоку, на самом деле не было.
Lost wakeup - ситуация, когда сигнал теряется. То есть оповещение произошло тогда, когда сигнал никто не ожидал. Другими словами, вызов notify() произошел до вызова wait()