Упорядочение памяти с несколькими выпусками и одним получением

Рассмотрим следующий код:

#include <atomic>
#include <thread>
#include <cassert>
#include <memory>

int i = 0;
std::atomic_int a{0};

int main()
{
    std::thread thr1{[]
    {
        i = 1; // A
        a.store(1, std::memory_order::release); // B
    }};
    std::thread thr2{[]
    {
        while (a.load(std::memory_order::relaxed) != 1); // C
        a.store(2, std::memory_order::release); // D
    }};
    std::thread thr3{[]
    {
        while (a.load(std::memory_order::acquire) != 2); // E
        assert(i == 1); // F
        
    }};
    thr1.join();
    thr2.join();
    thr3.join();
}

Я предполагаю, что утверждение может потерпеть неудачу, а может и не потерпеть неудачу, и поведение здесь неопределенно.
Хотя у нас есть отношения «произошло до», такие как A->B, C->D, D->E, E->F, у нас нет таких отношений для B->C из-за ослабленной нагрузки в C. . С другой стороны, https://en.cppreference.com/w/cpp/atomic/memory_order говорит, что

Вся память пишет (в том числе неатомная и релаксированная атомарная), что произошло — до атомарного хранилища с точки зрения потока А, станут видимыми побочные эффекты в потоке B. То есть, как только атомная загрузка завершено, поток B гарантированно увидит все, что написал поток A на память. Это обещание выполняется только в том случае, если B действительно возвращает значение. который A сохранен, или значение, полученное позже в последовательности выпуска.

Но мы не можем сказать, что B->D — это последовательность освобождения, возглавляемая B, потому что на a вообще нет операций чтения-изменения-записи, поэтому этот абзац здесь не работает.

Прав ли я в своем понимании?

Ваше предположение неверно. Из-за циклов while переменную i можно проверить только после выполнения кода в потоках 1 и 2. Утверждение никогда не может потерпеть неудачу. Это даже не зависит от используемых порядков памяти.

Michaël Roy 07.06.2024 14:10

@MichaëlRoy, да, я согласен, что его можно протестировать только после кода в двух предыдущих тредах, но как это гарантирует, что результат i = 1 будет там виден?

Denis 07.06.2024 14:26

Это перевернуто. Утверждение об отсутствии срабатывания (упорядочение) — это то, что должно быть доказано. Отсутствие этого означает, что утверждение может сработать в модели памяти.

Jeff Garrett 07.06.2024 16:09
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
10
3
369
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

В любой типичной реализации утверждение должно быть истинным.

Компилятор должен добавить 2 барьера памяти,

  1. в потоке 1 B будет сброшен в основную память после i в связи с выпуском.
  2. в потоке 3 i можно загрузить только после E из-за приобретения.

Если мы сможем доказать, что E произойдет после B, то это утверждение всегда будет истинным.

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

поэтому утверждение всегда будет True для любой типичной реализации.

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

Обновлено: судя по комментариям, это не переносимо на все платформы.

Я согласен, что это то, что делает типичная реализация, но я не уверен, что стандарт C действительно требует этого. Помните, что стандартная модель памяти не работает с точки зрения «барьеров памяти», «предсказания ветвей» или даже «после», только с точки зрения событий «до», и, как и ОП, я не вижу никакого способа установить «происходит-до». перед заказом по этому коду.

Nate Eldredge 07.06.2024 15:41

Я имею в виду C++, а не C выше.

Nate Eldredge 07.06.2024 15:47

@Nate прав, и это не просто гипотетическая проблема, если вы хотите полную переносимость. Только RMW продолжает последовательность релизов; чистый магазин этого не делает. Таким образом, загрузка не обязательно синхронизируется со всеми предыдущими хранилищами в порядке модификации. (Это происходит на большинстве ISA, но по крайней мере одна основная ISA может нарушить это, вероятно, PowerPC. C++ 20 даже еще больше ослабил правила; чистые сохранения из того же потока используются для продолжения последовательности выпуска, как и RMW из любого потока. )

Peter Cordes 07.06.2024 16:25

Ты прав.

  • Перед этим расположены следующие символы: A << B, C << D, E << F.
  • Следующие согласованности упорядочены ранее: B << C, C << D, D << E
  • Следующее синхронизируется с: B ~ E

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

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

Рассуждения, которые хотелось бы применить в программе OP, по существу смешивают два: порядок модификации a плюс выпуск-получение и порядок программы. Вот почему невозможно собрать воедино порядок.

Спасибо за ответ. Единственное, чего я не понимаю: 1). Незаданная ранее согласованность вступает в игру только тогда, когда включен режим Memory_order_seq_cst и 2). Если B все-таки синхронизируется с E, то, видимо, я не прав :(

Denis 07.06.2024 17:54
Ответ принят как подходящий

Ты прав. Либо утверждение выполнено успешно, либо поведение не определено (хотя на практике это означает, что утверждение должно завершиться неудачей).

Есть интуитивный способ объяснить это и формальный способ.

Интуиция

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

Хотя thr3 может достичь assert(i == 1); только после того, как thr2 завершит всю свою работу из-за ожидания занятости в thr3, в момент утверждения thr3 все еще может иметь устаревшее представление thr1 (где установлен i). Это устаревшее представление старше, чем существующее thr2.

Обратите внимание, что thr1 должен завершить всю свою работу, прежде чем thr2 сможет выполнить свою работу, а thr2 должен завершить всю свою работу, прежде чем thr3 сможет выполнить свою работу. Однако это ограничение на последовательность событий не ограничивает то, какие операции с памятью становятся видимыми между потоками. Даже если бы thr2 имел a.load(std::memory_order::acquire), это не навязывало бы, что thr3 должен иметь более свежее представление о thr1.

Формальное объяснение

i = 1; и assert(i == 1);, возможно, являются гонкой данных. Либо утверждение выполнено успешно, либо поведение не определено.

Проблема в том, что неизвестно, синхронизируется ли a.store(1, std::memory_order::release); с a.load(std::memory_order::acquire). Условие для этого можно найти в [atomics.order] p2

Атомарная операция A, выполняющая операцию освобождения атомарного объекта M, синхронизируется с атомарной операцией B, которая выполняет операцию получения M и получает свое значение из любого побочного эффекта в последовательности освобождения, возглавляемой A.

Если load принимает свое значение от store(1) в какой-то момент, то a происходит до того, как формируется связь, потому что i = 1 упорядочивается до этого store(1) и store(1) синхронизируется с load() в thr3.

Однако нет никакой гарантии, что это произойдет ([intro.races] стр.14):

Значение атомарного объекта M, определенное оценкой B, — это значение, сохраненное неким неопределенным побочным эффектом A, который модифицирует M, где B не происходит раньше A.

Вероятная последовательность событий такова, что thr2 завершает всю свою работу до того, как thr3 начнётся. thr3 затем загружает свое значение для a из побочного эффекта a.store(2, std::memory_order::release); // D, поэтому он никогда не берет свое значение из store в thr1.

В этом случае происходит гонка данных (между i = 1 и i == 1), а поведение не определено.

На большинстве аппаратных средств загрузка получения синхронизируется со всеми предыдущими хранилищами в этом месте. (т.е. чистое хранилище может продолжить последовательность релизов). Это естественное следствие MESI для процессоров, где единственный способ увидеть хранилище другим потоком — это предоставить писателю эксклюзивное право владения MESI строкой кэша, а затем читателю получить его копию. Но некоторые ISA слабее, что позволяет реализовать аппаратные реализации, которые могут, например. пересылаемые сохранения между логическими ядрами одного и того же физического ядра (например, POWER); тот же механизм позволяет изменять порядок IRIW.

Peter Cordes 08.06.2024 18:20

Итак, правила C++ настолько слабы из-за нескольких ISA, таких как графические процессоры POWER и NVidia; большинство из них сильнее, включая x86 и ARMv8. (И никакие реальные процессоры ARMv7 не были такими слабыми, как ARMv7, разрешенный на бумаге, поскольку не было ARM с SMT (несколько логических ядер на физическое устройство).) См. Почему последовательность выпуска может содержать только чтение-изменение-запись, но не чисто пиши / Что значит "последовательность релизов"?

Peter Cordes 08.06.2024 18:22

Другие вопросы по теме

Похожие вопросы