Почему стандартный шаблон вызова событий C# является потокобезопасным без барьера памяти или аннулирования кэша? А как насчет аналогичного кода?

В C# это стандартный код для вызова события потокобезопасным способом:

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);

Где, потенциально в другом потоке, сгенерированный компилятором метод add использует Delegate.Combine создать новый экземпляр делегата многоадресной рассылки, который он затем устанавливает в поле, сгенерированном компилятором (используя блокировку сравнения-обмена).

(Примечание: для целей этого вопроса нам нет дела до кода, который выполняется в подписчиках событий. Предположим, что он является поточно-ориентированным и надежным перед удалением.)


В своем собственном коде я хочу сделать что-то похожее по следующим направлениям:

var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);

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

(И, очевидно, предположим, что Foo является "достаточно неизменным", поэтому мы не активно его модифицируем, пока он используется в этом потоке.)


Теперь я понимаю очевидную причину, по которой это потокобезопасно: чтение из ссылочных полей является атомарным. Копирование в локальную систему гарантирует, что мы не получим два разных значения. ( Очевидно, гарантируется только от.NET 2.0, но я предполагаю, что это безопасно в любой разумной реализации.NET?)


Но что я не понимаю: как насчет памяти, занимаемой экземпляром объекта, на который ссылаются? В частности, что касается когерентности кэша? Если поток "писатель" делает это на одном процессоре:

thing.memberFoo = new Foo(1234);

Что гарантирует, что память, где новый Foo в кеше ЦП, на котором работает "читатель", не находится ли он с неинициализированными значениями? Что обеспечивает это localFoo.baz (выше) не читает мусор? (И насколько хорошо это гарантировано на разных платформах? На Mono? На ARM?)

А что, если вновь созданный foo происходит из пула?

thing.memberFoo = FooPool.Get().Reset(1234);

С точки зрения памяти, это ничем не отличается от нового распределения - но, возможно, распределитель.NET делает что-то волшебное, чтобы первый случай работал?


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

Мой источник для этого - Википедия, так что делайте из этого что хотите.

(Я могу предположить, что, возможно, interlocked-compare-exchange в потоке писателя делает недействительным кэш-память читателя? Или, может быть, все чтения вызывают аннулирование? Или разыменование указателя вызывает аннулирование? Меня особенно беспокоит, как эти вещи для конкретной платформы звучат.)


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

  • Скажем, у нас есть ссылка, хранящаяся в поле Q (место в памяти).
  • На CPU A (записывающее устройство) мы инициализируем объект в ячейке памяти Rи напишите ссылку на R в Q
  • На CPU B (ридер) мы разыменовываем поле Qи вернуть обратно память R
  • Затем на CPU B мы читаем значение из R

Предположим, что GC не работает в любой точке. Больше ничего интересного не происходит.

Вопрос: что мешает R от того, чтобы быть в кэше B, до того, как A изменил его во время инициализации, так что когда B читает из R он получает устаревшие значения, несмотря на то, что получает свежую версию Q знать где R на первом месте?

(Альтернативная формулировка: что делает модификацию R видимый для CPU B в точке или до того момента, когда изменение Q виден процессору B.)

(И относится ли это только к памяти, выделенной с newили в какую память?)+


Примечание: я разместил здесь ответ на свой вопрос.

5 ответов

Решение

Я думаю, что я понял, что ответ. Но я не аппаратный парень, так что я открыт для исправления кем-то более знакомым с тем, как работают процессоры.


Модель памяти.NET 2.0 гарантирует:

Записи не могут проходить мимо других записей из того же потока.

Это означает, что записывающий процессор (в примере A) никогда не будет записывать ссылку на объект в память (для Q) до тех пор, пока не будет выписано содержимое этого объекта (для R). Все идет нормально. Это не может быть повторно заказано:

R = <data>
Q = &R

Давайте рассмотрим чтение CPU (B). Что мешает читать R прежде чем он читает из Q?

На достаточно наивном процессоре можно было бы ожидать, что это будет невозможно прочитать из R без первого чтения из Q, Мы должны сначала прочитать Q получить адрес R, (Примечание: можно предположить, что компилятор C# и JIT ведут себя так.)

Но если процессор чтения имеет кэш, он не может иметь устаревшую память для R в своем кеше, но получите обновленный Q?

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

По-видимому, единственным оборудованием, где это не так, является DEC Alpha (в соответствии с таблицей 1 здесь). Это единственная архитектура в списке, где зависимые чтения могут быть переупорядочены. ( Дальнейшее чтение.)

Это действительно хороший вопрос. Давайте рассмотрим ваш первый пример.

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);

Почему это безопасно? Чтобы ответить на этот вопрос, вы должны сначала определить, что вы подразумеваете под "безопасным". Это безопасно от NullReferenceException? Да, довольно просто увидеть, что локальное кэширование ссылки на делегат устраняет эту надоедливую гонку между нулевой проверкой и вызовом. Безопасно ли, чтобы более одного потока касалось делегата? Да, делегаты являются неизменяемыми, поэтому ни один поток не может привести к тому, что делегат перейдет в полусгоревшее состояние. Первые два очевидны. Но как насчет сценария, в котором поток A выполняет этот вызов в цикле, а поток B в более поздний момент времени назначает первый обработчик события? Это безопасно в том смысле, что поток A в конечном итоге увидит ненулевое значение для делегата? Несколько неожиданный ответ на этот вопрос, вероятно. Причина в том, что реализации по умолчанию add а также remove средства доступа к событию создают барьеры памяти. Я считаю, что ранняя версия CLR приняла явное lock и более поздние версии использовали Interlocked.CompareExchange, Если вы реализовали свои собственные средства доступа и не использовали барьер памяти, ответ мог бы быть отрицательным. Я думаю, что в действительности это сильно зависит от того, добавила ли Microsoft барьеры памяти для построения самого делегата многоадресной рассылки.

На втором и более интересном примере.

var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);

Нету. Извините, это на самом деле не безопасно. Допустим memberFoo имеет тип Foo который определяется следующим образом.

public class Foo
{
  public int baz = 0;
  public int daz = 0;

  public Foo()
  {
    baz = 5;
    daz = 10;
  }

  public void Bar(int x)
  {
    x / daz;
  }
}

А затем давайте предположим, что другой поток делает следующее.

this.memberFoo = new Foo();

Несмотря на то, что некоторые могут подумать, нет ничего, что требовало бы выполнения инструкций в том порядке, в котором они были определены в коде, до тех пор, пока намерение программиста логически сохраняется. Компиляторы C# или JIT могут фактически сформулировать следующую последовательность инструкций.

/* 1 */ set register = alloc-memory-and-return-reference(typeof(Foo));
/* 2 */ set register.baz = 0;
/* 3 */ set register.daz = 0;
/* 4 */ set this.memberFoo = register;
/* 5 */ set register.baz = 5;  // Foo.ctor
/* 6 */ set register.daz = 10; // Foo.ctor

Обратите внимание, как назначение memberFoo происходит до запуска конструктора. Это верно, потому что у него нет непреднамеренных побочных эффектов с точки зрения потока, выполняющего его. Это может, однако, оказать серьезное влияние на другие потоки. Что произойдет, если ваша нулевая проверка memberFoo в теме чтения произошло, когда в теме записи только что закончилась инструкция № 4? Читатель увидит ненулевое значение, а затем попытается вызвать Bar перед daz переменная установлена ​​в 10. daz по-прежнему будет иметь значение по умолчанию 0, что приведет к ошибке деления на ноль. Конечно, это в основном теоретически, потому что реализация Microsoft CLR создает ограничения на выпуск записей, которые могли бы предотвратить это. Но спецификация технически позволила бы это. Смотрите этот вопрос для соответствующего содержания.

Когда это оценивается:

thing.memberFoo = new Foo(1234);

Первый new Foo(1234) оценивается, что означает, что Foo Конструктор выполняется до завершения. затем thing.memberFoo присваивается значение. Это означает, что любой другой поток читает из thing.memberFoo не собирается читать неполный объект. Либо будет читать старое значение, либо будет читать ссылку на новое Foo объект после того, как его конструктор завершен. Находится ли этот новый объект в кэше или нет, не имеет значения; читаемая ссылка не будет указывать на новый объект до тех пор, пока конструктор не завершит работу.

То же самое происходит с пулом объектов. Все справа оценивается полностью, прежде чем произойдет назначение.

В вашем примере B никогда не получит ссылку на R до Rконструктор сбежал, потому что A не пишет R в Q до тех пор A завершил строительство R, Если B читает Q до этого он получит то значение, которое уже было в Q, Если Rконструктор выдает исключение, тогда Q никогда не будет написано.

C# порядок операций гарантирует, что это произойдет таким образом. Операторы присваивания имеют самый низкий приоритет, и new и операторы вызова функций имеют наивысший приоритет. Это гарантирует, что new будет оцениваться до того, как задание будет оценено. Это необходимо для таких вещей, как исключения - если конструктор выдает исключение, то размещаемый объект будет в недопустимом состоянии, и вы не хотите, чтобы это назначение происходило независимо от того, многопоточный он или нет.

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

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

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

На кэш-памяти ЦП и т. П. Единственное изменение, которое может сделать недействительными данные в реальном месте в памяти для истинного неизменяемого объекта, - это сжатие GC. Этот код обеспечивает все необходимые согласованности блокировок / кэша - поэтому управляемый код никогда не будет наблюдать изменения в байтах, на которые ссылается ваш кешированный указатель на неизменяемый объект.

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

В событиях использовались блокировки, но в C# 4 используется синхронизация без блокировки - я точно не знаю, что именно ( см. Эту статью).

РЕДАКТИРОВАТЬ: методы Interlocked используют барьеры памяти, которые будут гарантировать, что все потоки читают обновленное значение (в любой разумной системе). Пока вы выполняете все обновления с помощью Interlocked, вы можете безопасно считывать значение из любого потока без барьера памяти. Это шаблон, используемый в классах System.Collections.Concurrent.

Другие вопросы по тегам