Информация об изменениях

Сообщение Re[6]: Доступ к локальной переменной из разных потоков от 12.01.2021 12:33

Изменено 12.01.2021 15:21 netch80

Re[6]: Доступ к локальной переменной из разных потоков
Здравствуйте, Sharov, Вы писали:

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


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


S>А для ассемблера это разве не актуально, процессор все равно же может переставить инструкции (ooe)?


Для ассемблера — актуально, но с меньшим количеством случаев.
Во-первых, сам компилятор только преобразует текстовые формы инструкций в машинные коды и указания для линкера, но не может их переставить или сделать, как в примере с циклом, оптимизацию типа "я её прочитаю только один раз" — ему такое не положено.
Во-вторых, пределы перестановки действий процессора (или их видимых последствий) другие.
В том же примере,
  mov xvar, 1
  mov lock, 0


если это на x86 — то декларировано, что экспорт в этот самый storage системы "ОЗУ+кэши" с видимостью всем остальным процессорам — будет обеспечен так, что сначала все увидят запись 1 в xvar, а потом 0 в lock.
Вот как это реализовано — уже дело стораджа. Например, если память некэшируемая (область так помечена), это будут просто записи на шине. Если кэшируемая, но строка уже во владении этого процессора, будет обновление строки. Если строка shared, то вначале через шину синхронизации кэша будет получено единоличное владение. И так далее (читать гуглом по словам типа MESI, MOESI, etc., но не только набор состояний, а ещё и взаимодействия).

(А вот на многих других процессорах надо — повторюсь — пометить вторую запись признаком release или вставить между ними команду барьера.)

Вот на чтении интереснее. Формально порядок чтений на x86 строго тоже в порядке их выполнения. Но пусть написано

    mov eax, lock
    mov ebx, xvar


Формально чтение xvar обязано быть после чтения lock. Но, реально процессор может (и будет пытаться в случае кэшируемой памяти) "спекулятивно" прочитать xvar одновременно или быстрее, если чтение lock задерживается. Но-2: если он записал себе в буферный регистр значение xvar, а затем память по адресу lock кто-то поменял — он сбросит это прочтённое значение и отправит запрос записи ещё раз. Дальше опять же дело стораджа, что с этим делать (читать напрямую, потребовать себе копию строки, и так далее).

И вот тут на других архитектурах ещё чаще делают просто отсутствие такой зависимости — поэтому если после чтения lock не стоит барьер load-load (например, как acquire флаг на чтении lock), то процессору может запомнить устаревшее значение xvar.

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

S>А в чем отличия? Запрет на оптимизацию действий с переменной + барьер.

Нет, в C volatile не означает никакого барьера, даже барьера компилятора. Этот признак говорит только то, что эту операцию нельзя объединять с любой другой, отменять, если компилятор считает ненужным, или, наоборот, вставлять чтение/запись по своему вкусу. При этом её можно переставлять с другими операциями, если это не запрещается компилятору чем-то другим.

Например, пусть у нас инструкция сетевой карте послать пакет выглядит как 1) записать адрес пакета в хвост списка отправки и 2) обновить регистр сетевухи, который содержит адрес конца списка. В C это может быть что-то вроде:

volatile void **reg_send_list_begin, **reg_send_list_end; // адреса регистров сетевухи - содержат адреса
send_block *send_list_begin, *send_list_end;

...

// добавляем пакет в очередь
send_list_end->next = new_packet;
new_packet->prev = send_list_end;
send_list_end = new_packet;
// X
*reg_send_list_end = new_packet;


у вас нет гарантии, что последнюю операцию компилятор не поставит первой по своему настроению.
Если хотите такой гарантии — хотя бы вставьте atomic_signal_fence(memory_order_release) в позицию "// X" (точнее, нужно таки atomic_thread_fence, но я говорил про обеспечение для компилятора).

А вот Java, C# — volatile обязательно будет с такими барьерами (acquire на чтении, release на записи, оба — на комбинированной операции) и уровня уже thread (то есть и перестановки компилятора не допускаются, и барьеры будут выставлены для тех процессоров, где они нужны).
Re[6]: Доступ к локальной переменной из разных потоков
Здравствуйте, Sharov, Вы писали:

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


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


S>А для ассемблера это разве не актуально, процессор все равно же может переставить инструкции (ooe)?


Для ассемблера — актуально, но с меньшим количеством случаев.
Во-первых, сам компилятор только преобразует текстовые формы инструкций в машинные коды и указания для линкера, но не может их переставить или сделать, как в примере с циклом, оптимизацию типа "я её прочитаю только один раз" — ему такое не положено.
Во-вторых, пределы перестановки действий процессора (или их видимых последствий) другие.
В том же примере,
  mov xvar, 1
  mov lock, 0


если это на x86 — то декларировано, что экспорт в этот самый storage системы "ОЗУ+кэши" с видимостью всем остальным процессорам — будет обеспечен так, что сначала все увидят запись 1 в xvar, а потом 0 в lock.
Вот как это реализовано — уже дело стораджа. Например, если память некэшируемая (область так помечена), это будут просто записи на шине. Если кэшируемая, но строка уже во владении этого процессора, будет обновление строки. Если строка shared, то вначале через шину синхронизации кэша будет получено единоличное владение. И так далее (читать гуглом по словам типа MESI, MOESI, etc., но не только набор состояний, а ещё и взаимодействия).

(А вот на многих других процессорах надо — повторюсь — пометить вторую запись признаком release или вставить между ними команду барьера.)

Вот на чтении интереснее. Формально порядок чтений на x86 строго тоже в порядке их выполнения. Но пусть написано

    mov eax, lock
    mov ebx, xvar


Формально чтение xvar обязано быть после чтения lock. Но, реально процессор может (и будет пытаться в случае кэшируемой памяти) "спекулятивно" прочитать xvar одновременно или быстрее, если чтение lock задерживается. Но-2: если он записал себе в буферный регистр значение xvar, а затем память по адресу lock кто-то поменял — он сбросит это прочтённое значение и отправит запрос записи ещё раз. Дальше опять же дело стораджа, что с этим делать (читать напрямую, потребовать себе копию строки, и так далее).

И вот тут на других архитектурах ещё чаще делают просто отсутствие такой зависимости — поэтому если после чтения lock не стоит барьер load-load (например, как acquire флаг на чтении lock), то процессору может запомнить устаревшее значение xvar.

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

S>А в чем отличия? Запрет на оптимизацию действий с переменной + барьер.

Нет, в C volatile не означает никакого барьера, даже барьера компилятора. Этот признак говорит только то, что эту операцию нельзя объединять с любой другой, отменять, если компилятор считает ненужным, или, наоборот, вставлять чтение/запись по своему вкусу. При этом её можно переставлять с другими операциями, если это не запрещается компилятору чем-то другим (хотя не с другой volatile — вот порядок между ними должен соблюдаться... а все остальные могут их обтекать, как вода камни).

Например, пусть у нас инструкция сетевой карте послать пакет выглядит как 1) записать адрес пакета в хвост списка отправки и 2) обновить регистр сетевухи, который содержит адрес конца списка. В C это может быть что-то вроде:

volatile void **reg_send_list_begin, **reg_send_list_end; // адреса регистров сетевухи - содержат адреса
send_block *send_list_begin, *send_list_end;

...

// добавляем пакет в очередь
send_list_end->next = new_packet;
new_packet->prev = send_list_end;
send_list_end = new_packet;
// X
*reg_send_list_end = new_packet;


у вас нет гарантии, что последнюю операцию компилятор не поставит первой по своему настроению.
Если хотите такой гарантии — хотя бы вставьте atomic_signal_fence(memory_order_release) в позицию "// X" (точнее, нужно таки atomic_thread_fence, но я говорил про обеспечение для компилятора).

А вот Java, C# — volatile обязательно будет с такими барьерами (acquire на чтении, release на записи, оба — на комбинированной операции) и уровня уже thread (то есть и перестановки компилятора не допускаются, и барьеры будут выставлены для тех процессоров, где они нужны).