Блокировка и блокировка памяти

У меня есть вопрос о следующем примере кода (m_value не является volatile, и каждый поток выполняется на отдельном процессоре)

void Foo() // executed by thread #1, BEFORE Bar() is executed
{
   Interlocked.Exchange(ref m_value, 1);
}

bool Bar() // executed by thread #2, AFTER Foo() is executed
{
   return m_value == 1;
}

Использует ли Interlocked.Exchange в Foo(), что, когда Bar() выполняется, я увижу значение "1"? (даже если значение уже существует в регистре или строке кэша?) Или мне нужно разместить барьер памяти перед чтением значения m_value?

Также (не связанный с исходным вопросом), является ли законным объявлять изменчивый член и передавать его ссылкой на методы InterlockedXX? (компилятор предупреждает о передаче летучих элементов по ссылке, поэтому следует ли игнорировать предупреждение в таком случае?)

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

Ответ 1

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

while (!pShared->lock.testAndSet_Acquire()) ;
// (this loop should include all the normal critical section stuff like
// spin, waste, 
// pause() instructions, and last-resort-give-up-and-blocking on a resource 
// until the lock is made available.)

// Access to shared memory.

pShared->foo = 1 
v = pShared-> goo

pShared->lock.clear_Release()

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

Предел памяти деблокирования гарантирует, что загрузка с goo в переменную v (local say) будет завершена до того, как будет заблокировано слово блокировки, защищающее разделяемую память.

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

Предположим, что ваш производитель использовал атомную переменную, чтобы указать, что какое-либо другое состояние уже готово к использованию. Вам нужно что-то вроде этого:

pShared->goo = 14

pShared->atomic.setBit_Release()

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

В потребителе

if ( pShared->atomic.compareAndSwap_Acquire(1,1) )
{
   v = pShared->goo 
}

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

Теперь, оставшаяся вещь, о которой нужно упомянуть, заключается в том, что конструкции барьера очень неспортивны. Вероятно, ваш компилятор предоставляет варианты _acquire и _release для большинства методов манипуляции атомами, и это способы их использования. В зависимости от платформы, которую вы используете (например, ia32), это может быть именно то, что вы получите без суффиксов _acquire() или _release(). Платформы, где это имеет значение, являются ia64 (фактически мертвы, за исключением HP, где все еще немного дергается) и powerpc. В большинстве команд загрузки и хранения (включая атомные, такие как cmpxchg) в ia64 были модификаторы .acq и .rel. powerpc имеет отдельные инструкции для этого (isync и lwsync дают вам барьеры чтения и записи соответственно).

Теперь. Сказав все это. У вас действительно есть веская причина пойти по этому пути? Выполнение всего этого может быть очень сложным. Будьте готовы к большому количеству сомнений и неуверенности в обзорах кода и убедитесь, что у вас много тестов concurrency со всеми видами случайных временных сцен. Используйте критический раздел, если у вас нет очень веских оснований, чтобы его избежать, и не пишите этот критический раздел самостоятельно.

Ответ 2

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

Вам действительно нужны атомарные операции, т.е. Функции InterlockedXXX или изменчивые переменные в С#. Если чтение в баре является атомарным, вы можете гарантировать, что ни компилятор, ни процессор не будут оптимизировать, чтобы он не читал ни значение перед записью в Foo, ни после записи в Foo, в зависимости от того, какой из них выполняется первым. Поскольку вы говорите, что вы "знаете", запись Foo происходит до чтения Bar, тогда Bar всегда возвращает true.

Без чтения в баре, который является атомарным, он может считывать частично обновленное значение (т.е. мусор) или кешированное значение (либо из компилятора, либо из ЦП), оба из которых могут препятствовать возврату бара true, он должен.

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

Ответ 3

Я не совсем уверен, но я думаю, что Interlocked.Exchange будет использовать функцию InterlockedExchange API Windows, которая обеспечивает полную память барьер в любом случае.

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

Ответ 4

Блокированные операции обмена гарантируют защиту памяти.

Следующие функции синхронизации используют соответствующие барьеры для обеспечения порядка памяти:

  • Функции, которые вводят или оставляют критические разделы

  • Функции, которые сигнализируют объекты синхронизации

  • Функции ожидания

  • Функции блокировки

(Источник: ссылка)

Но вам не повезло с переменными регистра. Если m_value находится в регистре в Bar, вы не увидите изменения в m_value. В связи с этим вы должны объявить переменные "изменчивые" переменных.

Ответ 5

Если m_value не помечено как volatile, тогда нет оснований полагать, что значение, считанное в Bar, огорожено. Оптимизация компилятора, кеширование или другие факторы могут изменить порядок чтения и записи. Переплетенный обмен полезен только тогда, когда он используется в экосистеме правильно защищенных ссылок на память. В этом весь смысл маркировки поля volatile. Модель памяти .Net не так прямолинейна, как ожидали некоторые.

Ответ 6

Interlocked.Exchange() должен гарантировать, что это значение будет правильно очищено от всех ЦП - оно обеспечивает свой собственный барьер памяти.

Я удивлен, что компилятор согласен с передачей volatile в Interlocked.Exchange() - тот факт, что вы используете Interlocked.Exchange(), должен почти задавать переменную volatile.

Проблема, которую вы можете увидеть, заключается в том, что если компилятор делает некоторую большую оптимизацию Bar() и понимает, что ничто не изменяет значение m_value, оно может оптимизировать ваш чек. То, что бы выполняло ключевое слово volatile, - это подсказывает компилятору, что эта переменная может быть изменена вне представления оптимизатора.

Ответ 7

Если вы не сообщите компилятору или времени выполнения, что m_value не следует читать перед Bar(), он может и может кэшировать значение m_value перед Bar() и просто использовать кешированное значение. Если вы хотите убедиться, что он видит "последнюю" версию m_value, запишитесь в Thread.MemoryBarrier() или используйте Thread.VolatileRead(ref m_value). Последнее дешевле, чем полный барьер памяти.

В идеале вы можете запихнуть в ReadBarrier, но CLR, похоже, не поддерживает это напрямую.

EDIT: Еще один способ подумать о том, что на самом деле существуют два типа барьеров памяти: барьеры памяти компилятора, которые сообщают компилятору, как последовательно считывать и записывать, а также блокировать память ЦП, которые указывают процессору, как последовательно читать и записывать. Функции Interlocked используют блокировки памяти CPU. Даже если компилятор относился к ним как к барьерам памяти компилятора, все равно это не имело бы значения, так как в этом конкретном случае Bar() мог быть отдельно скомпилирован и неизвестен о других использованиях m_value, для которых требовался бы защитный барьер компилятора.