3.2.3. Выявление состояний гонки, внутренне присущих интерфейсам

We use cookies. Read the Privacy and Cookie Policy

Тот факт, что вы пользуетесь мьютексами или другим механизмом для защиты разделяемых данных, еще не означает, что гонок можно не опасаться, — следить за тем, чтобы данные были защищены, все равно нужно. Вернемся снова к примеру двусвязного списка. Чтобы поток мог безопасно удалить узел, необходимо предотвратить одновременный доступ к трем узлам: удаляемому и двум узлам но обе стороны от него. Заблокировав одновременный доступ к указателям на каждый узел но отдельности, мы не достигнем ничего по сравнению с вариантом, где мьютексы вообще не используются, поскольку гонка по-прежнему возможна. Защищать нужно не отдельные узлы на каждом шаге, а структуру данных в целом на все время выполнения операции удаления. Простейшее решение в данном случае — завести один мьютекс, который будет защищать весь список, как в листинге 3.1.

Однако и после обеспечения безопасности отдельных операций наши неприятности еще не закончились — гонки все еще возможны, даже для самого простого интерфейса. Рассмотрим структуру данных для реализации стека, например, адаптер контейнера std::stack, показанный в листинге 3.3. Помимо конструкторов и функции swap(), имеется еще пять операций со стеком: push() заталкивает в стек новый элемент, pop() выталкивает элемент из стека, top() возвращает элемент, находящийся на вершине стека, empty() проверяет, пуст ли стек, и size() возвращает размер стека. Если изменить top(), так чтобы она возвращала копию, а не ссылку (в соответствии с рекомендацией из раздела 3.2.2), и защитить внутренние данные мьютексом, то и тогда интерфейс уязвим для гонки. Проблема не в реализации на основе мьютексов, она присуща самому интерфейсу, то есть гонка может возникать даже в реализации без блокировок.

Листинг 3.3. Интерфейс адаптера контейнера std::stack

template<typename T, typename Container = std::deque<T> >

class stack {

public:

 explicit stack(const Container&);

 explicit stack(Container&& = Container());

 template <class Alloc> explicit stack(const Alloc&);

 template <class Alloc> stack(const Container&, const Alloc&);

 template <class Alloc> stack(Container&&, const Alloc&);

 template <class Alloc> stack(stack&&, const Alloc&);

 bool empty() const;

 size_t size() const;

 T& top();

 T const& top() const;

 void push(T const&);

 void push(T&&);

 void pop();

 void swap(stack&&);

};

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

Если экземпляр stack не является разделяемым, то нет ничего страшного в том, чтобы проверить, пуст ли стек с помощью empty(), а затем, если стек не пуст, вызвать top() для доступа к элементу на вершине стека:

stack<int> s;

if (!s.empty())             ← (1)

{

 int const value = s.top(); ← (2)

 s.pop();                   ← (3)

 do_something(value);

}

Такой подход в однопоточном коде не только безопасен, но и единственно возможен: вызов top() для пустого стека приводит к неопределенному поведению. Но если объект stack является разделяемым, то такая последовательность операций уже не безопасна, так как между вызовами empty() (1) и top() (2) другой поток мог вызвать pop() и удалить из стека последний элемент. Таким образом, мы имеем классическую гонку, и использование внутреннего мьютекса для защиты содержимого стека ее не предотвращает. Это следствие дизайна интерфейса.

И что же делать? Поскольку проблема коренится в дизайне интерфейса, то и решать ее надо путем изменения интерфейса. Но возникает вопроса — как его изменить? В простейшем случае мы могли бы просто декларировать, что top() возбуждает исключение, если в момент вызова в стеке нет ни одного элемента. Формально это решает проблему, но затрудняет программирование, поскольку теперь мы должны быть готовы к перехвату исключения, даже если вызов empty() вернул false. По сути дела, вызов empty() вообще оказывается ненужным.

Внимательно присмотревшись к показанному выше фрагменту, мы обнаружим еще одну потенциальную гонку, на этот раз между вызовами top() (2) и pop() (3). Представьте, что этот фрагмент исполняют два потока, ссылающиеся на один и тот же объект s типа stack. Ситуация вполне обычная: при использовании потока для повышения производительности часто бывает так, что несколько потоков исполняют один и тот же код для разных данных, и разделяемый объект stack идеально подходит для разбиения работы между потоками. Предположим, что первоначально в стеке находится два элемента, поэтому можно с уверенностью сказать, что между empty() и top() не будет гонки ни в одном потоке. Теперь рассмотрим возможные варианты выполнения программы.

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

Поток А -                    - Поток В

if (!s.empty())

                            if (!s.empty())

 int const value = s.top();

                             int const value = s.top();

s.pop();

do_something(value);        s.pop();

                            do_something(value);

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

Для решения проблемы необходимо более радикальное изменение интерфейса — выполнение обеих операций top() и pop() под защитой одного мьютекса. Том Каргилл[4] указал, что такой объединенный вызов приводит к проблемам в случае, когда копирующий конструктор объектов в стеке может возбуждать исключения. С точки зрения безопасности относительно исключений, задачу достаточно полно решил Герб Саттер[5], однако возможность возникновения гонки вносит в нее новый аспект.

Для тех, кто незнаком с историей вопроса, рассмотрим класс stack<vector<int>>. Вектор — это контейнер с динамически изменяемым размером, поэтому при копировании вектора библиотека должна выделить из кучи память. Если система сильно загружена или имеются жесткие ограничения на ресурсы, то операция выделения памяти может завершиться неудачно, и тогда копирующий конструктор вектора возбудит исключение std::bad_alloc. Вероятность такого развития событий особенно велика, если вектор содержит много элементов. Если бы функция pop() возвращала вытолкнутое из стека значение, а не только удаляла его из стека, то мы получили бы потенциальную проблему: вытолкнутое значение возвращается вызывающей программе только после модификации стека, но в процессе копирования возвращаемых данных может возникнуть исключение. Если такое случится, то только что вытолкнутые данные будут потеряны — из стека они удалены, но никуда не скопированы! Поэтому проектировщики интерфейса std::stack разбили операцию на две: получить элемент, находящийся на вершине (top()), а затем удалить его из стека (pop()). Теперь, данные, которые не удалось скопировать, остаются в стеке; если проблема связана с нехваткой памяти в куче, то, возможно, приложение сможет освободить немного памяти и попытаться выполнить операцию еще раз.

Увы, это как раз то разбиение, которого мы пытались избежать в попытке уйти от гонки! К счастью, альтернативы имеются, но они не бесплатны.

Вариант 1: передавать ссылку

Первый вариант решения — передавать функции pop() ссылку на переменную, в которую она должна будет поместить вытолкнутое из стека значение:

std::vector<int> result;

some_stack.pop(result);

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

Вариант 2: потребовать наличия копирующего конструктора, не возбуждающего исключений, или перемещающего конструктора

Проблема с безопасностью относительно исключений в варианте функции pop(), возвращающей значение, проявляется только тогда, когда исключение может возникать в процессе возврата значения. Во многих типах имеются копирующие конструкторы, которые не возбуждают исключений, а после поддержки в стандарте С++ ссылок на r-значения (см. приложение А, раздел А.1), появилось еще много типов, в которых перемещающий конструктор не возбуждает исключений, даже если копирующий конструктор может их возбуждать. Один из вариантов решения заключается в том, чтобы наложить на потокобезопасный стек ограничение: в нем можно хранить только типы, поддерживающие возврат по значению без возбуждения исключений.

Это решение, пусть и безопасное, не идеально. Хотя на этапе компиляции можно узнать, существует ли копирующий или перемещающий конструктор, который не возбуждает исключений, — с помощью концепций std::is_nothrow_copy_constructible, std::is_nothrow_move_constructible и характеристик типов, но это слишком ограничительное требование. Пользовательских типов, в которых копирующий конструктор может возбуждать исключение и перемещающего конструктора нет, гораздо больше, чем типов, в которых копирующий и (или) перемещающий конструктор гарантированно не возбуждают исключений (хотя ситуация может измениться, когда разработчики привыкнут к появившейся в С++11 поддержке ссылок на r-значения). Было бы крайне нежелательно запрещать хранение таких объектов в потокобезопасном стеке.

Вариант 3: возвращать указатель на вытолкнутый элемент

Третий вариант — возвращать не копию вытолкнутого элемента по значению, а указатель на него. Его достоинство в том, указатели можно копировать, не опасаясь исключений, поэтому указанную Каргиллом проблему мы обходим. А недостаток в том, что возврат указателя заставляет искать средства для управления выделенной объекту памятью, так что для таких простых типов, как целые числа, накладные расходы на управление памятью могут превысить затраты на возврат типа по значению. В любом интерфейсе, где применяется этот вариант, в качестве типа указателя было бы разумно избрать std::shared_ptr; мало того что это предотвращает утечки памяти, поскольку объект уничтожается вместе с уничтожением последнего указателя на него, так еще и библиотека полностью контролирует схему распределения памяти и не требует использования new и delete. Это существенно с точки зрения оптимизации — требование, чтобы память для всякого хранящегося в стеке объекта выделялась с помощью new, повлекло бы заметные накладные расходы по сравнению с исходной версией, небезопасной относительно потоков.

Вариант 4: реализовать одновременно вариант 1 и один из вариантов 2 или 3

Никогда не следует пренебрегать гибкостью, особенно в обобщенном коде. Если остановиться на варианте 2 или 3, то будет сравнительно нетрудно реализовать и вариант 1, а это оставит пользователю возможность выбрать наиболее подходящее решение ценой очень небольших накладных расходов.

Пример определения потокобезопасного стека

В листинге 3.4 приведено определение класса стека со свободным от гонок интерфейсом. В нем реализованы приведенные выше варианты 1 и 3: имеется два перегруженных варианта функции-члена pop() — один принимает ссылку на переменную, в которой следует сохранить значение, а второй возвращает std::shared_ptr<>. Интерфейс предельно прост, он содержит только функции: push() и pop().

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

#include <exception>

struct empty_stack: std::exception {

 const char* what() const throw();

};

template<typename T>

class threadsafe_stack {

public:

 threadsafe_stack();

 threadsafe_stack(const threadsafe_stack&);

 threadsafe_stack& operator=(const threadsafe_stack&)

  = delete;← (1)

 void push(T new_value);

 std::shared_ptr<T> pop();

 void pop(T& value);

 bool empty() const;

};

Упростив интерфейс, мы добились максимальной безопасности — даже операции со стеком в целом ограничены: стек нельзя присваивать, так как оператор присваивания удален (1) (см. приложение А, раздел А.2) и функция swap() отсутствует. Однако стек можно копировать в предположении, что можно копировать его элементы. Обе функции pop() возбуждают исключение empty_stack, если стек пуст, поэтому программа будет работать, даже если стек был модифицирован после вызова empty(). В описании варианта 3 выше отмечалось, что использование std::shared_ptr позволяет стеку взять на себя распределение памяти и избежать лишних обращений к new и delete. Теперь из пяти операций со стеком осталось только три: push(), pop() и empty(). И даже empty() лишняя. Чем проще интерфейс, тем удобнее контролировать доступ к данным — можно захватывать мьютекс на все время выполнения операции. В листинге 3.5 приведена простая реализация в виде обертки вокруг класс std::stack<>.

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

#include <exception>

#include <memory>

#include <mutex>

#include <stack>

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; ←┐ (1) Копирование производится в теле

 }                    │ конструктора

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

 void push(T new_value) {

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

  data.push(new_value);

 }

 std::shared_ptr<T> pop()│ Перед тем как выталкивать значение,

 {                      ←┘ проверяем, не пуст ли стек

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

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

  std::shared_ptr<T> const res(std::make_shared<T>(data.top()));

  data.pop(); ←┐ Перед тем как модифицировать стек

  return res;  │ в функции pop(), выделяем память

 }             │ для возвращаемого значения

 void pop(T& value) {

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

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

  value = data.top();

  data.pop();

 }

 bool empty() const {

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

  return data.empty();

 }

};

Эта реализация стека даже допускает копирование — копирующий конструктор захватывает мьютекс в объекте-источнике, а только потом копирует внутренний стек. Копирование производится в теле конструктора (1), а не в списке инициализации членов, чтобы мьютекс гарантированно удерживался в течение всей операции.

Обсуждение функций top() и pop() показывает, что проблематичные гонки в интерфейсе возникают из-за слишком малой гранулярности блокировки — защита не распространяется на выполняемую операцию в целом. Но при использовании мьютексов проблемы могут возникать также из-за слишком большой гранулярности, крайним проявление этого является применение одного глобального мьютекса для защиты всех разделяемых данных. В системе, где разделяемых данных много, такой подход может свести на нет все преимущества параллелизма, постольку потоки вынуждены работать но очереди, даже если обращаются к разным элементам данных. В первых версиях ядра Linux для многопроцессорных систем использовалась единственная глобальная блокировка ядра. Это решение работало, но получалось, что производительность одной системы с двумя процессорами гораздо ниже, чем двух однопроцессорных систем, а уж сравнивать производительность четырёхпроцессорной системы с четырьмя однопроцессорными вообще не имело смысла — конкуренция за ядро оказывалась настолько высока, что потоки, исполняемые дополнительными процессорами, не могли выполнять полезную работу. В последующих версиях Linux гранулярность блокировок ядра уменьшилась, и в результате производительность четырёхпроцессорной системы приблизилась к идеалу — четырехкратной производительности однопроцессорной системы, так как конкуренция за ядро значительно снизилась.

При использовании мелкогранулярных схем блокирования иногда для защиты всех данных, участвующих в операции, приходится захватывать более одного мьютекса. Как отмечалось выше, бывают случаи, когда лучше повысить гранулярность защищаемых данных, чтобы для их защиты хватило одного мьютекса. Но это не всегда желательно, например, если мьютексы защищают отдельные экземпляры класса. В таком случае блокировка «на уровень выше» означает одно из двух: передать ответственность за блокировку пользователю или завести один мьютекс, который будет защищать все экземпляры класса. Ни одно из этих решений не вызывает восторга.

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