Здравствуйте, Евгений Музыченко, Вы писали:
ЕМ>Люди, которые не хотят изучать ничего нового, являются, как правило, клиентами психиатров. А нежелание изучать новое, несущее больше геморроя, чем пользы, просто потому, что оно ново — вполне разумная тактика управления личными ресурсами. Далеко не все востребованные продукты изготовлены с применением новейших технологий.
Речь не об использовании новейших технологий, а о том, что нужно понимать, что они возникают
не на пустом месте просто чтоб "быть новыми", а потому, что существует реальная насущная потребность
в них. Сколько-то там лет назад многоядерность была экзотикой, а сейчас в настольном компьютере
20 ядер. И появились средства определённые подходы к програмированию таких систем, о которых
хотя бы поверхностно нужно знать.
ЕМ>>>И как это связано с тривиальностью/нетривиальностью?
fk0>>Так, что невозможно компилятор сделать, чтоб он удовлетворял требованиям по обращению с volatile-переменными.
ЕМ>Не вижу ровно никаких причин для невозможности.
Я ж только что написал, что (N)RVO требуемые стандартом не выполнимы для классов с volatile переменными,
так как два раздела стандарта вступают в противоречие.
fk0>>Их, например, через memcpy() даже копировать нельзя. Потому, что в библиотеке сильно-оптимизированный вариант
fk0>>функции memcpy()
ЕМ>При чем здесь библиотека и функция memcpy? Под тривиальностью всегда понималась возможность сгенерировать код, который не обращается к методам класса, не использует виртуальности и т.п. То есть, тривиальность определяется по отсутствию вовлечения языковых средств, находящихся выше генератора кода. В частности, тривиальными являются конструкторы POD. POD с квалификатором volatile тоже всегда относился к тривиальным. Потому я и удивился, увидев, что VC++ 19.x вдруг стал считать их нетривиальными.
Мотивировка в том, это же очевидно, что для каждого класса с volatile членом нужна генерация уникального
конструктора копирования, уникального конструктора перемещения. Уникального для данного класса. Для обычного
класса, без volatile-переменных, с POD-членами, попросту вызывается memcpy, который даже тут же инлайнится
в месте использования. А в случае с классом у которого внутри volatile -- нужен вызов конкретной функции,
специфичной для данного класса. Что это как не тривиальный конструктор?
fk0>>хрен знает как разорвёт блок памяти по словам, что-то скопирует по байтам, что-то по длинным словам (через FPU, векторные расширения).
ЕМ>Всем этим традиционно занимается генератор кода. При таком подходе нужно заодно объявить нетривиальными и float/double, поскольку у многих процессоров вообще нет никакой аппаратной плавучки, и все операции преобразуются в неявные вызовы функций, а в многопроцессорных системах с общим процессорам плавучки нужны еще и приседания с синхронизацией доступа и запретом прерываний.
Переменные с плавающей точкой на любой архитектуре совершенно обыкновенным образом лежат в памяти
и никаких специальных мер по обращению с ними не требуется. То, что там операционная система при переключении
контекстов вынимает из стека математического сопроцессора числа и сохраняется где-то в структуре контекста
потока -- совершенно не имеет никакого отношения к компилятору. Компилятор класс с float переменными
может скопировать с помощью обычной memcpy. А сопроцессор использует как будто он использует его монопольно
и существует только один поток.
fk0>>Буквально, скопировать объект с volatile-переменной -- нетривиальная задача. Для этого нужно сгенерировать специальную функцию, которая умеет работать с этим объектом, будет знать там offset для volatile-переменной и её скопирует аккуратно одной инструкцией.
ЕМ>Во-первых, это типовая задача кодогенератора, с нею успешно справлялись во все времена.
Не типовая, так как требуется конструктор копирования специфичный для данной функции. Тривиальный
конструктор копирования -- это тот, который не сгенерирован для конкретного класса.
TriviallyCopyable objects can be copied by copying their object representations manually, e.g. with std::memmove. All data types compatible with the C language (POD types) are trivially copyable.
Так вот очевидно, что класс с volatile-переменными невозможно скопировать с помощью memmove.
Это следует из определения volatile-переменной:
Any attempt to refer to a volatile object through a glvalue of non-volatile type (e.g. through a reference or pointer to non-volatile type) results in undefined behavior.
ЕМ> Во-вторых, откуда взялось требование "аккуратно одной инструкцией"? volatile-переменные никогда не заявлялись атомарными, для них гарантировалось всего лишь неприменение отдельных оптимизаций.
Volatile переменная тем не менее не может считываться компилятором по два раза вместо одного (что произойдёт при побайтовом
копировании, например). Типичный пример -- SFR-регистры, которые изменяют состояние периферии при считывании. Да у них ширина
в машинное слово и их нужно читать одной инструкцией. И нельзя читать два раза если у компилятора не хватает регистров и он
соптимизировал вникуда локальную переменную в которой хранится копия.
По факту чтение volatile запросто атомарно до тех пор, пока такая переменная выровненная и имеет соответствующий размер.
fk0>>The problem with this is that a volatile qualified type may need to be copied in a specific way (by copying using only atomic operations on multithreaded platforms, for example) in order to avoid the “memory tearing” that may occur with a byte-by-byte copy.
ЕМ>Вы таки определитесь — то ли volatile "не атомарно", "не является барьером" и якобы вообще не имеет смысла, и тогда для него не нужны никакие дополнительные техники (а лишь отказ от того, что охотно используется для остального), или же это вполне себе рабочий инструмент.
Изменение volatile-переменной -- не атомарно. RISC-процессоры вообще не имеют инструкций (кроме SWAP на ARM) для
чтения-модификации-записи в одной инструкции, например... Чтение или запись де-факто в определенных обстоятельствах --
атомарно и этим часто пользуются.
Смысла процитированной фразы я не смог понять. Volatile имеет смысл для того, для чего предназначен -- для работы
с SFR-регистрами преимущественно (чтение или запись которых изменяет состояние аппаратуры). Volatile позволяет
гарантировать, что обращение к переменной произойдёт ровно так как написано в коде. Ровно один раз, например,
а не два или три подряд (потому, что компилятор сгенерировал такой код, потому, что регистровому аллокатору не
хватило регистров).
Volatile не предназначен для синхронизации потоков и ничего для этого не предоставляет. И уж тем более не предназначен
для синхронизации процессоров с общим полем памяти.
Для синхронизации потоков нужно заставить компилятор физически произвести запись в память в нужное время, в нужном
месте. Volatile этого не гарантирует (повторю, что компилятор может запросто "оптимизировать" логику так, что все
volatile переменные заипишет либо раньше времени, либо позже, относительно записи не-volatile ячеек памяти, относительно
других инструкций процессора, включая запрет прерываний, и т.п.) Для упорядочивания обращений к памяти со стороны
компилятора существует понятие компиляторного барьера памяти, чем является выражение вида asm("nop" ::: "memory) в GCC,
или даже просто вызов любой функции (кроме случая static-функции...) Компилятор должен записать всё в память перед барьером,
и чтения-записи которыев программе после барьера они будут сделаны точно после.
Для синхронизации процессоров вовсе нужны специальные инструкции, обеспечивающие синхронизацию кешей (иначе отдельные
строки кеша синхронизируются когда-нибудь потом и в каком попало порядке).
Volatile всего этого -- не даёт. Использование volatile скорей имеет корни в том, что есть такой частый паттерн,
когда запускается поток, в котором в цикле выполняется какая-то работа, и цикл прерывается по переменной-флагу.
И компилятор для обычной, не-volatile переменной попросту оптимизирует цикл в бесконечный. Так как видит, что
переменная не может никак быть изменена. По идее, если переменная-флаг может использоваться хоть в какой-то другой функции,
то такой оптимизации происходить не может. Однако если весь код состоит из static-переменных или функций и компилятор
построив граф вызовов понимает, что переменная изменена быть не может -- вправе выполнить такую оптимизацию.
И способ избежать такой оптимизации -- использовать volatile. Либо использовав memory barrier, либо отметить только
одну проблемную переменную, как в примере по ссылке:
https://godbolt.org/z/37hfeY76M
И если для описанного случая volatile как-то справляется со своей работой, то для случая когда есть хотя бы
две переменные обращение к котором нужно синхронизировать -- уже ничего не получится. Да, можно вообще всё подряд
сделать volatile и правильный порядок обращения гарантирован, казалось бы. Но порядок синхронизации кешей процессора
остаётся каким попало и на многопроцессорной машине такое всё равно не заработает.
Volatile просто не средство для синхронизации потоков и процессоров, вот и всё что следует знать.
Для синхронизации потоков есть барьеры, для синхронизации процессоров -- специальные инструкции процессора.
И в целом это всё вручную делать обычно нет необходимости, так как существуют, предоставляемые C/C++ библиотекой
более высокоуровневые примитивы синхронизации, такие как мьютексы (являющиеся барьерами с нужной acquire/release
семантикой) и атомарные переменные. Которые просто "работают из коробки". И их следует в нормальном случае
использовать. А код с volatile -- это нечто странно выглядящее и таящее в себе неведомые сюрпризы.
Здравствуйте, Максим, Вы писали:
М>Ну так да. Самый простой сценарий. Есть пременная flag которая в одном потоке читается, что-то в духе
М>М>while(flag) {...}
М>
М>а во втором изменяется
М>М>flag = false
М>
М>Так вот, изменяя переменную во втором потоке, мы не знаем когда эти изменения "прилетят" в первый поток. Все зависит от того, где находится эта самая переменная, когда будут сброшены процессорные кеши итд.
Я кстати, видел именно такой код с остановкой цикла по выставлению флага. Эта конструкция использовалась для корректного завершения программы (останавливаем все потоки, освобождаем все ресурсы). И оно много лет прекрасно работало (возможно и сейчас работает), компилировалось это дело сначала разными версиями gcc потом clang, но только под x86. Проблем никогда не возникало. Мне тогда очень хотелось заменить это на atomic, но мне сказали "работает не трогай!".
Я так понимаю, на x86 такой код не должен вызывать проблем. Да, изменяя переменную во втором потоке мы не знаем точно, когда изменения прилетят в первый поток, но они гарантированно прилетят, и это будет за небольшое время. Если у нас просто один флаг который нужен для остановки цикла, нам совсем не важен точный порядок записи чтения. Но вообще было бы интересно узнать, возможны ли какие-то реальные проблемы именно с этим кодом на x86? И какие реальные проблемы могут быть на других платформах (я никогда не работал с ARM, но правильно ли я понимаю, что там изменение переменной может так и не прилететь в читающий поток)?