Как выразить в С++ 11 обычные хранилища (экспорт) и загрузки (импорт) барьеры (заборы)?

В следующем коде реализована некоторая свободная от блокировок (и атомарная!) связь между потоками, которая требует использования барьеров сохранения и загрузки памяти, но семантика освобождения-получения C++11 не подходит и не гарантирует правильность. На самом деле алгоритм выявляет потребность в своего рода инверсии семантики освобождения-приобретения, то есть для того, чтобы сигнализировать о том, что какая-то операция не произошла, а не произошла.

volatile bool valid=true;
volatile uint8_t blob[1024] = {/*some values*/};

void zero_blob() {
    valid=false;
    STORE_BARRIER;
    memset(blob,0,1024);
}

int32_t try_get_sum(size_t index_1, size_t index_2) {
    uint8_t res = blob[index_1] + blob[index_2];
    LOAD_BARRIER;
    return valid ? res : -1; 
}

Я могу сделать этот код правильным на всех аппаратных архитектурах, просто используя встроенные барьеры памяти, например. на Intel здесь нет необходимости в барьерах памяти, на Sparc (RMO) membar #StoreStore и membar #LoadLoad, на PowerPC lwsync для обоих. Так что ничего страшного, и код является типичным примером использования барьеров хранения и загрузки. Теперь, какую конструкцию С++ 11 я должен использовать, чтобы сделать код правильным, предполагая, что я не хочу преобразовывать «BLOB» в объекты std::atomic, так как это сделало бы «BLOB» защитным объектом, а переменную «valid» защищенным, тогда как это наоборот. Преобразование переменной «valid» в объект std::atomic для меня нормально, но нет никаких барьеров, гарантирующих правильность. Чтобы было понятно, рассмотрим следующий код:

volatile std::atomic<bool> valid{true};
volatile uint8_t blob[1024] = {/*some values*/};

void zero_blob() {
    valid.store(false, std::memory_order_release);
    memset(blob,0,1024);
}

int32_t try_get_sum(size_t index_1, size_t index_2) {
    uint8_t res = blob[index_1] + blob[index_2];
    return valid.load(std::memory_order_acquire) ? res : -1; 
}

Код неверен, так как барьеры размещены не в тех местах, и, следовательно, запись в «большой двоичный объект» может предшествовать записи в «действительный» и/или загрузка из «действительного» может предшествовать загрузке из «большого двоичного объекта». Я думал, что для работы с такими конструкциями C++11 предоставил std::atomic_thread_fence и код должен быть таким:

volatile std::atomic<bool> valid{true};
volatile uint8_t blob[1024] = {/*some values*/};

void zero_blob() {
    valid.store(false, std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_release);
    memset(blob,0,1024);
}

int32_t try_get_sum(size_t index_1, size_t index_2) {
    uint8_t res = blob[index_1] + blob[index_2];
    std::atomic_thread_fence(std::memory_order_acquire);
    return valid.load(std::memory_order_relaxed); ? res : -1; 
}

К сожалению, С++ 11 говорит:

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

в котором четко указано, что std::atomic_thread_fence следует размещать в противоположных сторонах операций над атомарным объектом.

ПОЗЖЕ РЕДАКТИРОВАТЬ

Ниже вы найдете гораздо более полезный пример:

volatile uint64_t clock=1;
volatile uint8_t blob[1024] = {/*some values*/};

void update_blob(uint8_t vals[1024]) {
    clock++;
    STORE_BARRIER;
    memcpy(blob,vals,1024);
    STORE_BARRIER;
    clock++;
}

int32_t try_get_sum(size_t index_1, size_t index_2) {
    uint64_t snapshot = clock;
    if(snapshot & 0x1) {
        LOAD_BARRIER;
        uint8_t res = blob[index_1] + blob[index_2];
        LOAD_BARRIER;
        if(snapshot == clock)
            return res;
    }
    return -1;
}

person dervih    schedule 05.09.2019    source источник
comment
Я думаю, что в вашем коде есть гонка данных, которая является UB в соответствии со стандартом C++. Что такое memsetтинг и чтение blob[index] одновременно? Стандарт не говорит, что res будет неопределенным, тогда он четко говорит, что это UB. Конечно, это может работать с вашей реализацией/средой, но я бы не советовал такой код.   -  person Daniel Langr    schedule 05.09.2019
comment
volatile бесполезен для любого вашего кода. Если вы создаете atomic значение volatile, то вы почти наверняка делать неправильно.   -  person Nicol Bolas    schedule 05.09.2019
comment
volatile std::atomic<bool> ну... это новинка в моем списке неправильного использования volatile   -  person Guillaume Racicot    schedule 05.09.2019
comment
@NicolBolas Что не так с изменчивой атомарной переменной?   -  person curiousguy    schedule 06.09.2019
comment
@curiousguy: это неправильно в том смысле, что volatile не помогает коду достичь своей цели, и его присутствие убедительно свидетельствует о том, что автор страдает от распространенной ошибки: вера в то, что volatile имеет какое-либо отношение к видимости или потокобезопасности действия.   -  person Nicol Bolas    schedule 06.09.2019
comment
atomic_thread_fence(std::memory_order_release); Как релиз скажет что-нибудь о том, чего еще не было? Освобождение означает, что прошлое прошло; это создает прошлое. Это не создает будущее (приобретает).   -  person curiousguy    schedule 06.09.2019
comment
@NicolBolas Нет правила, согласно которому volatile нельзя использовать с потоками для общих переменных. (В некоторых случаях даже скаляр volatile можно использовать для взаимодействия между потоками.) Автор здесь явно не ожидает, что volatile сделает код потокобезопасным сам по себе; он специально спрашивает о необходимых дополнительных материалах. Здесь ожидается, что volatile заставит компилятор вести себя предсказуемо. Его присутствие здесь сильно говорит о том, что у писателя правильная интуиция. Отклонение Q на основе volatile очень неправильно.   -  person curiousguy    schedule 06.09.2019
comment
@curiousguy: Автор здесь явно не ожидает, что volatile сделает код потокобезопасным сам по себе Учитывая, что первый код (не atomic версия) очень сильно ожидает, что volatile сделает код потокобезопасным , я бы не стал делать такое предположение. Тот факт, что пользователь сохранил volatile в версии atomic, также подтверждает идею о том, что ОП, вероятно, не понимает, что это не помогает.   -  person Nicol Bolas    schedule 06.09.2019
comment
@dervih: Ниже вы найдете гораздо более полезный пример: Это не то, что вы можете реализовать без настоящего мьютекса или спин-блокировки. Эти барьеры должны предотвращать одновременное выполнение, чтобы код внутри try_get_sum не выполнялся, пока memcpy все еще выполняется. И чтобы memcpy не начал выполняться, пока другой поток читает данные.   -  person Nicol Bolas    schedule 06.09.2019
comment
Более удобный пример — SeqLock. Для ОДНОЙ записи можно реализовать несколько считывателей без блокировки, но я думаю, что гораздо лучше пойти дальше и блокировать запись с помощью мьютекса. Если есть только один пишущий блок, за блокировку никогда не будут бороться, поэтому это относительно бесплатно и предотвращает проблемы, если когда-либо возникнет несколько писателей. Поищите в Интернете SeqLock, чтобы найти много информации и ряд реализаций.   -  person ttemple    schedule 07.09.2019
comment
@NicolBolas Я полностью осознаю, что «volatile» не предназначено для многопоточности и никогда не было. Однако малоизвестно, что «volatile» не только обязывает компилятор уважать каждое чтение и запись, но и не переупорядочивать окружающий код, который может иметь видимый побочный эффект (en.cppreference.com/w/cpp/language/cv) Здесь я использовал volatile, поскольку я смешивал доступ к объекту std::atomic с неатомарным объектов и хотел быть на 100% уверенным, что компилятор (не ЦП) не изменит их порядок.   -  person dervih    schedule 09.09.2019
comment
@dervih: операции упорядочивания памяти предотвращают переупорядочивание, даже/особенно неатомарных доступов вокруг атомарных. Если вы пишете в неатомарное, то выполните атомарную запись с освобождением порядка памяти, приобретите операции, которые атомарные, которые видят ваше записанное значение, также увидят любые записи, которые вы сделали до выпуска в объекты кроме атомного. Вот для чего нужны приказы памяти; они контролируют видимость вещей, отличных от атома, из которого вы извлекли.   -  person Nicol Bolas    schedule 09.09.2019
comment
@NicolBolas Вы правы, но только когда речь идет о сценариях использования, охватываемых стандартом C ++ 11, обычно для семантики выпуска-приобретения критической секции. Но в моем случае я хотел предотвратить переупорядочивание операций записи после выпуска перед выпуском и загрузок до получения после получения. И, к счастью, этого можно достичь с помощью 'std::atomic_thread_fence', но все операции до и после должны выполняться на 'std::atomic's' для соответствия стандарту C++11. Я нашел исчерпывающий ответ на свою проблему по адресу: hpl. hp.com/techreports/2012/HPL-2012-68.pdf   -  person dervih    schedule 09.09.2019
comment
@DanielLangr Вы хотя бы согласны с тем, что состояние гонки на изменчивом объекте безвредно, поскольку ЦП не заботится о гонках данных? (если вы не ожидаете большего, чем гарантирует процессор)   -  person curiousguy    schedule 12.09.2019
comment
@curiousguy Цитата из этого ответа: volatile (почти) бесполезен для независимого от платформы многопоточного программирования приложений. Он не обеспечивает никакой синхронизации, не создает ограждений памяти и не обеспечивает порядок выполнения операций. Он не делает операции атомарными. Это не делает ваш код волшебным образом потокобезопасным. volatile может быть единственным неправильно понятым средством во всем C++. Другой ответ по этой теме.   -  person Daniel Langr    schedule 13.09.2019
comment
@DanielLangr Противоречит ли это тому, что я утверждал? Операция, которая является атомарной на уровне ЦП (например, сохранение размера слова), является атомарной, когда выполняется как изменчивая скалярная операция записи, потому что абстракция C/C++ разрешается в инструкции сборки ЦП, и проблема гонки данных исчезает, потому что ЦП не заботится о них. Так что это не бесполезно; но это редко полезно и почти никогда не достаточно из-за того, что вы написали.   -  person curiousguy    schedule 13.09.2019
comment
@curiousguy Вы уверены, что хранение и загрузка памяти являются атомарными на всех архитектурах, когда можно скомпилировать и запустить программу на C++?   -  person Daniel Langr    schedule 13.09.2019
comment
@DanielLangr Ни одна программа не гарантирует переносимость на все архитектуры. Все процессоры, которые я знаю, загружают и сохраняют размер слова гарантии (для скалярной переменной).   -  person curiousguy    schedule 13.09.2019
comment
@curiousguy Не все архитектуры, а все архитектуры, в которых C++ реализован в соответствии со стандартом C++.   -  person Daniel Langr    schedule 13.09.2019
comment
@DanielLangr Нет реального мира, полезная программа переносима на все архитектуры, где C ++ реализован в соответствии со стандартом C ++ (который, вероятно, равен нулю, поскольку я не верю, что может существовать такая вещь, как реализация C ++ std).   -  person curiousguy    schedule 13.09.2019


Ответы (3)


Согласно статье о порядке_памяти, чтобы быть консервативно безопасным, вам нужно использовать memory_order_release после хранилище и memory_order_acquire перед загрузкой (оба одной и той же атомарной переменной).

So:

 std::atomic<int> var;

 // Writer
 // something important <happens-before> writing 42 in the writer thread
 var.store(42, std::std::memory_order_release);

 // Reader
 auto result = var.load(std::std::memory_order_acquire);
 if (result == 42) {
    // transitively, as the result's new value is observed, the "something important" is here too
 }

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

Как правило, вы предпочитаете std::atomic_flag вместо std::atomic<bool>, так как первый гарантированно будет заблокирован -бесплатно, в отличие от последнего.

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

person bobah    schedule 05.09.2019
comment
Однако std::atomic_flag неприятна из-за того, что требует ATOMIC_FLAG_INIT. - person Max Langhof; 05.09.2019
comment
@MaxLanghof - да, бесплатного сыра нет - person bobah; 05.09.2019
comment
Нет, в целом я бы предпочел std::atomic‹bool› вместо std::atomic_flag, поскольку я не хочу, чтобы в моем коде использовались медленные избыточные атомарные инструкции только для загрузки и сохранения значений. - person dervih; 05.09.2019
comment
@bobah Я не думаю, что вы поняли проблему, которую я описал. В моем случае невозможно заставить работать объект std::atomic вместе с барьерами освобождения-приобретения. - person dervih; 09.09.2019

Ниже вы найдете гораздо более полезный пример:

Позвольте мне повторить, что вы по существу делаете.

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

С++ не позволяет вам это сделать.

Если вы читаете из неатомарного объекта, который потенциально может быть записан каким-то другим потоком, без надлежащего выполнения и синхронизации памяти между двумя операциями, то у вас есть гонка данных. Наличие гонки данных не означает, что вы можете считать неверные значения; это означает, что ваш код имеет неопределенное поведение.

Вы не можете отменить УБ. Как только ваша программа входит в зону неопределенного поведения, все ставки сняты. По крайней мере, что касается стандарта.

Конечно, вы можете переписать свой код, чтобы он работал в рамках стандарта. Но он должен предотвращать выполнение чтения, пока может происходить запись, а не просто проверять постфактум, чтобы убедиться, что чтение в порядке. Если размер записи всегда составляет 1 КБ, для функции записи, вероятно, подойдет спин-блокировка на основе атомарных данных, и читатель может просто вернуть -1, если атомарная блокировка недоступна.

Вы также можете написать такую ​​систему (по-видимому, называемую "SeqLock"), используя C++ atomics, с полным пониманием того, что он вызывает UB в отношении стандарта. Пока типы, которые вы копируете, тривиальны, все будет работать нормально.

Обратите внимание, что C++ Concurrency TS 2 будет включать функцию, позволяющую реализовывать SeqLock. Мы надеемся увидеть полный стандарт C++23.

person Nicol Bolas    schedule 09.09.2019
comment
На 100% верно, и я бы добавил, к сожалению, потому что такая конструкция работает на всем известном мне оборудовании, включая Intel, Sparc, PowerPC, и является основой SeqLock, используемого в ядре Linux, а также самых быстрых алгоритмов для программной транзакционной памяти. Обратите внимание, что единственная неправильная вещь - это параллельный доступ к данным за пределами std::atomic, который С++ 11 намеренно превратил в неопределенное поведение, следуя теоретической модели памяти и экзотическому оборудованию, имеющему мало общего с передовыми приложениями С++. Более того, в моем примере многие из предыдущего многопоточного кода C++11 стали некорректными. - person dervih; 09.09.2019
comment
@dervih: ни один из ваших примеров никогда не был правильным; они были только тем, что тебе сошло с рук. То есть это были вещи, которые компилировались в машинный код, который, казалось, делал то, что вы хотели. Это так же верно как после C++11, так и до C++11. - person Nicol Bolas; 09.09.2019
comment
Но не кажется ли вам, что на самом деле это провал Комитета по стандартизации C++, что до C++11 практически весь многопоточный код зависел от компиляторов и оборудования для правильной работы, а с C++11 мало что изменилось, особенно когда дело доходит до к существующим высокопроизводительным/малым задержкам/масштабируемым приложениям. C++ выбран для приложений не потому, что это хороший язык, а потому, что он позволяет получить от машины 100% мощности. Поэтому печально, что некоторые высокопроизводительные методы сталкиваются с неопределенным поведением. В любом случае, очень рад, что в С++ наконец-то появилась какая-то модель памяти. - person dervih; 09.09.2019
comment
@dervih: с C++11 мало что изменилось Гм, мне это не нравится. До C++11 вы не могли вообще писать многопоточный код с четко определенным поведением в соответствии со стандартом. Возможность писать что-то четкое — это улучшение. Он может не разрешать все и, конечно же, не делает ваш старый специальный код правильным, но это не значит, что мало что изменилось. Даже этот конкретный вопрос в настоящее время изучается для Исправление C++23 с Concurrency TS 2.0. - person Nicol Bolas; 09.09.2019
comment
ОТЛИЧНО :) Кажется, что спекулятивное чтение имеет шанс стать законным в C++ в конце концов, и все эти быстрые алгоритмы программной транзакционной памяти, полагающиеся на них, больше не будут осуждаться. Вы сделали мне день, так как теперь у меня есть аргумент, что такой подход всерьез считается прямым приемом программирования. СПАСИБО! - person dervih; 09.09.2019
comment
Почти все программы так или иначе имеют УБ; вы можете возразить, что ни одна программа никогда не определяла поведение. Важен тип УБ. - person curiousguy; 12.09.2019
comment
@curiousguy Почти все программы так или иначе имеют UB; Что? Вы имеете в виду, что во всех программах есть какие-то ошибки? Или даже то, что хорошо написанные программы не имеют определенного поведения? Почему? Именно поэтому у нас есть Стандарт, чтобы определить поведение программ, написанных по его правилам. - person Daniel Langr; 13.09.2019
comment
@DanielLangr Как вы определили поведение в программе машинного перевода? Вы не можете ни в C, ни в C++. Это невозможно, так как эти программы не являются последовательными, а C и C++ имеют UB (в отличие от Java). Кроме того, программы, обращающиеся к базовым классам, не имеют определенного поведения, поскольку указатели, преобразованные в базовые, по-прежнему указывают на производный объект. Также программы, которые используют строковые литералы, поскольку эти объекты char никогда не создаются (их время жизни не начинается). - person curiousguy; 13.09.2019
comment
@curiousguy Не понимаю. Почему программа машинного перевода не имеет определенного поведения? Не могли бы вы привести какой-нибудь пример? Стандарт C++ четко определяет поведение хорошо написанной программы МП. Не могли бы вы также привести пример для второго случая? Существуют четко определенные случаи преобразования указателей в базовые и производные объекты. - person Daniel Langr; 13.09.2019
comment
@DanielLangr 1) Пожалуйста, покажите, что вы можете доказать правильность любой программы MT на C или C ++. Вы не можете, из-за возможного UB. Вы никогда не сможете исключить УБ: люди просто предполагают, что УБ не было. Вы не можете рассуждать о программах машинного перевода на языке с UB. 2) Пожалуйста, покажите, где преобразование ptr в base имеет четко определенное полезное значение: на что указывает преобразованный ptr ? - person curiousguy; 13.09.2019
comment
@curiousguy Любая (MT) программа, написанная в соответствии со стандартными правилами C++. UB означает несоблюдение этих правил. Что касается второго случая, указатель на базу может, например, указывать на базовый подобъект производного объекта. Что здесь неопределенного? - person Daniel Langr; 13.09.2019
comment
@DanielLangr 1) Тогда покажите доказательство программы. Такого нет. Не существует правила, которое можно использовать, чтобы избежать UB. У вас не может быть правильной программы, потому что вы не можете исключить UB в семантике MT (даже с одним потоком). MT означает непоследовательность, и вы ничего не можете гарантировать. 2) Где четко определено базовое преобразование ptr? - person curiousguy; 13.09.2019
comment
@curiousguy Здесь очень четко: eel.is/c++draft/ conv.ptr#3.sentence-1. Для программ машинного перевода доказательством является Стандарт. На самом деле я совершенно не разделяю ваших выводов и мне кажется, что эта дискуссия не имеет смысла. Тем не менее, я бы очень хотел, чтобы вы написали отдельный вопрос о том, что вы говорите. Было бы очень интересно узнать мнение других. - person Daniel Langr; 13.09.2019
comment
@DanielLangr Ваше заявление о том, что стандарт является доказательством, неверно: в стандартном стандарте нет ни одного доказательства правильности программы, даже для тривиальной программы. Если я задам очень сложный вопрос, он все равно будет удален. - person curiousguy; 13.09.2019
comment
@curiousguy Не будет. Ваш вопрос никто не удалит. Вы рискуете только минусами или он закрывается. Впрочем, если вы так в этом уверены, бояться не стоит. Если вы правы, вы получите много голосов. Было бы огромным прорывом в C++ доказать, что не существует программы с определенным поведением. - person Daniel Langr; 13.09.2019

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

std::atomic<bool> valid{true};           // removed volatile
uint8_t blob[1024] = {/*some values*/};  // removed volatile

void zero_blob() {
    valid.store(false, std::memory_order_relaxed);            // A)
    std::atomic_thread_fence(std::memory_order_release);      // B)
    memset(blob,0,1024);                                      // C)
}                                                              

int32_t try_get_sum(size_t index_1, size_t index_2) {          
    uint8_t res = blob[index_1] + blob[index_2];              // D)
    std::atomic_thread_fence(std::memory_order_acquire);      // E)
    return valid.load(std::memory_order_relaxed) ? res : -1;  // F)
}

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

Забор на шаге E) гарантирует соблюдение порядка выполнения программы.

Таким образом, A) межпотоковое взаимодействие происходит раньше, чем C) и D) происходит раньше, чем F)

Если F) воспринимает, что valid является true, тогда, при условии отсутствия проблемы ABA, F) происходит раньше, чем A).

Если F) происходит раньше, чем A), то D) происходит раньше, чем A).

Если F воспринимает, что valid есть false, то A) происходит раньше F). Это не обязательно означает, что C) происходит раньше D), но на всякий случай мы отбрасываем результат. Это действительно важно отбросить, потому что значения в blob могут быть совершенно недействительными. (Хотя использование uint8_t защищает от частичного чтения при преобразовании в void*. Он выровнен по кешу, но технически не имеет размера слова, что ставит под сомнение атомарность. IIRC теоретически также может быть уязвим для истинного частичного чтения на некоторых маловероятных архитектурах. .)

Эти выводы основаны на модели согласованности памяти ядра Linux (LKMM), а не на модели памяти C++. Из того, что я прочитал на cppreference.com, когда они говорят о порядке распространения в модели памяти C++, они используют такие фразы, как «все изменения до X в потоке A будут видны после Y в потоке B», но обычно только в контекст атомарных операций. Я думаю, что ожидать, что компилятор C++ будет выдавать инструкции, гарантирующие порядок распространения, относительно мало рискованно, по крайней мере, на процессорах потребительского уровня. Вы все равно должны проверить сборку и свериться с документацией по процессору. Вы определенно будете в порядке на x86 благодаря TSO, но я никогда не смотрел, какие примитивы порядка распространения доступны в слабоупорядоченных архитектурах.

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

person Humphrey Winnebago    schedule 08.09.2019