Функция std::lock()
и шаблон класса std::lock_guard
покрывают большую часть простых случаев блокировки, по иногда этого недостаточно. Поэтому в стандартную библиотеку включен также шаблон std::unique_lock
. Подобно std::lock_guard
, этот шаблон класса параметризован типом мьютекса и реализует такое же управление блокировками в духе RAII, что и std::lock_guard
, однако обладает чуть большей гибкостью.
3.2.6. Гибкая блокировка с помощью std::unique_lock
Шаблон std::unique_lock
обладает большей гибкостью, чем std::lock_guard
, потому что несколько ослабляет инвариантыэкземпляр std::unique_lock
не обязан владеть ассоциированным с ним мьютексом. Прежде всего, в качестве второго аргумента конструктору можно передавать не только объект std::adopt_lock
, заставляющий объект управлять захватом мьютекса, но и объект std::defer_lock
, означающий, что в момент конструирования мьютекс не должен захватываться. Захватить его можно будет позже, вызвав функцию-член lock()
объекта std::unique_lock
(а не самого мьютекса) или передав функции std::lock()
сам объект std::unique_lock
. Код в листинге 3.6 можно было бы с тем же успехом написать, как показало в листинге 3.9, с применением std::unique_lock
и std::defer_lock()
(1) вместо std::lock_guard
и std::adopt_lock
. В новом варианте столько же строк, и он эквивалентен исходному во всем, кроме одной детали,std::unique_lock
потребляет больше памяти и выполняется чуть дольше, чем std::lock_guard
. Та гибкость, которую мы получаем, разрешая экземпляру std::unique_lock
не владеть мьютексом, обходится не бесплатно дополнительную информацию надо где-то хранить и обновлять.
Листинг 3.9. Применение std::lock()
и std::unique_guard
для реализации операции обмена
class some_big_object;
void swap(some_big_object& lhs,some_big_object& rhs);
class X {
private:
some_big_object some_detail;
std::mutex m;
public:
X(some_big_object const& sd): some_detail(sd) {}
friend void swap(X& lhs, X& rhs) {
if (&lhs == &rhs)
std::defer_lock оставляет
return;
мьютексы не захваченными (1)
std::unique_lock<std::mutex> lock_a(lhs.m, std::defer_lock);
std::unique_lock<std::mutex> lock_b(rhs.m, std::defer_lock);
std::lock(lock_a, lock_b);
(2) Мьютексы захватываются
swap(lhs.some_detail, rhs.some_detail);
}
};
В листинге 3.9 объекты std::unique_lock
можно передавать функции std::lock()
(2), потому что в классе std::unique_lock
имеются функции-члены lock()
, try_lock()
и unlock()
. Для выполнения реальной работы они вызывают одноименные функции контролируемого мьютекса, а сами только поднимают в экземпляре std::unique_lock
флаг, показывающий, что в данный момент этот экземпляр владеет мьютексом. Флаг необходим для того, чтобы деструктор знал, вызывать ли функцию unlock()
. Если экземпляр действительно владеет мьютексом, то деструктор должен вызвать unlock()
, в противном случаене должен. Опросить состояние флага позволяет функция-член owns_lock()
.
Естественно, этот флаг необходимо где-то хранить. Поэтому размер объекта std::unique_lock
обычно больше, чем объекта std::lock_guard
, и работает std::unique_lock
чуть медленнее std::lock_guard
, потому что флаг нужно проверять и обновлять. Если класс std::lock_guard
отвечает вашим нуждам, то я рекомендую использовать его. Тем не менее, существуют ситуации, когда std::unique_lock
лучше отвечает поставленной задаче, так как без свойственной ему дополнительной гибкости не обойтись. Один из примеровпоказанный выше отложенный захват; другойнеобходимость передавать владение мьютексом из одного контекста в другой.
3.2.7. Передача владения мьютексом между контекстами
Поскольку экземпляры std::unique_lock
не владеют ассоциированными мьютексами, то можно передавать владение от одного объекта другому путем перемещения. В некоторых случаях передача производится автоматически, например при возврате объекта из функции, а иногда это приходится делать явно, вызывая std::move()
. Ситуация зависит от того, является ли источник l-значением именованной переменной или ссылкой на нееили r-значениемвременным объектом. Если источникr-значение, то передача владения происходит автоматически, в случае же l-значение это нужно делать явно, чтобы не получилось так, что переменная потеряет владение непреднамеренно. Класс std::unique_lock
дает пример перемещаемого, но не копируемого типа. Дополнительные сведения о семантике перемещения см. в разделе А.1.1 приложения А.
Одно из возможных примененийразрешить функции захватить мьютекс, а потом передать владение им вызывающей функции, чтобы та могла выполнить дополнительные действия под защитой того же мьютекса. Ниже приведен соответствующий примерфункция get_lock()
захватывает мьютекс, подготавливает некоторые данные, а потом возвращает мьютекс вызывающей программе:
std::unique_lock<std::mutex> get_lock() {
extern std::mutex some_mutex;
std::unique_lock<std::mutex> lk(some_mutex);
prepare_data();
return lk;
(1)
}
void process_data() {
std::unique_lock<std::mutex> lk(get_lock());
(2)
do_something();
}
Поскольку lk
автоматическая переменная, объявленная внутри функции, то ее можно возвращать непосредственно (1), не вызывая std:move()
; компилятор сам позаботится о вызове перемещающего конструктора. Затем функция process_data()
может передать владение своему экземпляру std::unique_lock
(2), и do_something()
может быть уверена, что подготовленные данные не были изменены каким-то другим потоком.
Обычно подобная схема применяется, когда подлежащий захвату мьютекс зависит от текущего состояния программы или от аргумента, переданного функции, которая возвращает объект std::unique_lock
. Например, так имеет смысл делать, когда блокировка возвращается не напрямую, а является членом какого-то класса-привратника, обеспечивающего корректный доступ к разделяемым данным под защитой мьютекса. В таком случае любой доступ к данным производится через привратник, то есть предварительно необходимо получить его экземпляр (вызвав функцию, подобную get_lock()
в примере выше), который захватит мьютекс. Затем для доступа к данным вызываются функции-члены объекта-привратника. По завершении операции привратник уничтожается, при этом мьютекс освобождается, открывая другим потокам доступ к защищенным данным. Такой объект-привратник вполне может быть перемещаемым (чтобы его можно было возвращать из функции), и тогда тот его член, в котором хранится блокировка, также должен быть перемещаемым.
Класс std::unique_lock
также позволяет экземпляру освобождать блокировку без уничтожения. Для этого служит функция-член unlock()
, как и в мьютексе; std::unique_lock
поддерживает тот же базовый набор функций-членов для захвата и освобождения, что и мьютекс, чтобы его можно было использовать в таких обобщенных функциях, как std::lock
. Наличие возможности освобождать блокировку до уничтожения объекта std::unique_lock
означает, что освобождение можно произвести досрочно в какой-то ветке кода, если ясно, что блокировка больше не понадобится. Иногда это позволяет повысить производительность приложения, ведь, удерживая блокировку дольше необходимого, вы заставляете другие потоки впустую ждать, когда они могли бы работать.
3.2.8. Выбор правильной гранулярности блокировки
О гранулярности блокировок я уже упоминал в разделе 3.2.3: под этим понимается объем данных, защищаемых блокировкой. Мелкогранулярные блокировки защищают мало данных, крупногранулярныемного. Важно не только выбрать подходящую гранулярность, но и позаботиться о том, чтобы блокировка удерживалась не дольше, чем реально необходимо. Все мы сталкивались с ситуацией, когда очередь к кассе в супермаркете перестает двигаться из-за того, что обслуживаемый покупатель вдруг выясняет, что забыл прихватить баночку соуса, и отправляется за ней, заставляя всех ждать, или из-за того, что кассирша уже готова принять деньги, а покупатель только только полез за кошельком. Насколько было бы проще, если бы каждый подходил к кассе только после того, как купил все необходимое и подготовился оплатить покупки.
Вот так и с потоками: если несколько потоков ждут одного ресурса (кассира), то, удерживая блокировку дольше необходимого, они заставляют другие потоки проводить в очереди больше времени (не начинайте искать баночку соуса, когда уже подошли к кассе). По возможности захватывайте мьютекс непосредственно перед доступом к разделяемым данным; старайтесь производить обработку данных, не находясь под защитой мьютекса. В частности, не начинайте длительных операций, например файловый ввод/вывод, когда удерживаете мьютекс. Ввод/вывод обычно выполняется в сотни (а то и в тысячи) раз медленнее чтения или записи того же объема данных в памяти. Поэтому если блокировка не нужна для защиты доступа к файлу, то удерживание блокировки заставляет другие потоки ждать без необходимости (так как они не могут захватить мьютекс), и тем самым вы можете свести на нет весь выигрыш от многопоточной работы.
Объект std::unique_lock
отлично приспособлен для таких ситуаций, потому что можно вызвать его метод unlock()
, когда программе не нужен доступ к разделяемым данным, а затем вызвать lock()
, если доступ снова понадобится:
void get_and_process_data()
(1) Во время работы process() зах-
{
ватывать мьютекс не нужно
std::unique_lock<std::mutex> my_lock(the_mutex);
some_class data_to_process = get_next_data_chunk();
my_lock.unlock();
result_type result = process(data_to_process);
my_lock.lock();
Снова захватить мью-
write_result(data_to_process, result);
текс перед записью
}
(2) результатов
Удерживать мьютекс на время выполнения process()
нет необходимости, поэтому мы вручную освобождаем его перед вызовом (1) и снова захватываем после возврата (2).
Очевидно, что если один мьютекс защищает структуру данных целиком, то не только возрастает конкуренция за него, но и шансов снизить время удержания остается меньше. Поскольку под защитой одного мьютекса приходится выполнять больше операций, то и удерживать его нужно дольше. Такая двойная угроза должна вдвое усилить стремление всюду, где возможно, использовать мелкогранулярные блокировки.
Как следует из примера, выбор подходящей гранулярности определяется не только объемом защищаемых данных, но и временем удержания блокировки и тем, какие операции выполняются под ее защитой. В общем случае блокировку следует удерживать ровно столько времени, сколько необходимо для завершения требуемых операций. Это также означает, что длительные операции, например захват другой блокировки (даже если известно, что это не приведет к взаимоблокировке) или ожидание завершения ввода/вывода, не следует выполнять под защитой блокировки, если только это не является абсолют пой необходимостью.
В листингах 3.6 и 3.9 мы захватывали два мьютекса для операции обмела, которая очевидно требует одновременного доступа к обоим объектам. Предположим, однако, что требуется произвести сравнение простых членов данных типа int
. В чем разница? Копирование целых чиселдешевая операция, поэтому вполне можно было бы скопировать данные из каждого объекта под защитой мьютекса, а затем сравнить копии. Тогда мьютекс удерживался бы минимальное время, и к тому же не пришлось бы захватывать новый мьютекс, когда один уже удерживается. В следующем листинге показам как раз такой класс Y
и пример реализации в нем оператора сравнения на равенство.
Листинг 3.10. Поочерёдный захват мьютексов в операторе сравнения
class Y {
private:
int some_detail;
mutable std::mutex m;
int get_detail() const {
std::lock_guard<std::mutex> lock_a(m);
(1)
return some_detail;
}
public:
Y(int sd): some_detail(sd) {}
friend bool operator==(Y const& lhs, Y const& rhs) {
if (&lhs == &rhs)
return true;
int const lhs_value = lhs.get_detail();
(2)
int const rhs_value = rhs.get_detail();
(3)
return lhs_value == rhs_value;
(4)
}
};
В данном случае оператор сравнения сначала получает сравниваемые значения, вызывая функцию-член get_detail()
(2), (3). Эта функция извлекает значение, находясь под защитой мьютекса (1). После этого оператор сравнивает полученные значения (4). Отметим, однако, что наряду с уменьшением времени удержания блокировки за счет того, что в каждый момент захвачен только один мьютекс (и, стало быть, исключена возможность взаимоблокировки), мы немного изменили семантику операции по сравнению с реализацией, в которой оба мьютекса захватываются вместе. Если оператор в листинге 3.10 возвращает true
, то это означает лишь, что значение lhs.some_detail
в один момент времени равно значению rhs.some_detail
в другой момент времени. Между двумя операциями считывания значения могли измениться как угодно; например, между точками (2) и (3) программа могла обменять их местами, и тогда сравнение оказалось бы вообще бессмысленным. Таким образом, возврат оператором сравнения значения true
, означает, что значения были равны, пусть даже ни в какой момент времени фактическое равенство не наблюдалось. Очень важно следить, чтобы такие изменения семантики операций не приводили к проблемам: если блокировка не удерживается на протяжении всей операции, то возникает риск гонки.
Иногда подходящего уровня гранулярности просто не существует, потому что не все операции доступа к структуре данных требуют одного и того же уровня защиты. В таком случае вместо простого класса std::mutex
стоит поискать альтернативный механизм.
3.3. Другие средства защиты разделяемых данных
Хотя мьютексы и представляют собой наиболее общий механизм, но они не единственные игроки на поле защиты разделяемых данных. Есть и другие механизмы, обеспечивающие защиту в специальных случаях.
Один такой крайний (но на удивление распространенный) случай возникает, когда разделяемые данные нуждаются в защите от одновременного доступа только на этапе инициализации, а потом уже никакой синхронизации не требуется. Так может быть, например, потому что после инициализации данные только читаются или потому что необходимая защита обеспечивается неявно как часть операций над данными. Как бы то ни было, захватывать мьютекс после того, как данные инициализированы, совершенно не нужно, это только снизило бы производительность. Поэтому в стандарте С++ предусмотрен механизм, служащий исключительно для защиты разделяемых данных во время инициализации.
3.3.1. Защита разделяемых данных во время инициализации
Предположим, имеется разделяемый ресурс, конструирование которого обходится настолько дорого, что мы хотим делать это, лишь когда действительно возникает необходимость; быть может, конструктор открывает базу данных или выделяет очень много памяти. Такая отложенная инициализация часто встречает в однопоточных программахвсякая операция, нуждающаяся в ресурсе, сначала проверяет, инициализирован ли он, и, если нет, выполняет инициализацию:
std::shared_ptr<some_resource> resource_ptr;
void foo() {
if (!resource_ptr) {
resource_ptr.reset(new some_resource);
(1)
}
resource_ptr->do_something();
}
Если сам разделяемый ресурс безопасен относительно одновременного доступа, то при переходе к многопоточной реализации единственная нуждающаяся в защите частьинициализация (1), однако наивный подход, показанный в листинге ниже, может привести к ненужной сериализации использующих ресурс потоков. Дело в том, что каждый поток должен ждать освобождения мьютекса, чтобы проверить, был ли ресурс уже инициализирован.