Выбрать главу

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), она отнесена к категории неопределенного поведения.