Вариант 4: реализовать одновременно вариант 1 и один из вариантов 2 или 3
Никогда не следует пренебрегать гибкостью, особенно в обобщенном коде. Если остановиться на варианте 2 или 3, то будет сравнительно нетрудно реализовать и вариант 1, а это оставит пользователю возможность выбрать наиболее подходящее решение ценой очень небольших накладных расходов.
Пример определения потокобезопасного стека
В листинге 3.4 приведено определение класса стека со свободным от гонок интерфейсом. В нем реализованы приведенные выше варианты 1 и 3: имеется два перегруженных варианта функции-члена pop()
— один принимает ссылку на переменную, в которой следует сохранить значение, а второй возвращает std::shared_ptr<>
. Интерфейс предельно прост, он содержит только функции: push()
и pop()
.
Листинг 3.4.Определение класса потокобезопасного стека
#include
struct empty_stack: std::exception {
const char* what() const throw();
};
template
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 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
#include
#include
#include
struct empty_stack: std::exception {
const char* what() const throw();
};
template
class threadsafe_stack {
private:
std::stack data;
mutable std::mutex m;
public:
threadsafe_stack(){}
threadsafe_stack(const threadsafe_stack& other) {
std::lock_guard lock(other.m);
data = other.data; ←┐
(1) Копирование производится в теле
} │
конструктора
threadsafe_stack& operator=(const threadsafe_stack&) = delete;
void push(T new_value) {
std::lock_guard lock(m);
data.push(new_value);
}
std::shared_ptr pop()│
Перед тем как выталкивать значение,
{ ←┘
проверяем, не пуст ли стек
std::lock_guard lock(m);
if (data.empty()) throw empty_stack();
std::shared_ptr const res(std::make_shared(data.top()));
data.pop(); ←┐
Перед тем как модифицировать стек
return res; │
в функции pop(), выделяем память
} │
для возвращаемого значения
void pop(T& value) {
std::lock_guard lock(m);
if (data.empty()) throw empty_stack();
value = data.top();
data.pop();
}
bool empty() const {
std::lock_guard lock(m);
return data.empty();
}
};
Эта реализация стека даже допускает копирование — копирующий конструктор захватывает мьютекс в объекте-источнике, а только потом копирует внутренний стек. Копирование производится в теле конструктора (1), а не в списке инициализации членов, чтобы мьютекс гарантированно удерживался в течение всей операции.
Обсуждение функций top()
и pop()
показывает, что проблематичные гонки в интерфейсе возникают из-за слишком малой гранулярности блокировки — защита не распространяется на выполняемую операцию в целом. Но при использовании мьютексов проблемы могут возникать также из-за слишком большой гранулярности, крайним проявление этого является применение одного глобального мьютекса для защиты всех разделяемых данных. В системе, где разделяемых данных много, такой подход может свести на нет все преимущества параллелизма, постольку потоки вынуждены работать но очереди, даже если обращаются к разным элементам данных. В первых версиях ядра Linux для многопроцессорных систем использовалась единственная глобальная блокировка ядра. Это решение работало, но получалось, что производительность одной системы с двумя процессорами гораздо ниже, чем двух однопроцессорных систем, а уж сравнивать производительность четырёхпроцессорной системы с четырьмя однопроцессорными вообще не имело смысла — конкуренция за ядро оказывалась настолько высока, что потоки, исполняемые дополнительными процессорами, не могли выполнять полезную работу. В последующих версиях Linux гранулярность блокировок ядра уменьшилась, и в результате производительность четырёхпроцессорной системы приблизилась к идеалу — четырехкратной производительности однопроцессорной системы, так как конкуренция за ядро значительно снизилась.
Читать дальше