Параллельное программирование на С++ в действии. Практика разработки многопоточных программ - Энтони Уильямс 12 стр.


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

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

3.3.2. Защита редко обновляемых структур данных

Рассмотрим таблицу, в которой хранится кэш записей DNS, необходимых для установления соответствия между доменными именами и IP-адресами. Как правило, записи DNS остаются неизменными в течение длительного временизачастую многих лет. Новые записи, конечно, добавляютсяскажем, когда открывается новый сайтно на протяжении всей своей жизни обычно не меняются. Периодически необходимо проверять достоверность данных в кэше, но и тогда обновление требуется, лишь если данные действительно изменились.

Но хотя обновления происходят редко, они все же случаются, и если к кэшу возможен доступ со стороны нескольких потоков, то необходимо обеспечить надлежащую защиту, чтобы ни один поток, читающий кэш, не увидел наполовину обновленной структуры данных. Если структура данных не специализирована для такого способа использования (как описано в главах 6 и 7), то поток, который хочет обновить данные, должен получить монопольный доступ к структуре на все время выполнения операции. После того как операция обновления завершится, структуру данных снова смогут одновременно читать несколько потоков.

Использование std::mutex для защиты такой структуры данных излишне пессимистично, потому что при этом исключается даже возможность одновременного чтения, когда никакая модификация не производится. Нам необходим какой-то другой вид мьютекса. Такой мьютекс есть, и обычно его называют мьютексом чтения-записи (reader-writer mutex), потому что он допускает два режима: монопольный доступ со стороны одного «потока-писателя» и параллельный доступ со стороны нескольких «потоков-читателей».

В новой стандартной библиотеке С++ такой мьютекс не предусмотрен, хотя комитету и было подано предложение. Поэтому в этом разделе мы будем пользоваться реализацией из библиотеки Boost, которая основана на отвергнутом предложении. В главе 8 вы увидите, что использование такого мьютексане панацея, а его производительность зависит от количества участвующих процессоров и относительного распределения нагрузки между читателями и писателями. Поэтому важно профилировать работу программу в целевой системе и убедиться, что добавочная сложность действительно дает какой-то выигрыш.

Итак, вместо std::mutex мы воспользуемся для синхронизации объектом boost::shared_mutex. При выполнении обновления мы будем использовать для захвата мьютекса шаблоны std::lock_guard<boost::shared_mutex> и std::unique_lock<boost::shared_mutex>, параметризованные классом boost::shared_mutex, а не std::mutex. Они точно так же гарантируют монопольный доступ. Те же потоки, которым не нужно обновлять структуру данных, могут воспользоваться классом boost::shared_lock<boost::shared_mutex> для получения разделяемого доступа. Применяется он так же, как std::unique_lock, но в семантике имеется одно важное отличие: несколько потоков могут одновременно получить разделяемую блокировку на один и тот же объект boost::shared_mutex. Однако если какой-то поток уже захватил разделяемую блокировку, то любой поток, который попытается захватить монопольную блокировку, будет приостановлен до тех пор, пока все прочие потоки не освободят свои блокировки. И наоборот, если какой-то поток владеет монопольной блокировкой, то никакой другой поток не сможет получить ни разделяемую, ни монопольную блокировку, пока первый поток не освободит свою.

В листинге ниже приведена реализация простого DNS-кэша, в котором данные хранятся в контейнере std::map, защищенном с помощью boost::shared_mutex.

Листинг 3.13. Защита структуры данных с помощью boost::shared_mutex

#include <map>

#include <string>

#include <mutex>

#include <boost/thread/shared_mutex.hpp>

class dns_entry;

class dns_cache {

 std::map<std::string, dns_entry> entries;

 mutable boost::shared_mutex entry_mutex;

public:

 dns_entry find_entry(std::string const& domain) const {

  boost::shared_lock<boost::shared_mutex> lk(entry_mutex); (1)

  std::map<std::string, dns_entry>::const_iterator const it =

   entries.find(domain);

  return (it == entries.end()) ? dns_entry() : it->second;

 }

 void update_or_add_entry(std::string const& domain,

  dns_entry const& dns_details) {

  std::lock_guard<boost::shared_mutex> lk(entry_mutex); (2)

  entries[domain] = dns_details;

 }

};

В листинге 3.13 в функции find_entry() используется объект boost::shared_lock<>, обеспечивающий разделяемый доступ к данным для чтения (1); следовательно, ее можно спокойно вызывать одновременно из нескольких потоков. С другой стороны, в функции update_or_add_entry() используется объект std::lock_guard<>, который обеспечивает монопольный доступ на время обновления таблицы (2), и, значит, блокируются не только другие потоки, пытающиеся одновременно выполнить update_or_add_entry(), но также потоки, вызывающие find_entry().

3.3.3. Рекурсивная блокировка

Попытка захватить std::mutex в потоке, который уже владеет им, является ошибкой и приводит к неопределенному поведению. Однако бывают случаи, когда потоку желательно повторно захватывать один и тот же мьютекс, не освобождая его предварительно. Для этого в стандартной библиотеке С++ предусмотрен класс std::recursive_mutex. Работает он аналогично std::mutex, но с одним отличием: один и тот же поток может многократно захватывать данный мьютекс. Но перед тем как этот мьютекс сможет захватить другой поток, его нужно освободить столько раз, сколько он был захвачен. Таким образом, если функция lock() вызывалась три раза, то и функцию unlock() нужно будет вызвать трижды. При правильном использовании std::lock_guard<std::recursive_mutex> и std::unique_lock<std::recursive_mutex> это гарантируется автоматически.

Как правило, программу, в которой возникает необходимость в рекурсивном мьютексе, лучше перепроектировать. Типичный пример использования рекурсивного мьютекса возникает, когда имеется класс, к которому могут обращаться несколько потоков, так что для защиты его данных необходим мьютекс. Каждая открытая функция-член захватывает мьютекс, что-то делает, а затем освобождает его. Но бывает, что одна открытая функция-член вызывает другую, и в таком случае вторая также попытается захватить мьютекс, что приведет к неопределенному поведению. Тогда, чтобы решить проблему по-быстрому, обычный мьютекс заменяют рекурсивным. Это позволит второй функции захватить мьютекс и продолжить работу.

Однако такое решение не рекомендуется, потому что является признаком небрежного и плохо продуманного проектирования. В частности, при работе под защитой мьютекса часто нарушаются инварианты класса, а это означает, что вторая функция-член должна правильно работать даже в условиях, когда некоторые инварианты не выполняются. Обычно лучше завести новую закрытую функцию-член, которая вызывается из обеих открытых и не захватывает мьютекс (то есть предполагает, что мьютекс уже захвачен). Затем следует тщательно продумать, при каких условиях эта новая функция может вызываться и в каком состоянии будут при этом находиться данные.

3.4. Резюме

В этой главе мы рассмотрели, к каким печальным последствиям могут приводить проблематичные гонки, когда возможно разделение данных между потоками, и как с помощью класса std::mutex и тщательного проектирования интерфейса этих неприятностей можно избежать. Мы видели, что мьютексыне панацея, поскольку им свойственны собственные проблемы в виде взаимоблокировки, хотя стандартная библиотека С++ содержит средство, позволяющее избежать ихкласс std::lock(). Затем мы обсудили другие способы избежать взаимоблокировок и кратко обсудили передачу владения блокировкой и вопросы, касающиеся выбора подходящего уровня гранулярности блокировки. Наконец, я рассказал об альтернативных механизмах защиты данных, применяемых в специальных случаях: std::call_once() и boost::shared_mutex.

Назад Дальше