3.2.3. Выявление состояний гонки, внутренне присущих интерфейсам
Тот факт, что вы пользуетесь мьютексами или другим механизмом для защиты разделяемых данных, еще не означает, что гонок можно не опасаться, — следить за тем, чтобы данные были защищены, все равно нужно. Вернемся снова к примеру двусвязного списка. Чтобы поток мог безопасно удалить узел, необходимо предотвратить одновременный доступ к трем узлам: удаляемому и двум узлам но обе стороны от него. Заблокировав одновременный доступ к указателям на каждый узел но отдельности, мы не достигнем ничего по сравнению с вариантом, где мьютексы вообще не используются, поскольку гонка по-прежнему возможна. Защищать нужно не отдельные узлы на каждом шаге, а структуру данных в целом на все время выполнения операции удаления. Простейшее решение в данном случае — завести один мьютекс, который будет защищать весь список, как в листинге 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()
вообще оказывается ненужным.