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

Могут ли атомиты пострадать от ложных магазинов?

В С++ могут ли атоматики пострадать от ложных хранилищ?

Например, предположим, что m и n являются атомарными и m = 5 изначально. В потоке 1,

    m += 2;

В потоке 2,

    n = m;

Результат: конечное значение n должно быть 5 или 7, правильно? Но может ли это быть побочным? Может ли это ложно быть 4 или 8 или даже что-то еще?

Другими словами, позволяет ли модель памяти С++ запретить нить 1 вести себя, как если бы она это сделала?

    ++m;
    ++m;

Или, что более странно, как будто это так?

    tmp  = m;
    m    = 4;
    tmp += 2;
    m    = tmp;

Ссылка: H.-J. Boehm and S. V. Adve, 2008, Рисунок 1. (Если вы следуете ссылке, то в разделе 1 бумаги см. Первый маркированный товар: "Неофициальные спецификации, предоставленные..." )

ВОПРОС В АЛЬТЕРНАТИВНОЙ ФОРМЕ

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

Предположим, что программист попытался передать нить 1, чтобы пропустить операцию:

    bool a = false;
    if (a) m += 2;

Сохраняет ли модель памяти С++ нить 1 во время выполнения, как будто она это сделала?

    m += 2; // speculatively alter m
    m -= 2; // oops, should not have altered! reverse the alteration

Я прошу, потому что Boehm и Adve, ранее связанные, похоже, объясняют, что многопоточное выполнение может

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

КОД КОМПАЛЬНОГО ОБРАЗЦА

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

#include <iostream>
#include <atomic>
#include <thread>

// For the orignial question, do_alter = true.
// For the question in alternate form, do_alter = false.
constexpr bool do_alter = true;

void f1(std::atomic_int *const p, const bool do_alter_)
{
    if (do_alter_) p->fetch_add(2, std::memory_order_relaxed);
}

void f2(const std::atomic_int *const p, std::atomic_int *const q)
{
    q->store(
        p->load(std::memory_order_relaxed),
        std::memory_order_relaxed
    );
}

int main()
{
    std::atomic_int m(5);
    std::atomic_int n(0);
    std::thread t1(f1, &m, do_alter);
    std::thread t2(f2, &m, &n);
    t2.join();
    t1.join();
    std::cout << n << "\n";
    return 0;
}

Этот код всегда печатает 5 или 7 при запуске. (На самом деле, насколько я могу судить, он всегда печатает 7, когда я его запускаю.) Однако я ничего не вижу в семантике, которая помешала бы ей печатать 6, 4 или 8.

Отличный Cppreference.com утверждает, "Атомные объекты свободны от гонок данных", что приятно, но в таком контексте, как этот, что это значит?

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

ОТВЕТЫ

@Christophe, @ZalmanStern и @BenVoigt каждый освещает вопрос с мастерством. Их ответы скорее сотрудничают, чем конкурируют. На мой взгляд, читатели должны прислушаться ко всем трем ответам: сначала Кристофе; @ZalmanStern второй; и @BenVoigt для подведения итогов.

4b9b3361

Ответ 1

Существующие ответы дают много хороших объяснений, но они не дают прямого ответа на ваш вопрос. Здесь мы идем:

Могут ли атомиты пострадать от ложных хранилищ?

Да, но вы не можете наблюдать их из программы на С++, которая не содержит данных.

Только volatile фактически запрещается выполнять дополнительные обращения к памяти.

ли модель памяти С++ запрещает нить 1 вести себя, как если бы она это делала?

++m;
++m;

Да, но это разрешено:

lock (shared_std_atomic_secret_lock)
{
    ++m;
    ++m;
}

Это разрешено, но глупо. Более реалистичная возможность заключается в следующем:

std::atomic<int64_t> m;
++m;

в

memory_bus_lock
{
    ++m.low;
    if (last_operation_did_carry)
       ++m.high;
}

где memory_bus_lock и last_operation_did_carry являются функциями аппаратной платформы, которые не могут быть выражены в переносном С++.

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

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

  • Отладчики программного обеспечения могут видеть промежуточные значения и должны знать о блокировке программного обеспечения, чтобы избежать неправильной интерпретации.
  • Аппаратная периферия увидит изменения в программной блокировке и промежуточные значения атомного объекта. Некоторое волшебство может потребоваться, чтобы периферийное устройство распознало взаимосвязь между ними.
  • Если атомный объект находится в общей памяти, другие процессы могут видеть промежуточные значения и не могут каким-либо образом проверять блокировку программного обеспечения/могут иметь отдельную копию указанной блокировки программного обеспечения.
  • Если другие потоки одной и той же программы на С++ нарушают тип безопасности таким образом, который вызывает гонку данных (например, используя memcpy для чтения атомарного объекта), они могут наблюдать промежуточные значения. Формально это поведение undefined.

Последний важный момент. "Спекулятивная запись" - очень сложный сценарий. Это легче увидеть, если мы переименуем условие:

Тема № 1

if (my_mutex.is_held) o += 2; // o is an ordinary variable, not atomic or volatile
return o;

Тема № 2

{
    scoped_lock l(my_mutex);
    return o;
}

Здесь нет гонки данных. Если Thread # 1 заблокировал мьютекс, запись и чтение не могут быть неупорядоченными. Если он не заблокирован мьютексом, потоки выполняются неупорядоченными, но оба выполняют только чтение.

Поэтому компилятор не может разрешить просмотр промежуточных значений. Этот код на С++ не является правильным переписыванием:

o += 2;
if (!my_mutex.is_held) o -= 2;

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

Если промежуточные значения не должны рассматриваться аппаратными средствами, вам нужно использовать volatile (возможно, помимо атомистики, потому что volatile read-modify-write не гарантируется атомарным). При volatile, запрашивая операцию, которая не может быть выполнена как написанная, приведет к сбою компиляции, а не к ложному доступу к памяти.

Ответ 2

В коде используется fetch_add() для атома, что дает следующую гарантию:

Атомно заменяет текущее значение результатом арифметического добавления значения и arg. Операция - операция чтения-изменения-записи. Память зависит от значения порядка.

Семантика кристально чиста: до операции it m, после операции m + 2, и ни один поток не обращается к тому, что между этими двумя состояниями, потому что операция является атомарной.


Изменить: дополнительные элементы, касающиеся вашего альтернативного вопроса

Что бы ни говорили Boehm и Adve, компиляторы С++ подчиняются следующему стандарту:

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

Если компилятор С++ будет генерировать код, который может позволить спекулятивным обновлениям вмешиваться в наблюдаемое поведение программы (ака, получая что-то еще, чем 5 или 7), он не будет стандартным, поскольку он не сможет гарантировать гарантию упомянутый в моем первоначальном ответе.

Ответ 3

Ваш пересмотренный вопрос немного отличается от первого в том смысле, что мы перешли от последовательной последовательности к расслабленному порядку памяти.

Оба аргумента и определения слабых порядков памяти могут быть довольно сложными. Например. обратите внимание на разницу между спецификациями С++ 11 и С++ 14, указанными здесь: http://en.cppreference.com/w/cpp/atomic/memory_order#Relaxed_ordering. Однако определение атомарности не позволяет вызову fetch_add разрешить любому другому потоку видеть значения, отличные от тех, которые иначе записаны в переменную или один из них плюс 2. (Нить может делать почти что угодно, если это гарантирует промежуточные значения не наблюдаются другими потоками.)

(Чтобы получить ужасную специфику, вы, скорее всего, захотите найти "read-modify-write" в спецификации С++, например http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf.)

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

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

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

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

Основные операции синхронизации: acquire и release. Смотрите: http://en.cppreference.com/w/cpp/atomic/memory_order. Это имена на то, что происходит с мьютексом. Операция получения применяется к нагрузкам и предотвращает переупорядочение любых операций с памятью в текущем потоке за пределами точки, в которой происходит приобретение. Он также устанавливает порядок с любыми предыдущими операциями релиза по одной и той же переменной. Последний бит определяется загруженным значением. То есть если нагрузка возвращает значение из заданной записи с синхронизацией релиза, теперь теперь загружается загрузка против этой записи, а все остальные операции с памятью по этим потокам встают на место в соответствии с правилами упорядочения.

Операции Atomic или read-modify-write - это их небольшая последовательность в более крупном порядке. Гарантируется, что чтение, операция и запись происходят атомарно. Любое другое упорядочение задается параметром порядка памяти для операции. Например. указывая на непринужденное упорядочение, никакие ограничения не применяются к другим переменным. То есть не происходит никакого приобретения или освобождения, подразумеваемого операцией. Задание memory_order_acq_rel говорит, что операция не только атома атома, но и чтение является приобретением, а запись является выпуском - если поток читает значение из другой записи с семантикой выпуска, все остальные атомы теперь имеют соответствующее ограничение порядка в этой теме.

A fetch_add с непринужденным порядком памяти может использоваться для счетчика статистики при профилировании. В конце операции все потоки будут делать что-то еще, чтобы гарантировать, что все эти приращения счетчика теперь будут видны конечному читателю, но в промежуточном состоянии нам все равно, пока итоговая сумма складывается. Однако это не означает, что промежуточные чтения могут отображать значения, которые никогда не были частью счета. Например. если мы всегда добавляем четные значения в счетчик, начинающийся с 0, ни один нить не должен читать нечетное значение независимо от порядка.

Я немного откладываю, не имея возможности указать на конкретный текст в стандарте, в котором говорится, что не может быть никаких побочных эффектов для атомных переменных, кроме тех, которые явно закодированы в программе каким-то образом. Многие вещи говорят о побочных эффектах, но, кажется, считается само собой разумеющимся, что побочными эффектами являются те, которые указаны источником, а не что-либо, составленное компилятором. У меня нет времени отслеживать это прямо сейчас, но есть много вещей, которые бы не сработали, если бы это не было гарантировано, а часть точки std::atomic - это ограничение, поскольку оно не гарантируется другими переменными, (Это несколько предусмотрено volatile или, по крайней мере, предназначено для этого. Часть причин, по которым мы имеем эту спецификацию для упорядочения памяти вокруг std::atomic, состоит в том, что volatile никогда не становился достаточно хорошо указанным подробно о причине и ни один набор ограничений не удовлетворял всем потребностям.)