Re[4]: Доступ к локальной переменной из разных потоков
От: netch80 Украина http://netch80.dreamwidth.org/
Дата: 07.01.21 17:04
Оценка: 123 (2)
Здравствуйте, Sharov, Вы писали:

S>Здравствуйте, Aquilaware, Вы писали:


A>>Для x86 синхронизация между кешами данных происходит при: 1) Memory Barrier 2) автоматически каждые 10 нс


S>Я не совсем понимаю суть Memory Barrier -- это синхронизация кэшей для какой-то одной переменной,

S>или вообще всего содержимого?

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

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

Например, если вы пишете без дополнительных указаний

  int xvar, lock; // адрес выровнен как минимум у lock
  ...
  xvar = 1; // некоторая переменная, доступ к которой защищён спинлоком
  lock = 0; // освобождение спинлока (если не знаете, что это - считайте, что мьютекс)


компилятор имеет полное право переставить операции — и регулярно это делает по своему настроению в зависимости от тысячи факторов, которые предсказать невозможно. Если это не подходит, потому что нужен строгий порядок (как, например, между записью переменной под мьютексом и записью самого мьютекса для его освобождения) — нужен барьер компилятора. Для C/C++ этим является функция atomic_signal_fence, или asm("":::"memory") (GCC, Clang), или просто вызов посторонней функции, которую компилятор не знает в этот момент (кроме декларации) — вставить между этими двумя записями.

(А также может не только переставлять, а и группировать... например, если вы пишете
while (tmp = a) { // да-да, присвоение
  do_something_with(tmp);
}


компилятор может, увидев код для do_something_with, решить, что оно не меняет переменную `a`, и превратить в:

    if (tmp = a)
        for (;;)
            do_something_with(tmp);

Примеры из Linux memory barriers.

И аналогично, тут решается вставлением барьера компилятора. Альтернативно, чтение `a` помечается как volatile, но надо знать побочные эффекты этого метода. И, не отходя от кассы, volatile в C это не volatile в C#.)

Далее, с тем же примером: пусть у нас процессор с умением out-of-order исполнения и совсем расслабленной моделью отношений между командами. Имея в скомпилированном машинном коде что-то вроде (в синтаксисе x86)

  mov xvar, 1
  mov lock, 0


вы не можете быть уверенным, что процессор их не выполнит наоборот по каким-то своим произвольным соображениям — ну вот так свободные места во внутренней очереди команд разлеглись.
И если "mov lock, 0" выполнится раньше, чем "mov xvar, 1" — другие (ядра, процессоры, внешние устройства и т.п.) могут увидеть освобождение спинлока раньше, чем записано значение в xvar, захватить его самим и прочитать неверное. И тут это решается двумя вариантами:
1) или только "mov lock, 0" получает семантику release (для ARM, например, это STLR вместо STR). Этот барьер гарантирует, что все чтения и записи до "mov lock, 0" исполнятся раньше, чем эта запись (но ничего не говорит об операциях, которые в потоке команд находятся позже, неважно, чтения или записи — с ними никакой синхронизации тут не требуется, и они могут реально исполниться раньше, если процессору было так удобно).
2) или вставляется соответствующий release barrier отдельной командой. В таком случае гарантия распространяется на все записи после барьера (они будут выполнены позже всех чтений и записей до барьера), а не только на одну операцию (lock = 0).

"Записи исполнятся" означает, что внешний слой ОЗУ+кэши получит команды модификации данных по адресу, выполнит их и вернёт подтверждение (а как оно будет в чьём кэше — уже дело этого слоя). "Чтения исполнятся" — данные прочитаны и поступили в процессор (внутреннюю часть ближе, чем кэши).

На x86 (тут и дальше — x86-32 и x86-64 одинаково), любая запись (ну, почти — есть редкие исключения) обладает встроенным release-барьером, поэтому дополнительные флаги или команды не нужны; на ARM (если я правильно понял) запись без флагов упорядочена по отношению к предшествующим записям, но не чтениям (ради precise exceptions); но могут быть и такие места, где и предшествующая запись не упорядочена без соответствующих барьеров. (И все подобные барьеры включают в себя в обязательном неявном порядке и барьеры компилятора.)

Поэтому, для достижения такого эффекта, как нужно, надо писать:

C++:
int xvar;
std::atomic<int> lock;
...
xvar = 1;
lock.store(0, std::memory_order_release);


тут этот store() подразумевает и барьер компилятора, и барьер процессора перед записью в lock.

Или, с барьером отдельно от самой записи (чуть грубее),

int xvar;
int lock;
...
xvar = 1;
std::atomic_thread_fence(std::memory_order_release);
lock = 0;


C#:
int xvar;
int lock;
...
xvar = 1;
Volatile.Write(lock, 0);


то же самое про оба барьера.

И всё это делается само по себе безотносительно кэшей — которые сами по себе значительно усложняют саму работу с памятью, но на описанные мной вопросы прямо не влияют. Повторюсь, редставьте себе вместо системы кэшей просто некоторую сущность "интерфейс оперативной памяти", которая принимает/отправляет работает порциями по 64 байта (современные x86), и почему-то в зависимости от непредсказуемых факторов может выполнить операцию в 1 такт, а может в 300 — а теперь в этих условиях перечитайте всё вышеописанное. А после этого и тексты из MSDN типа:

> Reads the value of the specified field. On systems that require it, inserts a memory barrier that prevents the processor from reordering memory operations as follows: If a read or write appears after this method in the code, the processor cannot move it before this method.


> Writes the specified value to the specified field. On systems that require it, inserts a memory barrier that prevents the processor from reordering memory operations as follows: If a read or write appears before this method in the code, the processor cannot move it after this method.


станут понятны — вот как раз о выполнении команд тут и идёт речь. Для x86 дополнительные команды процессора не нужны: любая запись и так подразумевает подобное (хотя "before this method in the code" плохая формулировка, надо было сказать про операции, которые процессор исполняет для текущего треда), а чтение, наоборот, подразумевает, что все чтения и записи после данного в потоке процессорных инструкций будут выполнены после данной. И для него Volatile.{Read,Write} это только барьеры компилятора. Но уже для ARM надо явно помечать процессорные инструкции особыми флагами, а где-то может требоваться вводить более сильные меры.

(Примечание: некоторый объём тонкостей пропущен. Но для первичного понимания этого должно хватить с головой.)

Что имел в виду коллега Aquilaware:

A>>Для x86 синхронизация между кешами данных происходит при: 1) Memory Barrier 2) автоматически каждые 10 нс


это неверно в формулировке, как уже сказал — это не синхронизация между кэшами, это публикация всего сделанного внутри процессора-ещё-без-кэшей наружу тому слою, который реализует доступ к ОЗУ со всеми слоями кэшей перед ним. То есть, если цифры верны (не проверял), между завершением исполнения любой процессорной инструкции и тем, что сделанные ею записи в память будут опубликованы слою ОЗУ+кэши, пройдёт не более 10 нс. При необходимости можно это ускорить барьерами памяти (сюда для процессора входят как минимум sfence, mfence, также по отношению к большинству взаимодействий — in, out, xchg, lock+что-то, cpuid, переключения режимов). Впрочем, ускорение может быть только оттого, что последующие команды не будут мешаться тем, что до команды барьера.
The God is real, unless declared integer.
Отредактировано 08.01.2021 8:32 netch80 . Предыдущая версия .
 
Подождите ...
Wait...
Пока на собственное сообщение не было ответов, его можно удалить.