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

Комитет по стандартизации С++ счел этот случай достаточно важным, поэтому в стандартную библиотеку включен класс std::once_flag и шаблон функции std::call_once. Вместо того чтобы захватывать мьютекс и явно проверять указатель, каждый поток может просто вызвать функцию std::call_once, твердо зная, что к моменту возврата из нее указатель уже инициализирован каким-то потоком (без нарушения синхронизации). Обычно издержки, сопряженные с использованием std::call_once, ниже, чем при явном применении мьютекса, поэтому такое решение следует предпочесть во всех случаях, когда оно не противоречит требованиям задачи. В примере ниже код из листинга 3.11 переписан с использованием std::call_once. В данном случае инициализация производится путем вызова функции, но ничто не мешает завести для той же цели класс, в котором определен оператор вызова. Как и большинство функций в стандартной библиотеке, принимающих в качестве аргументов функции или предикаты, std::call_once работает как с функциями, так и с объектами, допускающими вызов.

std::shared_ptr<some_resource> resource_ptr;

std::once_flag resource_flag;← (1)

void init_resource() {

 resource_ptr.reset(new some_resource);

}

              │ Инициализация производится

void foo() { ←┘ ровно один раз

 std::call_once(resource_flag, init_resource);

 resource_ptr->do_something();

}

Здесь переменная типа std::once_flag (1) и инициализируемый объект определены в области видимости пространства имен, но std::call_once() вполне можно использовать и для отложенной инициализации членов класса, как показано в следующем листинге.

Листинг 3.12. Потокобезопасная отложенная инициализация члена класса с помощью функции std::call_once()

class X {

private:

 connection_infо connection_details;

 connection_handle connection;

 std::once_flag connection_init_flag;

 void open_connection() {

  connection = connection_manager.open(connection_details);

 }

public:

 X(connection_info const& connection_details_):

  connection_details(connection_details_) {}

 void send_data(data_packet const& data)← (1)

 {

  std::call_once(

   connection_init_flag, &X::open_connection, this);←┐

  connection.send_data(data);                        │

 }                                                   │

 data_packet receive_data() { ← (3)

  std::call_once(                                    │

   connection_init_flag, &X::open_connection, 2)     (2)

   this);                                           ←┘

  return connection.receive_data();

 }

};

В этом примере инициализация производится либо при первом обращении к send_data() (1), либо при первом обращении к receive_data() (3). Поскольку данные инициализируются функцией-членом open_connection(), то требуется передавать также указатель this. Как и во всех функциях из стандартной библиотеки, которые принимают объекты, допускающие вызов, (например, конструктор std::thread и функция std::bind()), это делается путем передачи std::call_once() дополнительного аргумента (2).

Следует отметить, что, как и в случае std:mutex, объекты типа std::once_flag нельзя ни копировать, ни перемещать, поэтому, если вы собираетесь использовать их как члены классы, то соответствующие конструкторы придется определить явно (если это необходимо).

Возможность гонки при инициализации возникает, в частности, при объявлении локальной переменной с классом памяти static. По определению, инициализация такой переменной происходит, когда поток управления программы первый раз проходит через ее объявление. Но если функция вызывается в нескольких потоках, то появляется потенциальная возможность гонки за то, кто определит переменную первым. Во многих компиляторах, выпущенных до утверждения стандарта С++11, эта гонка действительно приводит к проблемам, потому что любой из нескольких потоков, полагая, что успел первым, может попытаться инициализировать переменную. Может также случиться, что некоторый поток попытается использовать переменную после того, как инициализация началась в другом потоке, но до того, как она закончилась. В С++11 эта проблема решена: по определению, инициализация производится ровно в одном потоке, и никакому другому потоку не разрешено продолжать выполнение, пока инициализация не завершится, поэтому потоки конкурируют лишь за право выполнить инициализацию первым, ничего более серьёзного случиться не может. Это свойство можно использовать как альтернативу функции std::call_once, когда речь идет об инициализации единственной глобальной переменной:

class my_class;

 my_class& get_my_class_instance() {

 static my_class instance; ←┐ Гарантируется, что инициализация

 return instance;           (1) потокобезопасна

}

Теперь несколько потоков могут вызывать функцию get_my_class_instance() (1), не опасаясь гонки при инициализации.

Защита данных только на время инициализации — частный случай более общего сценария: доступ к редко обновляемой структуре данных. Обычно к такой структуре обращаются для чтения, когда ни о какой синхронизации можно не беспокоиться. Но иногда требуется обновить данные в ней. Нам необходим такой механизм защиты, который учитывал бы эти особенности.