Является ли функция вызова эффективным барьером памяти для современных платформ?

В кодовой базе, которую я рассмотрел, я нашел следующую идиому.

void notify(struct actor_t act) {
    write(act.pipe, "M", 1);
}
// thread A sending data to thread B
void send(byte *data) {
    global.data = data;
    notify(threadB);
}
// in thread B event loop
read(this.sock, &cmd, 1);
switch (cmd) {
    case 'M': use_data(global.data);break;
    ...
}

"Держи это", я сказал автору, высокопоставленному члену моей команды, "здесь нет барьера памяти! Вы не гарантируете, что global.data будет сброшен из кеша в основную память. Если поток A и поток B будет работать в двух разных процессорах - эта схема может выйти из строя".

Старший программист усмехнулся и объяснил медленно, как бы объясняя своему пятилетнему мальчику, как связать его шнурки: "Слушай, мальчик, мы видели здесь множество проблем, связанных с потоком, в тестах с высокой нагрузкой и у реальных клиентов", он сделал паузу, чтобы почесать свою длинную бороду, "но у нас никогда не было ошибки с этой идиомой".

"Но это сказано в книге..."

"Тихо!", он быстро замолчал: "Возможно, теоретически это не гарантировано, но на практике тот факт, что вы использовали вызов функции, фактически является барьером памяти. Компилятор не будет изменять порядок инструкций global.data = data, поскольку он не может знать, будет ли кто-либо использовать его в вызове функции, а архитектура x86 обеспечит, чтобы другие процессоры увидели эту часть глобальных данных к тому времени, когда поток B считывает команду из канала. Будьте уверены, у нас достаточно реальных мировые проблемы, о которых нужно беспокоиться. Нам не нужно вкладывать дополнительные усилия в фиктивные теоретические проблемы.

"Будьте уверены, мой мальчик, со временем вы поймете, чтобы отделить реальную проблему от проблем, связанных с I-need-to-get-a-PhD".

Правильно ли он? Это действительно не проблема на практике (скажем, x86, x64 и ARM)?

Это против всего, что я узнал, но у него есть длинная борода и действительно умный вид!

Дополнительные баллы, если вы можете показать мне часть кода, доказывающую, что он ошибается!

Ответ 1

Пункты памяти предназначены не только для предотвращения переупорядочения команд. Даже если инструкции не переупорядочиваются, они все равно могут вызвать проблемы с когерентностью кеша. Что касается переупорядочения - это зависит от вашего компилятора и настроек. ICC особенно агрессивен с переупорядочением. MSVC с оптимизацией всей программы также может быть.

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

(Если бы у меня были голоса, я бы добавил ваш вопрос для повествования.)

Ответ 2

На практике вызов функции является барьером компилятора, что означает, что компилятор не будет перемещать доступ к глобальной памяти после вызова. Предостережение к этому - это функции, о которых компилятор что-то знает, например. встроенные функции, встроенные функции (помните о IPO!) и т.д.

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

Ответ 3

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

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

Если рассматриваемая функция находится в общей библиотеке, она не сможет увидеть внутри нее, , но, если функция определена в соответствии со стандартом C, тогда ей не нужно - он уже знает, что делает функция, поэтому вам тоже нужно быть осторожным. Обратите внимание, что компилятор не распознает вызов ядра для того, что он есть, но сам акт вставки чего-то, что компилятор не может распознать (встроенный ассемблер или вызов функции в файл ассемблера), сам по себе создает барьер памяти.

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

На практике вам приходится писать очень плохой код, чтобы справиться с этим.

Ответ 4

На практике он исправляет, и в этом конкретном случае подразумевается барьер памяти.

Но дело в том, что, если его присутствие "спорно", код уже слишком сложный и неясный.

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

И, возможно, вы увидите другие ошибки, например, код непредсказуем, если send() вызывается более одного раза.