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), однако наивный подход, показанный в листинге ниже, может привести к ненужной сериализации использующих ресурс потоков. Дело в том, что каждый поток должен ждать освобождения мьютекса, чтобы проверить, был ли ресурс уже инициализирован.
Листинг 3.11. Потокобезопасная отложенная инициализация с помощью мьютекса
std::shared_ptr<some_resource> resource_ptr;
std::mutex resource_mutex; ←┐
В этой точке все потоки
│
сериализуются
void foo() {
std::unique_lock<std::mutex> lk(resource_mutex);
if (!resource_ptr) {
resource_ptr.reset(new some_resource); ←┐
в защите нуж-
} │
дается только
lk.unlock(); │
инициализация
resource_ptr->do_something();
}
Этот код встречается настолько часто, а ненужная сериализация вызывает столько проблем, что многие предпринимали попытки найти более приемлемое решение, в том числе печально известный паттерн блокировка с двойной проверкой (Double-Checked Locking): сначала указатель читается без захвата мьютекса (1) (см. код ниже), а захват производится, только если оказалось, что указатель равен NULL
. Затем, когда мьютекс захвачен (2), указатель проверяется еще раз (отсюда и слова «двойная проверка») на случай, если какой-то другой поток уже выполнил инициализацию в промежутке между первой проверкой и захватом мьютекса:
void undefined_behaviour_with_double_checked_locking() {
if (!resource_ptr) ←
(1)
{
std::lock_guard<std::mutex> lk(resource_mutex);
if (!resource_ptr) ←
(2)
{
resource_ptr.reset(new some_resource);←
(3)
}
}
resource_ptr->do_something(); ←
(4)
}
«Печально известным» я назвал этот паттерн не без причины: он открывает возможность для крайне неприятного состояния гонки, потому что чтение без мьютекса (1) не синхронизировано с записью в другом потоке с уже захваченным мьютексом (3). Таким образом, возникает гонка, угрожающая не самому указателю, а объекту, на который он указывает; даже если один поток видит, что указатель инициализирован другим потоком, он может не увидеть вновь созданного объекта some_resource
, и, следовательно, вызов do_something()
(4) будет применен не к тому объекту, что нужно. Такого рода гонка в стандарте С++ называется гонкой за данными (data race), она отнесена к категории неопределенного поведения.