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

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

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

    m += 2;

В теме 2

    n = m;

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

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

    ++m;
    ++m;

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

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

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

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

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

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

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

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

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

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

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

СОСТАВЛЯЕМЫЙ ОБРАЗЕЦ КОДА

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

#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 каждый освещают вопрос с умением. Их ответы скорее взаимодействуют, чем конкурируют. По моему мнению, читатели должны прислушаться ко всем трем ответам: @Christophe сначала; @ZalmanStern second; и @BenVoigt последний, чтобы подвести итог.

3 ответа

Решение

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

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

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

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

модель памяти C++ запрещает потоку 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 это особенности аппаратной платформы, которые нельзя выразить в переносимом C++.

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

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

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

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

Тема № 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;
}

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

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

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

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

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

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

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

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


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

Что бы ни говорили Бем и Адве, компиляторы C++ подчиняются следующему стандартному предложению:

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

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

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

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

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

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

РЕДАКТИРОВАТЬ: Я добавлю немного больше о "семантике", чтобы, возможно, помочь подумать о том, как анализировать подобные вещи.

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

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

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

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

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

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

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