Подтвердить что ты не робот

Можно ли объединить атомные нагрузки в модели памяти С++?

Рассмотрим приведенный ниже фрагмент С++ 11. Для GCC и clang это компилируется в две (последовательно согласованные) нагрузки foo. Модель модели С++ позволяет компилятору объединить эти две нагрузки в одну нагрузку и использовать одно и то же значение для x и y?

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

#include <atomic>
#include <cstdio>

std::atomic<int> foo;

int main(int argc, char **argv)
{
    int x = foo;
    int y = foo;

    printf("%d %d\n", x, y);
    return 0;
}
4b9b3361

Ответ 1

Да, потому что мы не можем наблюдать разницу!

Реализация позволяет превратить ваш фрагмент в следующую (псевдо-реализацию).

int __loaded_foo = foo;

int x = __loaded_foo;
int y = __loaded_foo;

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

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





Объяснение

Учитывая поток, который продолжает обновлять foo инкрементным образом, гарантируется, что y будет иметь одно и то же значение или более позднее письменное значение по сравнению с содержимым x.

// thread 1 - The Writer
while (true) {
  foo += 1;
}
// thread 2 - The Reader
while (true) {
  int x = foo;
  int y = foo;

  assert (y >= x); // will never fire, unless UB (foo has reached max value)
}                  

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


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

  • Никакое новое значение не записывается в foo между двумя чтениями (x == y).
  • Новое значение записывается в foo между двумя чтениями (x < y).

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





Что говорит стандарт?

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

Это описано в [intro.execution]p1:

Семантические описания в этом Международном стандарте определяют параметризованной недетерминированной абстрактной машиной. Этот международный Стандарт не требует от структуры соответствия Реализации. В частности, им не нужно копировать или эмулировать структура абстрактной машины. Скорее, соответствующие реализации требуется для подражания (только) наблюдаемого поведения абстрактного машина, как описано ниже.

Другой раздел, который делает его еще более понятным [intro.execution]p5:

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

Дополнительная литература:





Как насчет опроса в цикле?

// initial state
std::atomic<int> foo = 0;
// thread 1
while (true) {
  if (foo)
    break;
}
// thread 2
foo = 1

Вопрос. Учитывая рассуждения в предыдущих разделах, может ли реализация просто читать foo один раз в потоке 1, а затем никогда не выходить из цикла, даже если поток 2 записывается в foo

Ответ; Нет.

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

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

Примечание. Содержимое этого раздела гарантируется [atomics.order]p3-4.





Что делать, если я действительно хочу предотвратить эту форму "оптимизации"?

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

Ответ 2

Да, в вашем конкретном примере (не иначе).

В вашем конкретном примере есть один поток выполнения, foo имеет статическую продолжительность хранения и инициализацию (то есть до ввода main), и в противном случае он никогда не будет изменен в течение всего жизненного цикла программы.
Другими словами, не существует различий, наблюдаемых извне, и правило "как-будто" может применяться юридически. Фактически, компилятор мог бы уничтожить атомарные инструкции, все вместе. Невозможно, чтобы значение x или y могло быть чем-то другим.

В программе с concurrency, которая изменяет foo, это не тот случай.

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

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

С точки зрения вашего образца кода, если другой поток изменяет foo сразу после того, как вы читаете его в x (но перед тем, как вы читаете значение в y), значение, которое помещается в y must - это значение, которое было написано. Поскольку, если операции происходят в этом порядке, они также должны быть реализованы в этом порядке.

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