С++ 11 представил стандартизованную модель памяти. Что это значит? И как это повлияет на программирование на C++?
=================================================================================================================

 C++ 11 представила стандартизованную модель памяти, но что именно это означает? И как это повлияет на программирование C++?

  [Эта статья](https://www.theregister.co.uk/2011/06/11/herb_sutter_next_c_plus_plus/page2.html) (**Гэвин Кларк**, цитирующая  **[Херба Саттера](https://en.wikipedia.org/wiki/Herb_Sutter)**) говорит, что,

 
>   Модель памяти означает, что код C++ теперь имеет стандартизованную библиотеку для вызова независимо от того, кто создал компилятор и на какой платформе он работает. Там стандартный способ контролировать, как разные потоки разговаривают с памятью процессора.
> 
>   "Когда вы говорите о разделении [кода] на разные ядра, которые находятся в стандарте, мы говорим о модели памяти. Мы собираемся ее оптимизировать, не нарушая следующих предположений, которые люди собираются сделать в коде", - сказал **Саттер**.
> 
> 

  Ну, я могу запомнить этот и аналогичные абзацы, доступные в Интернете (так как у меня была моя собственная модель памяти с момента рождения: P), и я могу даже написать ответ на вопросы, заданные другими, но, честно говоря, я не совсем понимаю этот.

  C++ программисты использовали для разработки многопоточных приложений еще раньше, поэтому как это важно, если это потоки POSIX или потоки Windows или C++ 11 потоков? Каковы преимущества? Я хочу понять детали низкого уровня.

  Я также чувствую, что модель памяти C++ 11 каким-то образом связана с поддержкой многопоточности C++ 11, так как я часто вижу эти два вместе. Если да, то как именно? Почему они должны быть связаны?

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

 

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

  Спецификация C++ не ссылается на какой-либо конкретный компилятор, операционную систему или ЦП. Он ссылается на абстрактную машину, которая является обобщением реальных систем. В мире Language Lawyer работа программиста заключается в написании кода для абстрактной машины; работа компилятора состоит в том, чтобы актуализировать этот код на конкретной машине. Жестко программируя спецификацию, вы можете быть уверены, что ваш код будет компилироваться и выполняться без изменений в любой системе с совместимым компилятором C++, будь то сегодня или через 50 лет.

  Абстрактная машина в спецификации C++ 98/C++ 03 является принципиально однопоточной. Поэтому невозможно написать многопоточный код C++, который является "полностью переносимым" по отношению к спецификации. Спецификация даже не говорит ничего об атомарности загрузки и хранения памяти или о порядке, в котором могут происходить загрузки и хранения, не говоря уже о мьютексах.

  Конечно, вы можете написать многопоточный код на практике для конкретных конкретных систем, таких как pthreads или Windows. Но не существует стандартного способа написания многопоточного кода для C++ 98/C++ 03.

  Абстрактная машина в C++ 11 имеет многопоточный дизайн. Он также имеет четко определенную модель памяти; то есть он говорит, что компилятор может и не может делать, когда дело доходит до доступа к памяти.

  Рассмотрим следующий пример, где к паре глобальных переменных одновременно обращаются два потока:

 
```
           Global
           int x, y;

Thread 1            Thread 2
x = 17;             cout << y << " ";
y = 37;             cout << x << endl;

```
  Что может выводить тема 2?

  В C++ 98/C++ 03 это даже не неопределенное поведение; сам вопрос не имеет смысла, потому что стандарт не предусматривает ничего, что называется "нитью".

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

  Но с C++ 11 вы можете написать это:

 
```
           Global
           atomic<int> x, y;

Thread 1                 Thread 2
x.store(17);             cout << y.load() << " ";
y.store(37);             cout << x.load() << endl;

```

 (function(w, d, n, s, t) {
 w[n] = w[n] || [];
 w[n].push(function() {
 Ya.Context.AdvManager.render({
 blockId: "R-A-248011-3",
 renderTo: "yandex\_rtb\_R-A-248011-3",
 async: true
 });
 });
 t = d.getElementsByTagName("script")[0];
 s = d.createElement("script");
 s.src = "//an.yandex.ru/system/context.js";
 s.async = true;
 t.parentNode.insertBefore(s, t);
 })(this, this.document, "yandexContextAsyncCallbacks");

 Теперь все становится намного интереснее. Прежде всего, поведение здесь определено. Поток 2 теперь может печатать `0 0` (если он выполняется до потока 1), `37 17` (если он выполняется после потока 1) или `0 17` (если он выполняется после того, как поток 1 назначает x, но до того, как он назначает y).

  Он не может напечатать `37 0`, потому что режим по умолчанию для атомарных загрузок/хранилищ в C++ 11 - обеспечить последовательную согласованность. Это просто означает, что все загрузки и хранилища должны быть "такими, как если бы" происходили в том порядке, в котором вы их записали в каждом потоке, а операции между потоками могут чередоваться, как нравится системе. Таким образом, стандартное поведение атома обеспечивает атомарность и порядок загрузки и хранения.

  Теперь на современном процессоре обеспечение последовательной согласованности может быть дорогостоящим. В частности, компилятор, вероятно, будет создавать полноценные барьеры памяти между каждым доступом здесь. Но если ваш алгоритм может терпеть неупорядоченные загрузки и хранения; то есть, если это требует атомарности, но не упорядоченности; то есть, если он может терпеть `37 0` как вывод из этой программы, то вы можете написать это:

 
```
           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_relaxed);   cout << y.load(memory_order_relaxed) << " ";
y.store(37,memory_order_relaxed);   cout << x.load(memory_order_relaxed) << endl;

```
  Чем более современный процессор, тем больше вероятность, что он будет быстрее, чем в предыдущем примере.

  И, наконец, если вам просто нужно поддерживать порядок в определенных загрузках и хранилищах, вы можете написать:

 
```
           Global
           atomic<int> x, y;

Thread 1                            Thread 2
x.store(17,memory_order_release);   cout << y.load(memory_order_acquire) << " ";
y.store(37,memory_order_release);   cout << x.load(memory_order_acquire) << endl;

```
  Это возвращает нас к заказанным нагрузкам и хранилищам - так что `37 0` больше не является возможным выходом - но это происходит с минимальными издержками. (В этом тривиальном примере результат такой же, как у последовательной последовательной последовательности; в более крупной программе это не так).

  Конечно, если вы хотите видеть только `0 0` или `37 17`, вы можете просто обернуть мьютекс вокруг исходного кода. Но если вы прочитали это далеко, держу пари, вы уже знаете, как это работает, и этот ответ уже дольше, чем я предполагал :-).

  Итак, суть. Мьютексы великолепны, и C++ 11 их стандартизирует. Но иногда по соображениям производительности вам нужны низкоуровневые примитивы (например, классический [шаблон блокировки с двойной проверкой](http://www.justsoftwaresolutions.co.uk/threading/multithreading-in-C++0x-part-6-double-checked-locking.html)). Новый стандарт предоставляет высокоуровневые гаджеты, такие как мьютексы и условные переменные, а также низкоуровневые гаджеты, такие как атомарные типы и различные варианты барьера памяти. Так что теперь вы можете писать сложные, высокопроизводительные параллельные процедуры полностью на языке, указанном в стандарте, и вы можете быть уверены, что ваш код будет компилироваться и выполняться без изменений как в современных системах, так и в завтрашних.

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

  Подробнее об этом см. В [этом блоге](https://bartoszmilewski.wordpress.com/2008/12/01/c-atomics-and-memory-ordering).

 

Я просто дам аналогию, с которой я понимаю модели согласованности памяти (или модели памяти, для краткости). Его вдохновляет семантическая бумага Лесли Лампорта  ["Время, часы и порядок событий в распределенной системе"](https://dl.acm.org/citation.cfm?id=359545.359563) .
Аналогия уместна и имеет фундаментальное значение, но может быть излишним для многих людей. Однако я надеюсь, что это дает мысленный образ (графическое представление), что облегчает рассуждение о моделях согласованности памяти.

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

Цитата из  ["Основатель согласованности и согласованности кеша"](http://www.morganclaypool.com/doi/abs/10.2200/S00346ED1V01Y201104CAC016) 

>  Интуитивная (и наиболее ограничительная) модель памяти представляет собой последовательную согласованность (SC), в которой многопоточное выполнение должно выглядеть как чередование последовательных исполнений каждого составного потока, как если бы потоки были мультиплексированы по времени на одноядерном процессор.
> 
> 
> 

Этот глобальный порядок памяти может варьироваться от одного запуска программы к другому и может быть не известен заранее. Характерной особенностью SC является набор горизонтальных срезов в диаграмме адрес-пространство-время, представляющий  **плоскости одновременности** (т.е. Изображения в памяти). На данной плоскости все его события (или значения памяти) являются одновременными. Существует понятие Абсолютного времени, в котором все нити согласуются с тем, какие значения памяти являются одновременными. В SC в каждый момент времени есть только один образ памяти, общий для всех потоков. То есть, в каждый момент времени все процессоры согласуются с образом памяти (т.е. Совокупным содержимым памяти). Это не только означает, что все потоки рассматривают одну и ту же последовательность значений для всех мест памяти, но также и то, что все процессоры выполняют одни и те же комбинации значений всех переменных. Это то же самое, что сказать, что все операции с памятью (по всем ячейкам памяти) наблюдаются в том же полном порядке всеми потоками.

В моделях с ослабленной памятью каждый поток будет разделять адрес-пространство-время по-своему, единственным ограничением является то, что срезы каждого потока не пересекаются друг с другом, потому что все потоки должны согласовывать историю каждой отдельной ячейки памяти (конечно, кусочки разных нитей могут и будут пересекаться друг с другом). Нет универсального способа разрезать его (без привилегированного слоения адресного пространства-времени). Ломтики не должны быть плоскими (или линейными). Они могут быть изогнутыми, и это то, что может сделать значения чтения потока, написанные другим потоком, из того, в каком они были написаны. Истории разных мест памяти могут скользить (или растягиваться) произвольно относительно друг друга  **при просмотре любой конкретный поток**. Каждый поток будет иметь другое представление о том, какие события (или, что то же самое, значения памяти) являются одновременными. Набор событий (или значений памяти), которые одновременно связаны с одним потоком, не являются одновременными с другими. Таким образом, в модели с ослабленной памятью все потоки по-прежнему сохраняют одну и ту же историю (то есть последовательность значений) для каждой ячейки памяти. Но они могут наблюдать разные образы памяти (т.е. Сочетания значений всех мест памяти). Даже если два разных места памяти записаны одним и тем же потоком в последовательности, два новых записанных значения могут наблюдаться в другом порядке другими потоками.

[Иллюстрация из Википедии]
![Picture from Wikipedia]()

Читатели, знакомые с Einsteins  **Специальная теория относительности**заметят, о чем я говорю. Перевод слов Минковского в область моделей памяти: адресное пространство и время - это тени адресного пространства-времени. В этом случае каждый наблюдатель (т.е. Поток) будет проектировать тени событий (т.е. Запоминает память/нагрузки) на свою собственную линию мира (т.е. Свою временную ось) и свою собственную плоскость одновременности (его ось адресного пространства), Темы в модели памяти С++ 11 соответствуют  **наблюдателям**, которые перемещаются относительно друг друга в специальной теории относительности. Последовательная согласованность соответствует  **галилеевому пространству-времени** (т.е. Все наблюдатели соглашаются на один абсолютный порядок событий и глобальное чувство одновременности).

 (function(w, d, n, s, t) {
 w[n] = w[n] || [];
 w[n].push(function() {
 Ya.Context.AdvManager.render({
 blockId: "R-A-248011-4",
 renderTo: "yandex\_rtb\_R-A-248011-4",
 async: true
 });
 });
 t = d.getElementsByTagName("script")[0];
 s = d.createElement("script");
 s.src = "//an.yandex.ru/system/context.js";
 s.async = true;
 t.parentNode.insertBefore(s, t);
 })(this, this.document, "yandexContextAsyncCallbacks");

Сходство между моделями памяти и специальной теорией относительности связано с тем, что оба определяют частично упорядоченный набор событий, часто называемый причинным множеством. Некоторые события (т.е. Хранилища памяти) могут влиять (но не влиять) на другие события. Поток С++ 11 (или наблюдатель в физике) представляет собой не более чем цепочку (т.е. Полностью упорядоченную совокупность) событий (например, память загружает и сохраняет к возможным различным адресам).

В теории относительности некоторый порядок восстанавливается на кажущуюся хаотичную картину частично упорядоченных событий, так как единственным временным порядком, с которым согласны все наблюдатели, является упорядочение среди "временных событий" (т.е. те события, которые в принципе могут быть связаны любым частица идет медленнее, чем скорость света в вакууме). Только упорядоченные по времени события инвариантно упорядочены.
[Время в физике, Craig Callender](http://philosophyfaculty.ucsd.edu/faculty/ccallender/index.html).

В модели памяти С++ 11 аналогичный механизм (модель согласованности-освобождения-выпуска) используется для установления этих  **локальных причинно-следственных связей**.

Чтобы обеспечить определение последовательности памяти и мотивации отказа от SC, я приведу из [Primer по согласованности памяти и согласованности кеша](http://www.morganclaypool.com/doi/abs/10.2200/S00346ED1V01Y201104CAC016)

>  Для компьютера с общей памятью модель согласованности памяти определяет архитектурно видимое поведение своей системы памяти. Критерий правильности одного ядра процессора разбивает поведение между "одним правильным результатом" и "множеством неправильных альтернатив". Это связано с тем, что архитектура процессоров предусматривает, что выполнение потока преобразует заданное входное состояние в одно четко определенное состояние вывода даже на ядре вне порядка. Однако модели согласованности с общей памятью относятся к нагрузкам и хранилищам нескольких потоков и обычно позволяют много правильных исполнений, не допуская многих (более) неправильных. Возможность множественных правильных исполнений обусловлена тем, что ISA позволяет одновременному выполнению нескольких потоков, часто со многими возможными законными перехватами команд из разных потоков.
> 
>    **Relaxed** или  **слабые** модели согласованности памяти мотивированы тем, что большинство упорядочений памяти в сильных моделях не нужно. Если поток обновляет десять элементов данных, а затем флаг синхронизации, программистам обычно не важно, обновлены ли элементы данных по порядку относительно друг друга, а только обновлены все элементы данных до обновления флага (обычно они реализуются с использованием инструкции FENCE). Расслабленные модели стремятся уловить эту повышенную гибкость порядка и сохранить только заказы, которые программисты "требуют", чтобы получить как более высокую производительность, так и правильность SC. Например, в некоторых архитектурах буферы записи FIFO используются каждым ядром для хранения результатов фиксированных (удаленных) хранилищ перед тем, как записывать результаты в кеши. Эта оптимизация повышает производительность, но нарушает SC. Буфер записи скрывает задержку обслуживания пропусков магазина. Поскольку магазины являются общими, возможность избежать остановки большинства из них является важным преимуществом. Для одноядерного процессора буфер записи может быть сделан архитектурно невидимым, гарантируя, что загрузка адреса A возвращает значение самого последнего хранилища в A, даже если один или несколько хранилищ для A находятся в буфере записи. Обычно это делается путем обхода значения самого последнего хранилища в до нагрузки от A, где "последнее" определяется порядком программы или путем остановки нагрузки A, если хранилище A находится в буфере записи, Когда используется несколько ядер, каждый из них будет иметь свой собственный байпас записи. Без буферов записи аппаратное обеспечение является SC, но с буферами записи это не так, что делает буферы записи архитектурно видимыми в многоядерном процессоре.
> 
>  Переупорядочение магазина-хранилища может произойти, если в ядре есть буфер записи, отличный от FIFO, который позволяет магазинам уходить в другом порядке, чем в том порядке, в котором они были введены. Это может произойти, если первый магазин промахивается в кеше, а второй - или если второй магазин может объединиться с более ранним хранилищем (то есть перед первым хранилищем). Переупорядочение нагрузки может также происходить в динамически запланированных ядрах, которые выполняют инструкции из программы. Это может вести себя так же, как переупорядочение магазинов на другом ядре (можете ли вы придумать пример чередования между двумя потоками?). Переупорядочение ранней загрузки с последующим хранилищем (переупорядочение хранилища-загрузки) может привести к множеству неправильных действий, таких как загрузка значения после отпускания блокировки, которая его защищает (если хранилище является операцией разблокировки). Обратите внимание, что переупорядочивание в хранилище может также возникать из-за локального обхода в обычно реализованном буфере записи FIFO даже с ядром, которое выполняет все команды в порядке выполнения программы.
> 
> 
> 

Поскольку согласованность кэша и согласованность памяти иногда сбиты с толку, поучительно также иметь эту цитату:

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

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

 

 Теперь это многолетний вопрос, но, будучи очень популярным, стоит упомянуть фантастический ресурс для изучения модели памяти С++ 11.Я не вижу смысла подытоживать его разговор, чтобы сделать этот еще один полный ответ, но учитывая, что это тот парень, который на самом деле написал стандарт, я думаю, что стоит посмотреть разговор.

  Herb Sutter имеет трехчасовой разговор о модели памяти С++ 11 под названием "атомное оружие", доступное на сайте Channel9 - [часть 1](http://channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-1-of-2) и [часть 2](http://channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-2-of-2). Разговор довольно технический и охватывает следующие темы:

 1. Оптимизации, расы и модель памяти
2. Заказ - Что: Приобретать и выпускать
3. Заказ - Как: Мьютекс, Атомная техника и/или Заборы
4. Другие ограничения на компиляторы и аппаратные средства
5. Код Gen & Performance: x86/x64, IA64, POWER, ARM
6. Расслабленная атомная энергия

 В разговоре не говорится об API, а скорее о рассуждениях, предпосылках под капотом и за кулисами (знаете ли вы, что смягченная семантика была добавлена к стандарту только потому, что POWER и ARM не поддерживают синхронизированную нагрузку эффективно?).

 

 Это означает, что стандарт теперь определяет многопоточность и определяет, что происходит в контексте нескольких потоков. Конечно, люди использовали разные реализации, но это все равно, что спрашивать, почему у нас должен быть `std::string` когда мы все можем использовать класс `string` home-roll.

  Когда вы говорите о потоках POSIX или потоках Windows, то это немного иллюзия, так как на самом деле вы говорите о потоках x86, так как это аппаратная функция для одновременной работы. Модель памяти С++ 0x дает гарантии, будь то x86, ARM, [MIPS](https://en.wikipedia.org/wiki/MIPS_architecture) или что-то еще, что вы можете придумать.

 

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

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

  Интересно, что компиляторы Microsoft для C++ имеют семантику приобретения/выпуска для volatile, которая является расширением C++ для решения проблемы отсутствия модели памяти в C++ [http://msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80).aspx](https://msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80).aspx). Однако, учитывая, что Windows работает только на x86/x64, это мало что говорит (модели памяти Intel и AMD позволяют легко и эффективно реализовать семантику получения/выпуска на языке).

 

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

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

Ранее атомы выполнялись с использованием встроенных средств компилятора или некоторой библиотеки более высокого уровня. Заборы были бы выполнены с использованием инструкций, специфичных для процессора (барьеров памяти).

 

 **Это означает, что семантика многопоточных программ теперь не определена.** Ранее это было просто не указано.

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

  Так называемая "модель памяти" означает, что стандарт C++ не имеет смысла, как сейчас.

 

C и C++ раньше определялись трассировкой выполнения правильно сформированной программы.

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

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