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

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

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

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

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


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

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

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

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


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


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

thing.memberFoo = new Foo(1234);

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

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

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

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


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

Мой источник для этого Wikipedia, поэтому сделайте так, как хотите.

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


Обновление:. Чтобы сделать его более явным, вопрос заключается в недействительности кэша CPU и том, что обеспечивает .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 будет видимым для CPU B.)

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


Примечание. Я разместил сам ответ здесь.

Ответы

Ответ 1

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


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

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

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

R = <data>
Q = &R

Рассмотрим процессор чтения (B). Что значит остановить чтение с R, прежде чем читать с Q?

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

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

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

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

Ответ 2

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

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();

Несмотря на то, что некоторые могут подумать, что нет ничего, что требует, чтобы инструкции выполнялись в том порядке, в котором они были определены в коде, если намерение программиста логически сохраняется. Компиляторы С# или 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, что приведет к делению на нулевую ошибку. Конечно, это в основном теоретическое, потому что реализация CLR в Microsoft создает освобождение от записи, которая предотвратила бы это. Но техническая спецификация этого технически допускает. См. этот вопрос для соответствующего контента.

Ответ 3

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

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

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

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

Ответ 4

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

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 никогда не будет записано.

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

Ответ 5

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

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

EDIT: В методах блокировки используются барьеры памяти, которые обеспечивают, чтобы все потоки читали обновленное значение (в любой разумной системе). Пока вы выполняете все обновления с помощью блокировки, вы можете спокойно читать значение из любого потока без барьера памяти. Это шаблон, используемый в классах System.Collections.Concurrent.