Иногда нам не нужны издержки, которыми сопровождается перенос зависимости. Мы хотим, чтобы компилятор мог кэшировать значения в регистрах и изменять порядок операций во имя оптимизации кода, а не волновался по поводу зависимостей. В таких случаях можно воспользоваться шаблоном функции std::kill_dependency() для явного разрыва цепочки зависимостей. Эта функция просто копирует переданный ей аргумент в возвращаемое значение, но попутно разрывает цепочку зависимостей. Например, если имеется глобальный массив с доступом только для чтения, и вы используете семантику std::memory_order_consume при чтении какого-то элемента этого массива из другого потока, то с помощью std::kill_dependency() можно сообщить компилятору, что ему необязательно заново считывать содержимое элемента массива (см. пример ниже).
int global_data[] = { ... };
std::atomic
void f() {
int i = index.load(std::memory_order_consume);
do_something_with(global_data[std::kill_dependency(i)]);
}
Разумеется, в таком простом случае вы вряд ли вообще будете пользоваться семантикой std::memory_order_consume, но в аналогичной ситуации функцией std::kill_dependency() можно воспользоваться и в более сложной программе. Только не забывайте, что это оптимизация, поэтому прибегать к ней следует с осторожностью и только тогда, когда профилирование ясно продемонстрировало необходимость.
Теперь, рассмотрев основы упорядочения доступа к памяти, мы можем перейти к более сложным аспектам отношения синхронизируется-с, которые проявляются в форме
5.3.4. Последовательности освобождений и отношение синхронизируется-с
В разделе 5.3.1 я упоминал, что можно получить отношение синхронизируется-с между операцией сохранения атомарной переменной и операцией загрузки той же атомарной переменной в другом потоке, даже если между ними выполняется последовательность операций чтения-модификации-записи, — при условии, что все операции помечены надлежащим признаками. Теперь, когда мы знаем обо всех возможных «признаках» упорядочения, я могу подробнее осветить этот вопрос. Если операция сохранения помечена одним из признаков memory_order_release, memory_order_acq_rel или memory_order_seq_cst, а операция загрузки — одним из признаков memory_order_consume, memory_order_acquire или memory_order_seq_cst, и каждая операция в цепочке загружает значение, записанное предыдущей операцией, то такая цепочка операций составляет memory_order_acquire или memory_order_seq_cst) или предшествует-по-зависимости (в случае memory_order_consume) последней операции загрузки. Любая атомарная операция чтения-модификации-записи в цепочке может быть помечена memory_order_relaxed).
Чтобы попять, что это означает и почему так важно, рассмотрим значение типа atomic, которое используется как счетчик count элементов в разделяемой очереди (см. листинг ниже).
Листинг 5.11. Чтение из очереди с применением атомарных операций
#include
#include
std::vector
void populate_queue() {
unsigned const number_of_items = 20;
queue_data.clear();
for (unsigned i = 0; i < number_of_items; ++i) {
queue_data.push_back(i);
} ←(1) Начальное сохранение
count.store(number_of_items, std::memory_order_release);
}
void consume_queue_items() {
while (true) { ←(2) Операция ЧМЗ
int item_index;
if (
(item_index =
count.fetch_sub(1, std::memory_order_acquire)) <= 0) {
wait_for_more_items();←┐Ждем дополнительных
continue; (3) элементов
}
process(queue_data[item_index-1]);←┐Чтение из queue_data
} (4) безопасно
int main() {
std::thread a(populate_queue);
std::thread b(consume_queue_items);