std :: memory_order_relaxed атомарность по отношению к той же атомарной переменной

В документации cppreference о заказах памяти говорится:

Типичное использование для ослабленного упорядочения памяти - увеличение счетчиков, таких как счетчики ссылок std :: shared_ptr, поскольку для этого требуется только атомарность, но не упорядочение или синхронизация (обратите внимание, что уменьшение счетчиков shared_ptr требует синхронизации получения-освобождения с деструктором )

Означает ли это, что ослабление порядка в памяти на самом деле не приводит к атомарности по отношению к одной и той же переменной? Но скорее просто приводит к возможной согласованности по отношению к другим расслабленным нагрузкам на память и / или compare_exchanges? Использование std::memory_order_seq_cst было бы единственным способом увидеть стабильные результаты в сочетании с std::memory_order_relaxed?

Я исходил из предположения, что std::memory_order_relaxed по-прежнему является атомарным по отношению к той же переменной, но не предоставляет никаких других ограничений на загрузку и хранение по отношению к другим данным.


person Curious    schedule 06.01.2018    source источник
comment
std::memory_order_relaxed гарантирует атомарность, но не упорядочивает или синхронизирует. Таким образом, он работает для таких вещей, как счетчики статистики, но может быть сложно использовать, если счет является частью инварианта, включающего другие области памяти.   -  person Arch D. Robison    schedule 06.01.2018
comment
@ ArchD.Robison, но тогда почему cppreference говорит, что требуется получение-выпуск?   -  person Curious    schedule 06.01.2018
comment
Потому что память должна быть в синхронизированном состоянии, прежде чем ее можно будет удалить. Объяснить, почему приращение может быть relaxed, в то время как декремент использует acq/rel, нетривиально, хотя   -  person LWimsey    schedule 06.01.2018
comment
@LWimsey Вы имеете в виду, что если используется расслабленная модель памяти, может быть чтение или запись (если осторожно) в состояние подсчета ссылок, и это может быть упорядочено после операции удаления в деструкторе без более строгого упорядочения памяти? В этом есть смысл ... Тогда почему тогда не используется потребление-релиз? Поскольку delete является зависимостью от полученного значения   -  person Curious    schedule 06.01.2018
comment
Да, теоретически это может произойти. Может быть, я отправлю ответ с примером, чтобы объяснить shared_ptr порядок памяти. (Или, по крайней мере, попытку).   -  person LWimsey    schedule 06.01.2018
comment
@Curious У вас уже есть ответ, поэтому я просто напишу небольшой комментарий;). Фраза на самом деле не приводит к атомарности заставляет меня думать, что вы путаете атомарность с порядком памяти. Атомарность просто означает, что можно безопасно выполнять одновременный доступ для разных потоков, не вызывая Undefine Behavior в форме гонки данных. Вы все еще не знаете, какой поток был первым, но программа все еще четко определена. Упорядочивание намного сложнее, поскольку речь идет о порядке в разных потоках, в котором видны побочные эффекты атомарных операций (записи). Но для любой заданной (атомарной) переменной все потоки всегда будут видеть   -  person Carlo Wood    schedule 16.02.2018
comment
тот же порядок, независимо от того, какой порядок памяти вы используете (например, расслабленный). Это должно быть так, поскольку если вы напишете 1, затем 2 и затем 3 в переменную x, последнее записанное значение будет тем, которое сохраняется, когда прошло достаточно времени. Вы можете видеть, что этот порядок определяется порядком, в котором записи поступают в конечный пункт назначения в памяти, из которых есть только один (кеши (L1, L2, L3 ...) делают это более сложным в действительности, но вы не можете Обратите внимание, что, кроме доступа к переменной из одного потока, выполняется быстро (кэшируется), в то время как одновременный доступ к одной и той же переменной из нескольких потоков выполняется медленно.)   -  person Carlo Wood    schedule 16.02.2018
comment
@Curious не используется потребление-релиз delete потребляет ... что?   -  person curiousguy    schedule 21.01.2019
comment
@LWimsey Объяснить, почему приращение может быть ослаблено, в то время как декремент использует acq / rel, нетривиально, хотя Проще говоря, ничего важного не происходит, когда RC достигает высокой точки (а максимального значения в любом случае нет) и что-то очень важное происходит, когда он достигает нижней точки (RC> 0 по определению). Таким образом, операции увеличения и уменьшения принципиально не похожи.   -  person curiousguy    schedule 20.11.2019


Ответы (1)


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

Атомарная операция всегда атомарна по отношению к переменной (или POD), к которой она применяется; изменения одной переменной станут видимыми для всех потоков в согласованном порядке.
В вашем вопросе описан способ работы расслабленных атомарных операций:

std::memory_order_relaxed по-прежнему является атомарным по отношению к той же переменной, но не предоставляет никаких других ограничений на загрузку и хранение по отношению к другим данным.

Ниже приведены 2 типичных сценария, в которых ограничения порядка для атомарной операции могут быть опущены (т. Е. С использованием std::memory_order_relaxed):

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

    Типичный пример - атомарный счетчик, увеличиваемый несколькими потоками, чтобы отслеживать, сколько раз произошло конкретное событие. Операцию приращения (fetch_add) можно ослабить, если счетчик представляет значение, не зависящее от других операций.
    Я считаю пример, приведенный cppreference, не очень убедительным, потому что счетчик ссылок shared_ptr делает иметь зависимость; т.е. память удаляется, как только ее значение становится равным нулю. Лучшим примером является веб-сервер, отслеживающий количество входящих запросов только для целей отчетности.

  2. Упорядочивание памяти необходимо, но нет необходимости в использовании ограничений упорядочения, потому что требуемая синхронизация уже прошла (IMO, это лучше объясняет, почему приращение счетчика ссылок shared_ptr может быть ослаблено, см. Пример ниже).
    Конструктор копирования / перемещения shared_ptr может быть вызван только тогда, когда у него есть синхронизированное представление (ссылка на) скопированный / перемещенный экземпляр (или это было бы неопределенным поведением), и поэтому никакого дополнительного упорядочивания не требуется.

В следующем примере показано, как упорядочение памяти обычно используется реализацией shared_ptr для изменения своего счетчика ссылок. Предположим, что все потоки выполняются параллельно после того, как sp_main был освобожден (тогда счетчик ссылок shared_ptr равен 10).

int main()
{
    std::vector<std::thread> v;
    auto sp_main = std::make_shared<int>(0);

    for (int i = 1; i <= 10; ++i)
    {
        // sp_main is passed by value
        v.push_back(thread{thread_func, sp_main, i});
    }

    sp_main.reset();

    for (auto &t : v)  t.join();
}

void thread_func(std::shared_ptr<int> sp, int n)
{
    // 10 threads are created

    if (n == 7)
    {
        // Only thread #7 modifies the integer
        *sp = 42;
    }

    // The only thead with a synchronized view of the managed integer is #7
    // All other threads cannot read/write access the integer without causing a race

    // 'sp' going out of scope -> destructor called
}

Создание потока гарантирует (межпотоковую) связь «происходит раньше» между make_sharedmain) и конструктором копирования / перемещения sp (внутри каждого потока). Следовательно, конструктор shared_ptr имеет синхронизированное представление памяти и может безопасно увеличивать ref_count без дополнительного упорядочивания:

ctrlblk->ref_count.fetch_add(1, std::memory_order_relaxed);

Что касается части уничтожения, поскольку только поток #7 записывает в общее целое число, остальным 9 потокам не разрешается обращаться к той же области памяти, не вызывая гонки. Это создает проблему, потому что все потоки разрушаются примерно в одно и то же время (предположим, что reset в main был вызван ранее), и только один поток собирается удалить общее целое число (тот, который уменьшает ref_count с 1 до 0).
Крайне важно, чтобы последний поток имел синхронизированное представление памяти, прежде чем он удалит целое число, но поскольку 9 из 10 потоков не имеют синхронизированного представления, необходимо дополнительное упорядочение.

Деструктор может содержать что-то вроде:

if (ctrlblk->ref_count.fetch_sub(1, std::memory_order_acq_rel) == 1)
{
    // delete managed memory
}

Атомарный ref_count имеет один порядок модификации, и поэтому все атомарные модификации происходят в определенном порядке. Скажем, потоки (в этом примере), которые выполняют последние 3 декремента для ref_count, - это поток #7 (3 → 2), #5 (2 → 1) и #3 (1 → 0). Оба декремента, выполняемые потоками #7 и #5, происходят раньше в порядке модификации, чем тот, который выполняется #3.
Последовательность выпуска становится следующей:

#7 (выпуск магазина) → #5 (чтение-изменение-запись, порядок не требуется) → #3 (получение загрузки)

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

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

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

if (ctrlblk->ref_count.fetch_sub(1, std::memory_order_release) == 1)
{
    std::atomic_thread_fence(std::memory_order_acquire);

    // delete managed memory
}
person LWimsey    schedule 08.01.2018
comment
Я не понимаю, почему std::memory_order_relaxed нельзя использовать для уменьшения счетчика ссылок, поскольку нет других загрузок / хранилищ, которые нельзя переупорядочивать относительно атомарного изменения счетчика ссылок. - person Maxim Egorushkin; 08.01.2018
comment
@MaximEgorushkin Сохранение общего целого числа (42 в примере) должно быть освобождено модифицирующим потоком (# 7) и получено последним потоком (# 3). Без этого порядка удаление области разделяемой памяти представляет собой гонку данных. - person LWimsey; 08.01.2018
comment
поскольку счетчик ссылок shared_ptr имеет зависимость, но чтение счетчика ссылок в программе MT считается ненадежным. Вам не нужно use_count()>X; единственное релевантное событие для refcount - это когда он падает до нуля. - person curiousguy; 21.01.2019
comment
Единственная статья [sic] с синхронизированным представлением управляемого целого числа - это №7. Ваша программа совершенно законна и должна поддерживаться std::shared_ptr, так что это технически допустимый пример, подтверждающий вашу точку зрения; OTOH - это исключительно глупый дизайн: все потоки, кроме одного, получают объект, с которым они ничего не могут сделать, кроме как безопасно его уничтожить. Идеально для языкового юриста, плохо для учебного курса. - person curiousguy; 21.01.2019
comment
@MaximEgorushkin Опубликованный код на 100% легален, но крайне нереалистичен. На практике объект, совместно используемый разными потоками без внутренней синхронизации, будет использоваться как объект только для чтения. Все потоки будут читать объект, и когда они будут выполнены, они уменьшат счетчик. Это должно происходить в таком порядке; OTOH приращение счетчика не обязательно должно происходить немедленно, оно слабо упорядочено (оно должно произойти только до уменьшения). - person curiousguy; 21.01.2019
comment
Если функция создает копию и уничтожает ее без какой-либо промежуточной синхронизации или чего-либо, что действует на какие-либо shared_ptr, обе операции могут быть опущены: void f(shared_ptr<T> x) { if (x->foo()) g(move(x)); } Встраивая f, компилятор потенциально может полностью избежать манипуляции refcount с условием false и push позже он будет встроен g, если это правда. - person curiousguy; 21.01.2019