Блокировка и барьеры памяти - PullRequest
11 голосов
/ 18 ноября 2009

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

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 методы? (компилятор предупреждает о передаче летучих веществ по ссылке, поэтому я должен игнорировать предупреждение в таком случае?)

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

Ответы [ 7 ]

5 голосов
/ 18 ноября 2009

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

Что вам действительно нужно, так это атомарные операции, т.е. InterlockedXXX функции, или переменные переменные в C #. Если чтение в Bar было атомарным, вы могли бы гарантировать, что ни компилятор, ни процессор не выполнят каких-либо оптимизаций, которые не позволяют ему читать либо значение перед записью в Foo, либо после записи в Foo, в зависимости от того, какой из них выполняется первым. Поскольку вы говорите, что «знаете», что запись Foo происходит до чтения Bar, Bar всегда будет возвращать true.

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

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

4 голосов
/ 19 ноября 2009

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

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 завершена до того, как будет очищено слово блокировки, защищающее разделяемую память.

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

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

pShared->goo = 14

pShared->atomic.setBit_Release()

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

у потребителя

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

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

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

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

2 голосов
/ 18 ноября 2009

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

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

1 голос
/ 19 ноября 2009

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

1 голос
/ 18 ноября 2009

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

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

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

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

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

  • Блокированные функции

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

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

0 голосов
/ 19 ноября 2009

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

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

РЕДАКТИРОВАТЬ: Еще один способ думать об этом заключается в том, что на самом деле существуют два вида барьеров памяти: барьеры памяти компилятора, которые сообщают компилятору, как последовательно выполнять чтение и запись, и барьеры памяти ЦП, которые сообщают ЦПУ, как последовательно читать и писать. Функции Interlocked используют барьеры памяти процессора. Даже если бы компилятор рассматривал их как барьеры памяти компилятора, это все равно не имело бы значения, так как в этом конкретном случае Bar() мог бы быть скомпилирован отдельно и неизвестно о других применениях m_value, которые потребовали бы барьер памяти компилятора .

0 голосов
/ 18 ноября 2009

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

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

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

...