Comments 21
Очень бы хотелось продолжения, т.к. нередко сталкиваюсь с последствиями недопонимания этих концепций в различном коде. А у вас очень хорошо получается объяснять.
Жду продолжения!
Мы отказываемся от слабого исполнения и обязываем процессор обновить глобальную память после первой инструкции (как я это понял). Как данная модификация сказывается на быстродействии?
Ну, asm volatile("mfence" ::: "memory")
— это как раз своего рода синхронизация и есть. Но да, это непереносимо и работает только на одном процессоре.
Я так понимаю здесь рассматривается именно реальное поведение на конкретном процессоре, т.к с точки зрения C++ просто нельзя писать в неатомарнвы переменные без синхронизации. Они вообще могли какую нить ерись выдать.
Вы правы, но я просто решил не упоминать atomic в первой статье. На самом деле, то же самое можно повторить, если сделать x и y atomic'ами и обращаться к ним с помощью memory_order_relaxed.
Модель C/C++ достоина отдельного вдумчивого поста)
У меня как минимум серьезные сомнения, что программист вправе делать предположения о внутреннем устройстве процессоров.
Да, прикладной программист по-хорошему должен работать с абстракцией, предоставляемой языком.
Другое дело — разработчик компилятора/операционной системы, который и занимается поддержанием данной абстракции.
Спасибо за очень ясную нотацию.
Вместо этого они отправляют запрос в локальный буфер записи, из которого он через некоторое время попадает в основную память.
Через какое? И может быть, что не попадет вовсе в гипотетическом случае, когда кроме наших 2-х потоков ничего более не выполняется?
Аппаратно — нет, такого быть не может, "спекулятивность" процессора ограничена длиной конвейера, да и кеши не для "застревания" данных проектируются...
А вот в C++ такое может произойти запросто, потому что компилятор имеет право передвинуть присваивание куда угодно или даже вовсе удалить, если докажет что присвоенное значение нигде не используется.
Через какое?
Через минимально возможное в данной ситуации. Это не то чтобы точные данные, но просто держать в этом буфере что-то лишнее время тупо бесполезно, и процессор его выливает (в цепочку кэши — память) так быстро, как возможно без порчи остальной работы (а если в этом буфере и так места нет, то его выливу предоставляется приоритет перед другими операциями с памятью).
Вам удавалось в тестах воспроизвести ситуацию нарушения SC именно из-за аппаратных оптимизаций x86?
Вам удавалось в тестах воспроизвести ситуацию нарушения SC именно из-за аппаратных оптимизаций x86?
Я воспроизвёл. i3-4170 и Ryzen 5 2400G.
по-идее, даже без перестановки строк компилятором студенты могут получить нарушение SC только из-за буферов записи.
тогда не совсем понятно, зачем автор вставляет cout.flush().
Это как раз понятно. Это не та же мысль, что барьеры процессора, но подвод к ней: вначале барьер в компиляторе, а потом показывается, что он недостаточен. Можно было, конечно, методически обойтись и без него — только для данного примера с буфером записи — но я думаю, что автор прав: барьеры компилятора тоже стоит упомянуть в этом контексте.
Компилятор может переставить две строки в thread1 (в показанном варианте кода без барьеров), но не обязан. А от чего это зависит — выглядит как погода на Марсе, но является результатом взаимодействия исходника, версии компилятора, опции сборки и т.п. Вот автор и нашёл эмпирический вариант, как это сделать. Не был бы cout — нашлось бы что-то другое.
Тема интересная, имеет смысл именно такие крайние тонкие случаи подробно рассматривать. Замечания по сказанному:
1. Есть разные уровни "слабости" моделей памяти. X86, например, не переупорядочивает чтения одного ядра (соответственно одной нити) между собой, аналогично записи, аналогично записи с более поздними чтениями… а большинство RISC может делать хотя бы часть из этого. Особенность с буфером записи, конечно, сильно изменяет обстановку, но не фатально для обычной синхронизации. Фактически, модель X86 следовало бы назвать сильной, а не слабой (как обычно и делают) — специфика буфера записи (или строковых команд) тут влияет на очень специфические случаи. Хотя по сравнению с SystemZ она, да, слабая :)
Но вот классический документ "Intel64 Architecture Memory Ordering" (318147) про store buffer не говорит ни слова, зато,
Stores are not reordered with older loads.
как бы намекает на то, что его такие действия недопустимы. Или это случай его пункта 2.4? Тут слишком легко запутаться, прошу подтверждений.
2. Имеет смысл добавить, что использовать asm тут не нужно: в C++11 есть стандартные переносимые эквиваленты:
asm("":::"memory") -> std::atomic_signal_fence(std::memory_order_acq_rel);
asm("mfence":::"memory") -> std::atomic_thread_fence(std::memory_order_seq_cst);
это даже без атомарных переменных.
3. А теперь интересное: если я в каждой нити вставлю синхронизацию по следующему типу:
void thread2() {
y = 1;
std::atomic_thread_fence(std::memory_order_acq_rel);
b = x;
}
то GCC (8, 9), Clang (6, 10) при -O0 сохраняют mfence, а при O уже нет! Им надо заменить acq_rel на seq_cst, чтобы сохранился mfence на всех уровнях оптимизации.
Я в первой версии этого комментария начал откровенно недоумевать, почему acq_rel по его мнению не должен включать все меры, чтобы выпихнуть предыдущие сбросы в память, если он знает про существование store buffer. Но, кажется, таки понятно: на обычную синхронизацию по типу мьютексов это не влияет. А вот с lock-free хитрее, за время доступного редактирования точно не успею обдумать во всех деталях. Прошу объяснений, кто может.
про store buffer не говорит ни слова
Недоредактировал. Говорит — в пункте 2.4, который надо отдельно грок.
PS: Понял. older loads — это которые раньше в потоке команд, а я почему-то подумал в обратную сторону. Да, порядка между более ранними записями и более поздними чтениями никто не обещал, его при необходимости надо требовать явно. Но сама необходимость такого типа как раз и выходит за рамки обычной mutex-style синхронизации.
Слабые модели памяти: буферизации записи на x86