Порядок памяти для многопоточного программирования на C ++

Вступление

Как многие знают, начиная с C ++ 11, std::atomic<T> был введен как часть стандартной библиотеки. Вероятно, наиболее очевидной частью функциональности является то, что каждый экземпляр типа std::atomic<T> может атомарно обрабатываться из разных потоков, не вызывая гонки за данными. Но также есть еще один аспект std::atomic<T>, который важно знать, чтобы избежать сложных ошибок или улучшить производительность ваших программ. Этот аспект связан с моделью памяти¹, особенно с упорядочением памяти².

В стандарте C ++ указано шесть порядков памяти: memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel и memory_order_seq_cst³. Вы можете указать этот порядок памяти с помощью атомарной операции, как показано ниже.

example) x.store(true, std::memory_order_relaxed);

Шесть моделей можно в основном разделить на три категории заказа, как показано ниже.

  • Расслабленный заказ (memory_order_relaxed).
  • Заказ на получение-выпуск (memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel)
  • Последовательно согласованный порядок (memory_order_seq_cst)

В этом посте я расскажу о трех категориях, используя простые диаграммы и конкретные примеры, и, надеюсь, это даст вам достаточно понимания, чтобы подробно изучить каждую опцию memory_order.

Удобный заказ

Сначала я объясню упрощенный порядок (std::memory_order_relaxed). Давайте посмотрим на пример программы ниже. Как видите, есть два потока (thread_1 и thread_2).

В thread_1 он сохраняет true в атомарном объекте x, а затем сохраняет значение true в атомарном объекте y.

В thread_2 он проверяет значение y в while цикле и повторяет, пока не будет прочитано true. Если после выхода из цикла значение x равно true, то он печатает "y == true also x == true”.

#include <atomic>
#include <thread>
#include <iostream>
std::atomic<bool> x,y;
void func_1() {
  x.store(true, std::memory_order_relaxed);
  y.store(true, std::memory_order_relaxed);
}
void func_2() {
  while(!y.load(std::memory_order_relaxed));
  if(x.load(std::memory_order_relaxed)) {
    std::cout << "y == true also x == true \n";  // This might not be executed.
  }
}
int main() {
 x = false;
 y = false;
 std::thread thread_1(func_1);
 std::thread thread_2(func_2);
 thread_1.join();
 thread_2.join();
}

Так каков результат выполнения программы? Поскольку x установлен на true до того, как y будет установлен на true в thread_1 (func_1), можно ожидать, что программа всегда печатает "y == true also x == true". Однако правда в том, что "y == true also x == true” не может быть напечатан (он может быть напечатан, но это не гарантируется стандартом).

Это почему? Конечно, современные компиляторы и процессоры могут изменять порядок операций доступа к памяти (например, x.store, x.load) для оптимизации.

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

Напротив, в многопоточной программе указанный порядок памяти определяет возможные переупорядочения компилятором и процессором.

memory_order_relaxed, который был указан в приведенном выше примере, является наименее ограниченным порядком. Он почти не обеспечивает синхронизации порядка операций доступа к памяти между разными потоками. Одно исключение состоит в том, что он дает гарантию того, что порядок изменений одного и того же объекта должен быть одинаковым для разных потоков.

Так что это значит? Позвольте мне попытаться объяснить это с помощью концептуальных схем.

Горизонтальная ось представляет собой ячейку памяти. Вы можете видеть, что объекты x и y из примера программы расположены в двух разных ячейках памяти и имеют два состояния (True, False), которые меняются с течением времени.

Зеленые линии представляют собой «снимки памяти» с точки зрения конкретного потока в определенное время. Эта «точка зрения» является ключевой. Это «наблюдаемые» снимки памяти из определенного потока в определенное время. Итак, для потока значения в памяти в определенный момент времени выглядят так, как если бы вы разрезали зеленую линию. По идее, есть бесконечное количество снимков в направлении зеленой стрелки.

Линии моментального снимка не будут пересекаться друг с другом из-за вышеупомянутого требования синхронизации «порядок изменений одного и того же объекта должен быть одинаковым для разных потоков». Таким образом, все потоки по-прежнему соблюдают один и тот же порядок событий в каждой ячейке памяти.

Вот что интересно. Как видите, thread_1 (вверху) и thread_2 (внизу) имеют свои собственные линии снимков, которые по-своему разрезают пространство ячеек памяти (под разными углами).

Чтобы объяснить удивительный результат примера программы, нам нужно увидеть переход строк A в B.

С точки зрения thread_1 это выглядит так (см. Значение x, y в точках пересечения зеленых линий)

  1. Во-первых, для x установлено значение true.
  2. Затем y устанавливается на true.

С точки зрения thread_2 это выглядит так

  1. Во-первых, для y установлено значение true.
  2. Их x установлен на true.

Следовательно, с точки зрения thread_2, может быть случай, когда y равно true, а x равно false. Вот почему программа-пример может не распечатать ожидаемый результат.

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

Заказ приобретения-выпуска

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

Давайте посмотрим на пример ниже: thread_1 сохраняет, thread_2 загружает y с _67 _, _ 68_ параметрами соответственно. Чтобы упорядочение получения и выпуска работало, параметры _69 _, _ 70_ всегда должны использоваться как пары на одном и том же атомарном объекте.

Порядок получения-выпуска гарантирует, что все операции с памятью, которые происходят до операции сохранения (в данном случае y.store(true, std::memory_order_release)) в одном потоке, будут видны другому потоку, выполняющему соответствующую операцию загрузки (аналогично y.load(std::memory_order_acquire)).

#include <atomic>
#include <thread>
#include <iostream>
std::atomic<bool> x,y;
void func_1() {
  x.store(true, std::memory_order_relaxed);
  y.store(true, std::memory_order_release);
}
void func_2() {
  while(!y.load(std::memory_order_acquire));
  if(x.load(std::memory_order_relaxed)) {
    std::cout << "y == true also x == true \n";  // This is guaranteed to be executed.
  }
}
int main() {
 x = false;
 y = false;
 std::thread thread_1(func_1);
 std::thread thread_2(func_2);
 thread_1.join();
 thread_2.join();
}

Итак, на этот раз диаграмма снимка памяти выглядит так, как показано ниже. По сути, дополнительная синхронизация выпуска и получения гарантирует, что строки снимка памяти для thread_1 и thread_2 выглядят одинаково (для простоты он нарисован так же, как и на диаграмме ниже). Почему? Потому что при thread_2 загрузке y все операции с памятью, выполненные до thread_1 сохраненного y, должны быть видны thread_2. Чтобы гарантировать это, строки снимка памяти thread_2 (то, как он срезает пространство памяти вместе со временем) во время перехода от A к B должны выглядеть так же, как строки из thread_1. Если вы возьмете порядок обновлений x, y в качестве примера, во время перехода от A к B из обоих потоков это будет выглядеть так:

  1. Во-первых, x устанавливается на true.
  2. Затем y устанавливается на true

Таким образом, с порядком выпуска-получения гарантированно, что программа-пример напечатает "y == true also x == true”.

Итак, как вы видели, модель упорядочивания выпуска-приобретения выглядит более интуитивно понятной, чем упрощенная модель упорядочивания. Однако бывают случаи, когда это может дать удивительный результат. Давайте посмотрим на следующий пример.

#include <atomic>
#include <thread>
#include <assert.h>
#include <iostream>
std::atomic<bool> x,y;
void func_1() {
  x.store(true, std::memory_order_release);
}
void func_2() {
  y.store(true, std::memory_order_release);
}
void func_3() {
  while(!x.load(std::memory_order_acquire));
  if(y.load(std::memory_order_acquire)) {
    std::cout << "x == true then also y == true \n"; 
  }
}
void func_4() {
  while(!y.load(std::memory_order_acquire));
  if(x.load(std::memory_order_acquire)) {
    std::cout << "y == true then also x == true \n";
  }
}
// It is possible that neither func_3 or func_4 executes print out.
int main() {
 x = false;
 y = false;
 std::thread thread_1(func_1);
 std::thread thread_2(func_2);
 std::thread thread_3(func_3);
 std::thread thread_4(func_4);
 thread_1.join();
 thread_2.join();
 thread_3.join();
 thread_4.join();
}

На данный момент есть четыре потока. Два отдельных потока (thread_1, thread_2) хранят x, y отдельно. thread_3 и thread_4 загружают x, y в разных заказах, затем выводит сообщение, если оба x, y равны true.

Операции сохранения / загрузки в каждом потоке правильно связаны с соответствующей операцией сохранения / загрузки с std::memory_order_release и std::memory_order_acquire для x, y. Пока все выглядит хорошо.

Если мы наивно угадываем возможные результаты выполнения, то программа либо печатает, либо

  1. x== true then also y == trueor,
  2. y == true then also x == trueor,
  3. x== true then also y == trueи y == true then also x == true

Однако опять же, что удивительно, программа может НЕ печатать ничего.

Вернемся к схемам снимков памяти. Как видите, thread_3 и thread_4 имеют разные строки снимков памяти. И используя аналогичные объяснения из предыдущего, сосредоточив внимание на переходе от A к B в обоих потоках,

Из thread_3, похоже,

  1. Во-первых, x устанавливается в true.
  2. Затем y устанавливается на true.

Из thread_4 похоже,

  1. Во-первых, y устанавливается на true.
  2. Затем x устанавливается на true.

Следовательно, программа-пример может не распечатать ожидаемый результат. Итак, о чем я? По сути, порядок выпуска-получения обеспечивает синхронизацию потоков с соответствующими парами хранилище-загрузка, однако он не заставляет потоки соглашаться с порядком, если они не связаны с операциями загрузки-хранилища (так, thread_3 и thread_4).

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

Стандарт C ++ имеет более строгий порядок памяти, который может решить проблему (с большими накладными расходами). Это называется последовательным согласованным порядком, и это то, что мы рассмотрим дальше.

Последовательный Последовательный заказ

Последнее упорядочение памяти - это модель последовательного согласованного упорядочивания (memory_order_seq_cst). Это самый строгий порядок, гарантирующий программисту наиболее интуитивно понятный результат. Поскольку это единственная модель упорядочивания, которую можно легко обосновать, если вы не укажете модель упорядочивания для атомарных операций, она будет установлена ​​по умолчанию.

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

Давайте посмотрим на пример программы ниже,

#include <atomic>
#include <thread>
#include <assert.h>
#include <iostream>
std::atomic<bool> x,y;
void func_1() {
  x.store(true, std::memory_order_seq_cst);
}
void func_2() {
  y.store(true, std::memory_order_seq_cst);
}
void func_3() {
  while(!x.load(std::memory_order_seq_cst));
  if(y.load(std::memory_order_seq_cst)) {
    std::cout << "x == true then also y == true \n";
  }
}
void func_4() {
  while(!y.load(std::memory_order_seq_cst));
  if(x.load(std::memory_order_seq_cst)) {
    std::cout << "y == true then also x == true \n";
  }
}
int main() {
 x = false;
 y = false;
 std::thread thread_1(func_1);
 std::thread thread_2(func_2);
 std::thread thread_3(func_3);
 std::thread thread_4(func_4);
 thread_1.join();
 thread_2.join();
 thread_3.join();
 thread_4.join();
}

В настоящее время все атомарные операции помечены тегом memory_order_seq_cst.

Это гарантирует, что существует только один общий порядок истории операций с памятью для thread_1, thread_2, thread_3 и thread_4. Это имеет то же значение, что и все потоки, имеющие те же строки снимка, что и ниже.

В этом конкретном случае бывает так, что общие согласованные строки снимка говорят

  1. Во-первых, x устанавливается в true.
  2. Затем y устанавливается на true.

В результате, по крайней мере, печать из thread_4 (func_4) выполняется y == true then also x == true.

Заключение

Начиная с C ++ 11, модель упорядочения памяти для многопоточных программ была представлена ​​как часть языкового стандарта вместе со стандартными библиотеками для многопоточной синхронизации, такими как std::atomic, которые выполняются поверх моделей памяти. Это упрощает написание переносимых программ, поскольку до C ++ 11 модели упорядочения памяти определялись только каждой конкретной архитектурой.

Язык предлагает несколько вариантов упорядочивания памяти для использования с std::atomicоперациями.

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

Тем не менее, полезно знать о других вариантах, если вам нужно понять или отладить поведение программ, написанных кем-то еще, которые используют порядок памяти, отличный от memory_order_seq_cst , или если вам нужно оптимизировать программы, и вы не можете терпеть накладные расходы, связанные с memory_order_seq_cst.

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

Если вы хотите узнать больше по темам, есть отличные видео от Herb Sutter, рекомендую их посмотреть.

[1]: https://en.wikipedia.org/wiki/Memory_model_(programming)

[2]: https://en.wikipedia.org/wiki/Memory_ordering

[3]: https://en.cppreference.com/w/cpp/atomic/memory_order

[4]: https://en.wikipedia.org/wiki/As-if_rule