Такой подход в однопоточном коде не только безопасен, но и единственно возможен: вызов 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()
никто не может модифицировать стек, так что оба потока увидят одно и то же значение. Однако беда в том, что empty()
и top()
, — на первый взгляд, ничего страшного не произошло, а последствия ошибки проявятся, скорее всего, далеко от места возникновения, хотя, конечно, всё зависит от того, что именно делает функция do_something()
.
Для решения проблемы необходимо более радикальное изменение интерфейса — выполнение обеих операций top()
и pop()
под защитой одного мьютекса. Том Каргилл[4] указал, что такой объединенный вызов приводит к проблемам в случае, когда копирующий конструктор объектов в стеке может возбуждать исключения. С точки зрения безопасности относительно исключений, задачу достаточно полно решил Герб Саттер[5], однако возможность возникновения гонки вносит в нее новый аспект.