Я пришел к тебе с приветом,
Рассказать, что солнце встало,
Что оно горячим светом
По листам затрепетало;
...
Белеет парус одинокой
В тумане моря голубом!..
Что ищет он в стране далекой?
Что кинул он в краю родном?..
...
Мороз и солнце; день чудесный!
Еще ты дремлешь, друг прелестный —
Пора, красавица, проснись:
...
Ф: Я пришел к тебе с приветом,
Л: Белеет парус одинокой
Ф: Рассказать, что солнце встало,
П: Мороз и солнце; день чудесный!
П: Еще ты дремлешь, друг прелестный —
Л: В тумане моря голубом!..
Ф: Что оно горячим светом
Л: Что ищет он в стране далекой?
П: Пора, красавица, проснись:
Л: Что кинул он в краю родном?..
Ф: По листам затрепетало;
...
Чтобы избежать подобных проблем, необходимо синхронизировать выполнение кода в разных потоках.
Существуют некоторые примитивы, которые помогают выполнять синхронизацию потоков.
В программировании под состоянием “гонки” понимается любая ситуация, исход которой зависит от относительного порядка выполнения операций в более чем в одном потоке.
Гонки приводят к ошибкам в случае, если они (гонки) приводят к нарушению инвариантов.
Инвариант - утверждение о структуре данных, которое всегда должно быть истинным.
Чтобы избавиться от проблематичных гонок, структуру данных можно снабдить неким защитным механизмом, который гарантирует, что только один поток может видеть промежуточные состояния при нарушении инвариантов.
Другой способ избежать проблем - изменить дизайн структуры данных и её инварианты так, чтобы модификация представляла собой последовательность неделимых изменений, каждое из которых сохраняет инварианты. Такой подход называется программированием без блокировок.
Взаимоисключающая блокировка - простейший сбособ защиты разделяемых данных.
Мьютексы - наиболее общий механизм защиты данных.
class mutex {
public:
void lock();
bool try_lock();
void unlock();
native_handle_type native_handle();
};
std::cout paper;
std::mutex mutex;
void WriteVerse() {
mutex.lock();
for (auto str : CreateVerse()) {
paper << str;
}
mutex.unlock();
}
std::jthread Pushkin(WriteVerse);
std::jthread Lermontov(WriteVerse);
std::jthread Fet(WriteVerse);
lock()
unlock()
void WriteVerse() {
mutex.lock();
for (auto str : CreateVerse()) {
dAnthes.Shoots();
// Что случится, если будет сгенерировано исключение?
paper << str;
}
mutex.unlock();
}
std::lock_guard
template <typename mutex_type>
struct lock_guard {
explicit lock_guard( mutex_type& m );
lock_guard( mutex_type& m, std::adopt_lock_t t );
lock_guard( const lock_guard& ) = delete;
// ...
};
void WriteVerse() {
std::lock_guard<std::mutex> lk(mutex);
for (auto str : CreateVerse()) {
dAnthes.Shoots();
paper << str;
}
}
std::unique_lock
Класс std::unique_lock
обладает большей гибкостью, чем std::lock_guard
.
std::unique_lock
реализует семантику перемещения
std::unique_lock
позволяет управлять ассоциированным с ним мьютексом (есть методы lock
, try_lock
, unlock
)
std::unique_lock
template <typename mutex_type>
struct unique_lock {
// ...
unique_lock(mutex_type& m, std::defer_lock_t t) noexcept;
unique_lock(mutex_type& m, std::try_to_lock_t t);
unique_lock(mutex_type& m, std::adopt_lock_t t);
// ...
};
std::unique_lock
defer_lock_t
не захватывает мьютексtry_to_lock_t
пытается захватить мьютекс с помощью функции try_lock()
adopt_lock_t
используется в случае, если мьютекс уже захвачен текущим потокомЕсли несколько потоков только считывают данные и не модифицируют их, то гонок за данными не возникает.
Поэтому иногда разумно предоставлять доступ для чтения к разделяемым данным нескольким потокам одновременно.
Если же какой-то поток пытается модифицировать данные, то ему следует предоставлять монопольный доступ.
Для такой ситуации существует класс shared_mutex
.
std::shared_mutex m;
T read() {
// Несколько потоков может читать данные одновременно.
// Поэтому используем shared_lock.
std::shared_lock<std::shared_mutex> lk(m);
// Чтение разделяемых данных.
}
void modify() {
// Чтобы не возникало гонки за данными,
// только один потом может изменять данные.
// Используем lock_guard или unique_lock.
std::lock_guard<std::shared_mutex> lk(m);
// Изменяем разделяемые данные.
}
Взаимная блокировка – ситуация, когда два или более потока ожидают завершения друг друга.
Deadlock возможен, когда для выполнения процедуры потоки должны захватить два мьютекса, но по каким-то причинам каждый поток захватил только по одному мьютексу.
Сначала возьми перо, потом чернила. Нет пера, оставь чернила в покое и жди перо
Идея состоит в том, чтобы каждому мьютексу присвоить численное значение уровня, и позволять захватывать потоку только мьютексы с большим значением. Этим обеспечивается строгий порядок захвата мьютексов
Неизвестно, что выполняет пользовательский код. Возможно, он будет пытаться захватить еще один мьютекс.
Возможно, синхронизация потоков и мьютексы не нужны. Если вы сведете задачу к однопоточному режиму или к случаю, когда функции будут выполняться последовательно, это решит проблемы многопоточности.
Для предотвращения вложенных блокировок, следует производить единовременный захват мьютексов – захватить все необходимые мьютексы за раз.
Если не получается захватить все мьютексы, то не захватывать ни один из них.
std::lock
Для такого поведения в С++ есть функция std::lock
, которая захватывает сразу несколько мьютексов.
Если std::lock
успешно захватила первый мьютекс, но во время попытки захвата второго мьютекса произошло исключение, то первый мьютекс освобождается.
std::lock
std::mutex mutex_ink;
std::mutex mutex_pen;
// Одновременно забираем и перо и чернила.
std::lock(mutex_ink, mutex_pen);
std::lock_guard<std::mutex> lk_ink(mutex_ink, std::adopt_lock);
std::lock_guard<std::mutex> lk_pen(mutex_pen, std::adopt_lock);
// Есть и перо и чернила, можно записывать стихотворение.
WriteVerse();
std::adopt_lock
Мьютексы mutex_ink
и mutex_pen
были захвачены внутри функции std::lock
.
std::adopt_lock
Однако, освобождать мьютексы мы предпочтем автоматически.
std::adopt_lock
Для этого, используем конструктор std::lock_guard
, который НЕ захватывает мьютекс.
Но в деструкторе все равно произойдет освобождение мьютекса.
Иногда текущему потоку требуется оповестить о совершенном событии другие потоки, которым для своей работы необходимо знать об этом событии.
std::future<void>
Один из механизмов, который можно использовать для реализации такого поведения, – это std::future
и std::promise
.
Однако, такой механизм позволяет может передавать сигнал от одного потока другому только один раз.
std::future<void>
std::promise<void> chain;
std::future<void> event = chain.get_future();
// Поток-отправитель события.
void Sender() {
// ...
// Сообщаем, что что-то произошло.
chain.set_value();
// Продолжаем выполнение потока.
}
// Поток, ожидающий событие.
void Receiver() {
// ...
// Ждем, пока Sender не сообщит о событии.
event.get();
// Продолжаем выполение потока,
// зная, что событие в потоке Sender совершилось.
}
Для синхронизации логических зависимостей между потоками, которыми можно обмениваться многократно, можно использовать условные переменные.
Условные переменные предоставляют простой механизм ожидания события, возникающего в другом потоке.
Их работу удобно рассмотреть на примере шаблона паралелльного программирования Producer-Consumer.
Этот шаблон часто применяется в высоконагруженных системах и распределенных вычислениях.
Почти всегда можно абстрагировать задачу и выделить в ней части, которые делают некоторый препроцессинг, и части, которые занимаются окончательной обработкой данных на основе этого препроцессинга.
Шаблон Producer-Consumer – это обобщение принципа подобного разделения.
Producer, или “поставщик”, — это некоторый поток, который генерирует “задания” и складывает их в очередь.
Consumer, или “потребитель”, — это поток, который обрабатывает “задачи” из очереди.
std::mutex m;
std::queue<std::string> queue;
std::condition_variable cv;
void Producer() {
std::lock_guard<std::mutex> lk(m);
queue.push(ReadMessageFromNetwork());
cv.notify_one();
}
void Consumer() {
std::unique_lock<std::mutex> lk(m);
while(queue.empty()) {
cv.wait(lk);
}
// Используем очередь.
lk.unlock();
// Продолжаем выполнения потока.
}
Предполагается, что функции Producer
и Consumer
запущены в разных потоках:
Producer
– генерирует данные.
Consumer
– обрабатывает их.
std::queue
), затем добавляется новая задача для обработки “потребителем”.notify_one
у условной переменной.wait
у условной переменной.wait
является блокирующим и поток Consumer
будет ожидать сигнала от соответствующего объекта условной переменной.Producer
смог предоставить данные необходимо разблокировать мьютекс. Что и происходит в методе wait.notify_one
, метод wait
перейдет из блокирующего состояния в активное. При этом снова захватит мьютекс, переданный в качестве аргумента. И метод Consumer
продолжит свое выполнение.wait
переходит в активное состояние без соответствующего вызова метода notify_one
. Для этого wait
вызывается в цикле.std::recursive_mutex