6.2.1. Потокобезопасный стек с блокировками

В следующем листинге воспроизведен код потокобезопасного стека из главы 3. Задача состояла в том, чтобы реализовать потокобезопасную структуру данных наподобие std::stack<>, которая поддерживала бы операции заталкивания и выталкивания.

Листинг 6.1. Определение класса потокобезопасного стека

#include <exception>

struct empty_stack: std::exception {

 const char* what() const throw();

};

template<typename T>

class threadsafe_stack {

private:

 std::stack<T> data;

 mutable std::mutex m;

public:

 threadsafe_stack(){}

 threadsafe_stack(const threadsafe_stack& other) {

  std::lock_guard<std::mutex> lock(other.m);

  data = other.data;

 }

 threadsafe_stack& operator=(const threadsafe_stack&) = delete;

 void push(T new_value) {

  std::lock_guard<std::mutex> lock(m);

  data.push(std::move(new_value)); ← (1)

 }

 std::shared_ptr<T> pop() {

  std::lock_guard<std::mutex> lock(m);

  if (data.empty()) throw empty_stack(); ← (2)

  std::shared_ptr<T> const res(

   std::make_shared<T>(std::move(data.top())));← (3)

  data.pop(); ← (4)

  return res;

 }

 void pop(T& value) {

  std::lock_guard<std::mutex> lock(m);

  if (data.empty()) throw empty_stack();

  value = std::move(data.top()); ← (5)

  data.pop(); ← (6)

 }

 bool empty() const {

  std::lock_guard<std::mutex> lock(m);

  return data.empty();

 }

};

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

Во-вторых, существует потенциальная гонка между empty() и любой из функций pop(), но поскольку мы явно проверяем, что стек пуст, удерживая блокировку в pop(), эта гонка не проблематична. Возвращая извлеченные данные прямо в pop(), мы избегаем потенциальной гонки, которая могла бы случиться, если бы top() и pop() были отдельными функциями-членами, как в std::stack<>.

Далее, существует несколько возможных источников исключений. Операция захвата мьютекса может возбудить исключение, но, во-первых, это крайне редкий случай (свидетельствующий о проблемах в реализации мьютекса или о нехватке системных ресурсов), а, во-вторых, эта операция всегда выполняется в самом начале любой функции-члена. Поскольку в этот момент никакие данные еще не изменены, опасности нет. Операция освобождения мьютекса не может завершиться ошибкой, она всегда безопасна, а использование std::lock_guard<> гарантирует, что мьютекс не останется захваченным.

Вызов data.push() (1) может возбудить исключение, если его возбуждает копирование или перемещение данных либо если памяти недостаточно для увеличения размера структуры, в которой хранятся сами данные. В любом случае std::stack<> гарантирует безопасность, поэтому здесь проблемы тоже нет.

В первом перегруженном варианте pop() наш код может возбудить исключение empty_stack (2), но в этот момент еще ничего не изменено, так что мы в безопасности. Создание объекта res (3) может возбудить исключение по двум причинам: при обращении к std::make_shared может не хватить памяти для нового объекта и внутренних данных, необходимых для подсчёта ссылок, или копирующий либо перемещающий конструктор возбуждает исключение при копировании или перемещении данных в только что выделенную область памяти. В обоих случаях исполняющая среда С++ и стандартная библиотека гарантируют отсутствие утечек памяти и корректное уничтожение нового объекта (если он был создан). Поскольку мы все еще не модифицировали данные стека, все хорошо. Вызов data.pop() (4) гарантированно не возбуждает исключений, равно как и возврат результата, так что этот вариант pop() безопасен относительно исключений.

Второй перегруженный вариант pop() аналогичен, только на этот раз исключение может возбудить оператор копирующего или перемещающего присваивания (5), а не конструктор нового объекта или экземпляра std::shared_ptr. Но и теперь мы ничего не изменяли до вызова функции data.pop() (6), которая гарантированно не возбуждает исключений, так что и этот вариант безопасен относительно исключений.

Наконец, функция empty() вообще не изменяет данные, так что она точно безопасна относительно исключений

В этом коде есть две возможности для взаимоблокировки из-за того, что пользовательский код вызывается, когда удерживается блокировка: копирующий или перемещающий конструктор (1), (3) и копирующий или перемещающий оператор присваивания (5) хранимых в стеке данных. И еще — operator new, который также мог бы быть определён пользователем. Если любая из этих функций вызовет функции-члены стека, в который вставляется или из которого удаляется элемент, либо затребует какую-либо блокировку в момент, когда удерживается блокировка, захваченная при вызове функции-члена стека, то может возникнуть взаимоблокировка. Однако было бы разумно возложить ответственность за это на пользователей стека; невозможно представить себе разумную реализацию операций добавления в стек и удаления из стека, которые не копировали бы данные и не выделяли память.

Поскольку все функции-члены используют для защиты данных класс std::lock_guard<>, их можно безопасно вызывать из любого количества потоков. Единственные небезопасные функции-члены — конструкторы и деструкторы, но эта проблема не особенно серьезна; объект можно сконструировать и уничтожить только один раз. Вызов функций-членов не полностью сконструированного или частично уничтоженного объекта — это всегда плохо, и к одновременности доступа отношения не имеет. Таким образом, пользователь должен гарантировать, что никакой другой поток не может обратиться к стеку, пока он не будет сконструирован полностью, и что любая операция доступа завершается до начала его уничтожения.

Хотя благодаря блокировке несколько потоков могут одновременно вызывать функции-члены стека, в каждый момент времени с ним реально работает не более одного потока. Такая сериализация потоков может снизить производительность приложения в случае, когда имеется значительная конкуренция за стек, — пока поток ожидает освобождения блокировки, он не выполняет никакой полезной работы. Кроме того, стек не предоставляет средств, позволяющих ожидать добавления элемента. Следовательно, если потоку нужно получить из стека элемент, то он должен периодически опрашивать его с помощью empty() или просто вызывать pop() и обрабатывать исключение empty_stack.

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