Лекция 6

Синхронизация потоков

Содержание

  • проблемы многопоточности
  • мьютексы
  • условные переменные
  • шаблон consumer-producer

Поэты золотого века

Афанасий Афанасьевич Фет
Я пришел к тебе с приветом,
Рассказать, что солнце встало,
Что оно горячим светом
По листам затрепетало;
...
Михаил Юрьевич Лермонтов
Белеет парус одинокой
В тумане моря голубом!..
Что ищет он в стране далекой?
Что кинул он в краю родном?..
...
Александр Сергеевич Пушкин
Мороз и солнце; день чудесный!
Еще ты дремлешь, друг прелестный —
Пора, красавица, проснись:
...

Сочиняют стихи

content content content content content content content

Проблема синхронизации

Ф: Я пришел к тебе с приветом,
Л: Белеет парус одинокой
Ф: Рассказать, что солнце встало,
П: Мороз и солнце; день чудесный!
П: Еще ты дремлешь, друг прелестный —
Л: В тумане моря голубом!..
Ф: Что оно горячим светом
Л: Что ищет он в стране далекой?
П: Пора, красавица, проснись:
Л: Что кинул он в краю родном?..
Ф: По листам затрепетало;
...

Синхронизация потоков

Чтобы избежать подобных проблем, необходимо синхронизировать выполнение кода в разных потоках.

Примитивы синхронизации

Существуют некоторые примитивы, которые помогают выполнять синхронизацию потоков.

  • мютексы
  • семафоры
  • критические секции
  • события
  • атомарные переменные

Гонка за данными

В программировании под состоянием “гонки” понимается любая ситуация, исход которой зависит от относительного порядка выполнения операций в более чем в одном потоке.

Data race

  • Гонки приводят к ошибкам в случае, если они (гонки) приводят к нарушению инвариантов.

  • Инвариант - утверждение о структуре данных, которое всегда должно быть истинным.

Устранение гонок

Чтобы избавиться от проблематичных гонок, структуру данных можно снабдить неким защитным механизмом, который гарантирует, что только один поток может видеть промежуточные состояния при нарушении инвариантов.

Структуры данных без блокировок

Другой способ избежать проблем - изменить дизайн структуры данных и её инварианты так, чтобы модификация представляла собой последовательность неделимых изменений, каждое из которых сохраняет инварианты. Такой подход называется программированием без блокировок.

Mutual exclusion - mutex

Взаимоисключающая блокировка - простейший сбособ защиты разделяемых данных.

Мьютексы - наиболее общий механизм защиты данных.

Mutex

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

RAII

  • Необходимо освобождать мьютекс на каждом пути выхода из функции, в том числе и при исключениях.
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 используется в случае, если мьютекс уже захвачен текущим потоком

Read Write Mutex

Если несколько потоков только считывают данные и не модифицируют их, то гонок за данными не возникает.

Read Write Mutex

Поэтому иногда разумно предоставлять доступ для чтения к разделяемым данным нескольким потокам одновременно.

Read Write Mutex

Если же какой-то поток пытается модифицировать данные, то ему следует предоставлять монопольный доступ.

Read Write Mutex

Для такой ситуации существует класс 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

Взаимная блокировка – ситуация, когда два или более потока ожидают завершения друг друга.

Deadlock

content

Deadlock

content content content

Причины deadlock

Deadlock возможен, когда для выполнения процедуры потоки должны захватить два мьютекса, но по каким-то причинам каждый поток захватил только по одному мьютексу.

Как избежать deadlock’ов

  • Постарайтесь отказаться от вложенных блокировок. Не захватывайте мьютекс, если уже захватили другой.

Как избежать deadlock’ов

  • Захватывать мьютексы в фиксированном порядке.

Сначала возьми перо, потом чернила. Нет пера, оставь чернила в покое и жди перо

Как избежать deadlock’ов

  • Использовать иерархию блокировок.

Идея состоит в том, чтобы каждому мьютексу присвоить численное значение уровня, и позволять захватывать потоку только мьютексы с большим значением. Этим обеспечивается строгий порядок захвата мьютексов

Как избежать deadlock’ов

  • Не вызывать пользовательский код, когда удерживаете мьютекс.

Неизвестно, что выполняет пользовательский код. Возможно, он будет пытаться захватить еще один мьютекс.

Как избежать 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 совершилось.
}

Conditional Variable

Для синхронизации логических зависимостей между потоками, которыми можно обмениваться многократно, можно использовать условные переменные.

Conditional Variable

Условные переменные предоставляют простой механизм ожидания события, возникающего в другом потоке.

Их работу удобно рассмотреть на примере шаблона паралелльного программирования Producer-Consumer.

Шаблон Producer-Consumer

Этот шаблон часто применяется в высоконагруженных системах и распределенных вычислениях.

Шаблон Producer-Consumer

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

Шаблон Producer-Consumer – это обобщение принципа подобного разделения.

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 – обрабатывает их.

Как работает Producer

  1. В “поставщике” все относительно просто: захватывается мьютекс, который ассоциирован с данными (std::queue), затем добавляется новая задача для обработки “потребителем”.

Как работает Producer

  1. Чтобы “потребители” приступили к работе их необходимо оповестить о том, что данные готовы. Для этого вызывается метод notify_one у условной переменной.

Как работает Consumer

  1. В “потребителях” аналогично захватывается мьютекс, который ассоциирован с данными, чтобы сохранить инварианты в целости и никто не мог изменить данные.

Как работает Consumer

  1. Затем проверяется условие, которое проверит готовы ли данные в текущий момент. Если данные готовы, то можно сразу приступить к их обработке.

Как работает Consumer

  1. Если данные в текущий момент не готовы, то стоит дождаться их готовности. Для этого вызываем метод wait у условной переменной.

Как работает Consumer

  1. Метод wait является блокирующим и поток Consumer будет ожидать сигнала от соответствующего объекта условной переменной.

Как работает Consumer

  1. Чтобы поток Producer смог предоставить данные необходимо разблокировать мьютекс. Что и происходит в методе wait.

Как работает Consumer

  1. Как только из соседнего потока будет передан сигнал, т.е. будет вызован метод notify_one, метод wait перейдет из блокирующего состояния в активное. При этом снова захватит мьютекс, переданный в качестве аргумента. И метод Consumer продолжит свое выполнение.

Как работает Consumer

  1. Стоит отметить, что иногда происходит ложное срабатывания, и метод wait переходит в активное состояние без соответствующего вызова метода notify_one. Для этого wait вызывается в цикле.

Домашнее задание

  • std::recursive_mutex
  • реализация потокобезопасные структуры данных с блокировками