Как я могу показать, что энергозависимое задание не атоми c? - PullRequest
6 голосов
/ 29 февраля 2020

Я понимаю, что назначение не может быть атомом c в C ++. Я пытаюсь вызвать состояние гонки, чтобы показать это.

Однако мой код ниже, похоже, не вызывает ничего подобного. Как я могу изменить его так, чтобы он в конечном итоге вызывал состояние гонки?

#include <iostream>
#include <thread>

volatile uint64_t sharedValue = 1;
const uint64_t value1 = 13;
const uint64_t value2 = 1414;

void write() {
    bool even = true;
    for (;;) {
        uint64_t value;
        if (even)
            value = value1;
        else
            value = value2;
        sharedValue = value;
        even = !even;
    }
}

void read() {
    for (;;) {
        uint64_t value = sharedValue;
        if (value != value1 && value != value2) {
            std::cout << "Race condition! Value: " << value << std::endl << std::flush;
        }
    }
}

int main()
{
    std::thread t1(write);
    std::thread t2(read);
    t1.join();
} 

Я использую VS 2017 и компилирую в Выпуске x86.

Вот разборка этого назначения:

sharedValue = value;
00D54AF2  mov         eax,dword ptr [ebp-18h]  
00D54AF5  mov         dword ptr [sharedValue (0D5F000h)],eax  
00D54AFA  mov         ecx,dword ptr [ebp-14h]  
00D54AFD  mov         dword ptr ds:[0D5F004h],ecx 

Полагаю, это означает, что присвоение не атоми c? Кажется, что 32 бита копируются в универсальный 32-битный регистр eax, а остальные 32 бита копируются в другой 32-битный регистр ecx общего назначения перед копированием в sharedValue, который находится в регистре сегмента данных?

Я также пытался с uint32_t, и все данные были скопированы в один go. Итак, я думаю, что на x86 нет необходимости использовать std::atomic для 32-битных типов данных?

Ответы [ 4 ]

6 голосов
/ 02 марта 2020

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

Если вы спите, вы долго ждете, не создавая окна для этого. Спящий между половинками сделает его еще более легким для обнаружения, но вы не сможете сделать это, если не используете отдельный memcpy для записи половин 64-битного целого или чего-то еще.

Разрыв между чтениями в считывателе также возможен, даже если записи атоми c. Это может быть менее вероятно, но на практике все еще бывает достаточно. Современные процессоры x86 могут выполнять две загрузки за такт (Intel начиная с Sandybridge, AMD начиная с K8). Я тестировал с атомными c 64-битными хранилищами, но разбил 32-битные загрузки на Skylake, и разрывы все еще достаточно часты, чтобы извергать строки текста в терминале. Таким образом, ЦПУ не удалось запустить все в режиме блокировки с соответствующими парами чтений, всегда выполняющимися в одном и том же тактовом цикле. Таким образом, есть окно для считывателя, делающего его строку кэша недействительной между парой нагрузок. (Однако все ожидающие загрузки с отсутствием кэша, пока строка кэша принадлежит ядру модуля записи, вероятно, завершают все сразу, когда появляется строка кэша. И общее число доступных буферов загрузки является четным числом в существующих микроархитектурах.)


Как вы обнаружили, ваши тестовые значения имели одинаковую верхнюю половину 0, поэтому это сделало невозможным наблюдение каких-либо разрывов; изменялась только 32-битная выровненная младшая половина, и она изменялась атомарно, потому что ваш компилятор гарантирует как минимум 4-байтовое выравнивание для uint64_t, а x86 гарантирует, что 4-байтовые выровненные загрузки / хранилища имеют атомы c.

0 и -1ULL являются очевидным выбором. Я использовал то же самое в тестовом примере для этого G CC C11 _Atomi c ошибка для 64-битной структуры.

Для вашего случая я бы сделал это , read() и write() являются именами системных вызовов POSIX, поэтому я выбрал другое.

#include <cstdint>
volatile uint64_t sharedValue = 0;  // initializer = one of the 2 values!

void writer() {
    for (;;) {
        sharedValue = 0;
        sharedValue = -1ULL;  // unrolling is vastly simpler than an if
    }
}

void reader() {
    for (;;) {
        uint64_t val = sharedValue;
        uint32_t low = val, high = val>>32;
        if (low != high) {
            std::cout << "Tearing! Value: " << std::hex << val << '\n';
        }
    }
}

MSV C 19.24 -O2 компилирует программу записи с использованием movlpd 64-битного хранилища для = 0, но два отдельных 32-разрядных хранилища -1 для = -1. (И считыватель для двух отдельных 32-битных загрузок). G CC использует в записывающем устройстве в общей сложности четыре магазина mov dword ptr [mem], imm32, как и следовало ожидать. ( Исследователь компилятора Godbolt )

Терминология : это всегда состояние гонки (даже с атомарностью вы этого не делаете знаю, какое из двух значений вы собираетесь получить). С std::atomic<> у вас будет только то состояние гонки сорта сада, без неопределенного поведения.

Вопрос в том, действительно ли вы видите разрыв с неопределенной поведением гонки данных на volatile объект, на определенной c реализации C ++ / наборе параметров компиляции, для конкретной c платформы. Data race UB - это технический термин, имеющий более конкретное значение c, чем "условие гонки" . Я изменил сообщение об ошибке, чтобы сообщить об одном признаке, который мы проверяем. Обратите внимание, что гонка данных UB на объекте, отличном от volatile, может иметь более странные эффекты, такие как размещение нагрузки или сохранение вне циклов, или даже создание дополнительных операций чтения, приводящих к коду, который считает, что одно чтение было одновременно истинным и ложным время. (https://lwn.net/Articles/793253/)

Я удалил 2 избыточных cout сброса : один из std::endl и один из std::flush. cout является буферизованным по умолчанию или полностью буферизованным, если записываете в файл, что нормально. И '\n' столь же переносим, ​​как и std::endl, что касается концов линии DOS; текст против двоичного режима потока обрабатывает это. endl по-прежнему просто \n.

Я упростила вашу проверку на разрыв, проверив, что high_half == low_half . Тогда компилятору просто нужно выдать один cmp / j cc вместо двух сравнений с расширенной точностью, чтобы увидеть, равно ли это значение 0 или -1. Мы знаем, что нет вероятного способа, чтобы ложные негативы, такие как high = low = 0xff00ff00, происходили на x86 (или любом другом стандартном ISA с любым здравомыслящим компилятором).


Так что я думаю, что на x86 нет необходимости использовать std :: atomi c для 32-битных типов данных?

Неправильно .

Ручная прокрутка с volatile int может не дает вам операций Atom c RMW (без встроенного ассемблера или специальных функций, таких как Windows InterlockedIncrement или GNU C встроенный __atomic_fetch_add), и не может дать вам никаких гарантий заказа в отношении. другой код. (Освободить / приобрести семантику)

Когда использовать volatile с многопоточностью? - почти никогда.

Свертывание собственной атомики с помощью volatile - это все еще возможно и де-факто поддерживается многими основными компиляторами (например, ядро ​​Linux все еще делает это, наряду со встроенным asm). Реальные компиляторы действительно определяют поведение гонок данных на volatile объектах. Но обычно это плохая идея, когда есть портативный и гарантированный безопасный способ. Просто используйте std::atomic<T> с std::memory_order_relaxed, чтобы получить asm, который так же эффективен, как то, что вы могли бы получить с volatile (для случаев, когда volatile работает), но с гарантиями безопасности и корректности из стандарта ISO C ++.

atomic<T> также позволяет вам спросить реализацию, может ли данный тип быть дешевым атомом c или нет, с C ++ 17 std::atomic<T>::is_always_lock_free или более старой функцией-членом. (На практике реализации C ++ 11 решили не позволять некоторым, но не всем экземплярам любого данного атома c быть свободными от блокировки на основе выравнивания или чего-то еще; вместо этого они просто дают атому c требуемые выравнивания, если таковые имеются. C ++ 17 сделал постоянную константу для каждого типа вместо функции-члена для каждого объекта, чтобы проверить свободу блокировки).

std::atomic также может дать дешевую атомарность без блокировки для типов шире, чем нормальный регистр . например, на ARM, используя ARMv6 strd / ldrd для хранения / загрузки пары регистров.

На 32-битном x86 хороший компилятор может реализовать std::atomic<uint64_t>, используя SSE2 movq для выполнения Atomi c 64-битные загрузки и сохранения без возврата к механизму non-lock_free (таблица блокировок). На практике G CC и clang9 используют movq для atomic<uint64_t> load / store . clang8.0 и более ранние версии, к сожалению, lock cmpxchg8b. MSV C использует lock cmpxchg8b еще более неэффективно. Измените определение sharedVariable в ссылке Godbolt, чтобы увидеть его. (Или если вы используете одно из значений по умолчанию seq_cst и memory_order_relaxed в l oop, MSV C по какой-то причине вызывает вспомогательную функцию ?store@?$_Atomic_storage@_K$07@std@@QAEX_KW4memory_order@2@@Z для одного из них. Но когда оба хранилища имеют одинаковый порядок, он встраивает блокировку cmpxchg8b с гораздо более грубыми циклами, чем clang8.0) Обратите внимание, что этот неэффективный код MSV C предназначен для случая, когда volatile не был атомарным; в случаях, когда это так, atomic<T> с mo_relaxed тоже хорошо компилируется.

Обычно вы не можете получить этот широко-атомный c код-ген из volatile. Хотя G CC действительно использует movq для вашей функции записи if () bool (см. Более раннюю ссылку на проводник компилятора Godbolt), потому что он не может видеть переменную или что-то еще. Это также зависит от того, какие значения вы используете. С 0 и -1 он использует отдельные 32-битные хранилища, но с 0 и 0x0f0f0f0f0f0f0f0fULL вы получаете movq для пригодного для использования шаблона. (Я использовал это, чтобы убедиться, что вы все еще можете получить разрыв только со стороны чтения, вместо того, чтобы писать что-нибудь вручную.) Моя простая развернутая версия компилируется для использования простых mov dword [mem], imm32 хранилищ с G CC. Это хороший пример нулевой гарантии того, как volatile действительно компилируется на этом уровне детализации.

atomic<uint64_t> также гарантирует 8-байтовое выравнивание для объекта atomi c, даже если обычный uint64_t может быть выровнен только на 4 байта.


В ISO C ++ гонка данных на объекте volatile по-прежнему не определена. (За исключением volatile sig_atomic_t гонок с обработчиком сигнала.)

«Гонка данных» - это каждый раз, когда происходит два несинхронизированных доступа, и они не оба считываются. ISO C ++ допускает возможность запуска на машинах с аппаратным определением гонки или чем-то подобным; на практике ни одна из основных систем не делает этого, поэтому результат просто разрывается, если летучий объект не «естественно атоми c».

ISO C ++ также теоретически позволяет работать на машинах, которые не имеют связного разделяемая память и требует ручного сброса после хранения atomi c, но на практике это не реально. Реальные реализации не такие, AFAIK. Системы с ядрами, которые имеют некогерентную разделяемую память (например, некоторые ARM SoC с ядрами DSP + ядра микроконтроллера) не запускают std :: thread через эти ядра.

См. Также Почему целочисленное назначение включено естественно выровненная переменная atomi c на x86?

Это все еще UB, даже если вы не наблюдаете разрывы на практике, хотя, как я сказал, настоящие компиляторы де-факто определить поведение volatile.


Эксперименты Skylake, чтобы попытаться обнаружить объединение буфера хранилища

Интересно, может ли объединение хранилища в буфере хранилища создать атоми c 64- передача битов в кэш L1d из двух отдельных 32-битных хранилищ. (Пока никаких полезных результатов, оставляя это здесь, на случай, если кто-то заинтересован или хочет его использовать.)

Я использовал встроенную для читателя GNU C __atomi c, поэтому, если магазины также закончились Будучи атомом c мы бы не увидели разрывов.

void reader() {
    for (;;) {
        uint64_t val = __atomic_load_n(&sharedValue, __ATOMIC_ACQUIRE);
        uint32_t low = val, high = val>>32;
        if (low != high) {
            std::cout << "Tearing! Value: " << std::hex << val << '\n';
        }
    }
}

Это была одна попытка заставить микроархитектуру сгруппировать магазины.

void writer() {
    volatile int separator;  // in a different cache line, has to commit separately
    for (;;) {
        sharedValue = 0;

        _mm_mfence();
        separator = 1234;
        _mm_mfence();
        sharedValue = -1ULL;  // unrolling is vastly simpler than an if

        _mm_mfence();
        separator = 1234;
        _mm_mfence();
    }
}

Я все еще вижу разрывы с этим. (mfence в Skylake с обновленным микрокодом аналогичен lfence и блокирует неиспользуемый exe c, а также очищает буфер хранилища. Поэтому более поздние хранилища даже не должны входить в буфер хранилища до того, как последующие покинут Это может быть проблемой, потому что нам нужно время для слияния, а не только для фиксации 32-битного хранилища, как только оно «завершится», когда магазин выйдет из эксплуатации). Оцените разрывов и посмотрите, реже ли это с чем-либо, потому что разрывов вообще достаточно, чтобы спамить окно терминала с текстом на машине с частотой 4 ГГц.

4 голосов
/ 29 февраля 2020

Я изменил код на:

volatile uint64_t sharedValue = 0;
const uint64_t value1 = 0;
const uint64_t value2 = ULLONG_MAX;

и теперь код вызывает состояние гонки менее чем за секунду. Проблема была в том, что и 13, и 1414 имеют 32 MSB = 0.

13=0xd
1414=0x586
0=0x0
ULLONG_MAX=0xffffffffffffffff
4 голосов
/ 29 февраля 2020

Возьмите разборку, а затем проверьте документацию для вашей архитектуры; на некоторых машинах вы обнаружите, что даже стандартные операции «не Atomi c» (в терминах C ++) на самом деле являются атомами c, когда он касается оборудования (в терминах сборки).

При этом Тем не менее, ваш компилятор будет знать, что является и что не безопасно, и поэтому лучше использовать шаблон std::atomic, чтобы сделать ваш код более переносимым между архитектурами. Если вы работаете на платформе, которая не требует ничего особенного, она в любом случае будет оптимизирована до примитивного типа (без учета порядка памяти).

Я не помню подробностей операций x86 от руки, но я бы предположил, что у вас есть гонка данных, если 64-битное целое число записывается в 32-битных "кусках" (или меньше); в этом случае можно разорвать чтение.

Существуют также инструменты, называемые дезинфицирующим средством для потока, чтобы поймать его в действии. Я не верю, что они поддерживаются на Windows с MSV C, но если вы можете заставить работать G CC или clang, тогда вам может повезти. Если ваш код переносим (выглядит так), вы можете запустить его в Linux системе (или ВМ), используя эти инструменты.

1 голос
/ 29 февраля 2020

Во-первых, ваш код имеет состояние гонки данных при чтении и записи в переменную sharedValue без какой-либо синхронизации, что является неопределенным поведением в C ++. Это можно исправить, установив sharedValue переменную atomi c:

std::atomic<uint64_t> sharedValue{1};

Вы можете запустить условие логической гонки, используя искусственную задержку перед записью в sharedValue ("Условие гонки! Значение: ... "сообщение будет напечатано в ветке читателя). Вы можете использовать std::this_thread::sleep_for для этого:

void write() {
    bool even = true;
    for (;;) {
        uint64_t value;
        if (even)
            value = value1;
        else
            value = value2;

        using namespace std::chrono_literals;
        std::this_thread::sleep_for(1ms);

        sharedValue = value;
        even = !even;
    }
}
...