Подтвердить что ты не робот

События и многопоточность еще раз

Меня беспокоит правильность, казалось бы, стандартного шаблона pre-С# 6 для запуска события:

EventHandler localCopy = SomeEvent;
if (localCopy != null)
    localCopy(this, args);

Я прочитал Eric Lippert События и расы и знаю, что есть остальная проблема вызова устаревшего обработчика событий, но мое беспокойство является ли компилятор /JITter разрешено оптимизировать локальную копию, эффективно переписывая код как

if (SomeEvent != null)
    SomeEvent(this, args);

с возможным NullReferenceException.

В соответствии с Спецификацией языка С#, §3.10,

Критические точки выполнения, в которых должен сохраняться порядок этих побочных эффектов, являются ссылками на изменчивые поля (§10.5.3), операторы блокировки (§8.12) и создание и прекращение потоков.

- поэтому в указанном шаблоне нет критических точек выполнения, и оптимизатор не ограничен этим.

Связанный ответ Jon Skeet (год 2009) гласит

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

- но комментарии относятся к этому сообщению в блоге (год 2008): События и темы (часть 4), в котором в основном говорится, что CLR 2.0 JITter (и, возможно, последующие версии?) Не должны вводить чтения или записи, поэтому в Microsoft.NET не должно быть никаких проблем. Но это ничего не говорит о других реализациях .NET.

[Боковое замечание: я не вижу, как не-введение чтения подтверждает правильность указанного шаблона. Невозможно ли JITTER увидеть какое-то устаревшее значение SomeEvent в некоторой другой локальной переменной и оптимизировать одно из чтения, но не другое? Совершенно законный, верно?]

Кроме того, эта статья MSDN (год 2012): Модель памяти С# в теории и практике Игоря Островского гласит следующее:

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

Поскольку спецификация ECMA С# не исключает оптимизацию без переопределения, предполагается, что они допустимы. Фактически, как обсуждают в Части 2, компилятор JIT выполняет эти типы оптимизации.

Это, похоже, противоречит ответу Джона Скита.

Поскольку теперь С# уже не является языком Windows, возникает вопрос, является ли справедливость шаблона следствием ограниченных оптимизаций JITter в текущей реализации CLR, или это свойство свойства языка.

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

Конечно, нормативные ссылки на эту тему приветствуются.

4b9b3361

Ответ 1

Согласно источникам, которые вы предоставили, и некоторым другим в прошлом, это сводится к следующему:

  • При реализации Microsoft вы можете положиться на not, ознакомившись с введением [1] [2] [3]

  • Для любой другой реализации, может читать введение, если не указано иное

EDIT: внимательно прочитав спецификацию ECMA CLI, ознакомьтесь с инструкциями, но с ограничениями. Из раздела I, 12.6.4 Оптимизация:

Соответствующие реализации CLI могут свободно запускать программы с использованием любой технологии, которая гарантирует в одном потоке выполнения побочные эффекты и исключения, созданные потоком, видны в порядке, указанном CIL. Для этой цели только изменчивые операции (включая изменчивые чтения) представляют собой видимые побочные эффекты. (Обратите внимание, что, хотя только изменчивые операции представляют собой видимые побочные эффекты, летучие операции также влияют на видимость нелетучих ссылок.)

Очень важная часть этого параграфа находится в круглых скобках:

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

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

Таким же образом, С# язык также ограничивает чтение введением на уровне С# -to-CIL. Из спецификации языка С# версии 5.0, 3.10 Порядок выполнения:

Выполнение программы С# продолжается так, что побочные эффекты каждого исполняемого потока сохраняются в критических точках выполнения. побочный эффект определяется как чтение или запись изменчивого поля, запись в энергонезависимую переменную, запись на внешний ресурс и металирование исключения. Критические точки выполнения, в которых должен сохраняться порядок этих побочных эффектов, - это ссылки на изменчивые поля (§10.5.3), lock (§8.12), а также создание и прекращение потоков. Среда исполнения может изменять порядок выполнения программы С# с учетом следующих ограничений:

  • Зависимость данных сохраняется в потоке выполнения. То есть значение каждой переменной вычисляется так, как будто все инструкции в потоке выполнялись в исходном программном порядке.

  • Правила упорядочения инициализации сохраняются (§10.5.4 и §10.5.5).

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

Точка о зависимости данных - это та, которую я хочу подчеркнуть:

Зависимость данных сохраняется в потоке выполнения. То есть значение каждой переменной вычисляется так, как если бы все операторы в потоке выполнялись в исходном программном порядке.

Таким образом, глядя на ваш пример (аналогичный тому, который дал Игорь Островский [4])

EventHandler localCopy = SomeEvent;
if (localCopy != null)
    localCopy(this, args);

Компилятор С# не должен выполнять чтение, когда-либо. Даже если это может доказать, что не существует мешающих доступов, нет гарантии от базового CLI, что два последовательных нелетучих чтения на SomeEvent будут иметь одинаковый результат.

Или, используя эквивалентный оператор с нулевым условием, поскольку С# 6.0:

SomeEvent?.Invoke(this, args);

Компилятор С# должен всегда расширяться до предыдущего кода (гарантируя уникальное имя неконфликтной переменной), не выполняя чтение, поскольку это оставило бы состояние гонки.

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

Такая оптимизация, если она локальна, может выполняться только на простых (не ref и без вывода) параметрах и незахваченных локальных переменных. Благодаря оптимизации между методами или целыми программами он может выполняться на общих полях, параметрах ref или out и захваченных локальных переменных, которые могут быть доказаны, что они никогда не будут заметно затронуты другими потоками.

Таким образом, существует большая разница в том, записывает ли он следующий код или компилятор С#, генерирующий следующий код, по сравнению с JIT-компилятором, генерирующим машинный код, эквивалентный следующему коду, поскольку JIT-компилятор является единственным, способным доказать, введенное чтение согласуется с выполнением одного потока, даже сталкиваясь с потенциальными побочными эффектами, вызванными другими потоками:

if (SomeEvent != null)
    SomeEvent(this, args);

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

Таким образом, если комментарий в примере Игоря Островского [4], я говорю это ошибка.


[1]: комментарий Эрика Липперта; процитировать:

Чтобы рассказать о спецификации ECI CLI и спецификации С#: более мощная модель памяти promises, созданная с помощью CLR 2.0, - это promises, сделанная Microsoft. Третья сторона, решившая сделать свою собственную реализацию С#, которая генерирует код, который выполняется на собственной реализации CLI, может выбрать более слабую модель памяти и по-прежнему соответствовать спецификациям. Независимо от того, сделала ли это команда Mono, я не знаю; вы должны спросить их.

[2]: модель памяти CLR 2.0 Джо Даффи, повторив следующую ссылку; цитируя соответствующую часть:

  • Правило 1: Зависимость данных между грузами и магазинами никогда не нарушается.
  • Правило 2: Все магазины имеют семантику выпуска, т.е. загрузка или сохранение могут перемещаться после одного.
  • Правило 3: все летучие нагрузки приобретаются, т.е. загрузка или хранение не могут перемещаться до одного.
  • Правило 4. Никакие нагрузки и хранилища никогда не пересекают полный барьер (например, Thread.MemoryBarrier, блокировка, Interlocked.Exchange, Interlocked.CompareExchange и т.д.).
  • Правило 5: Загрузка и сохранение в кучу никогда не могут быть введены.
  • Правило 6. Нагрузки и хранилища могут быть удалены только при объединении смежных нагрузок и хранилищ из/в том же месте.

[3]: Понимание влияния методов с низким уровнем блокировки в многопоточных приложениях Vance Morrison, последний снимок, который я мог бы получить на Интернет-архив; цитируя соответствующую часть:

Сильная модель 2:.NET Framework 2.0

(...)

  • Все правила, содержащиеся в модели ECMA, в частности три основных правила модели памяти, а также правила ECMA для неустойчивых.
  • Чтение и запись не могут быть введены.
  • Чтение может быть удалено только в том случае, если оно смежно с другим, прочитанным в том же месте из того же потока. Запись может быть удалена только в том случае, если она смежна с другой записью в том же месте из того же потока. Правило 5 может использоваться для чтения или записи рядом, прежде чем применять это правило.
  • Писания не могут перемещаться из других записей из одного потока.
  • Чтения могут перемещаться только по времени, но никогда не записываются в одну ячейку памяти из того же потока.

[4]: ​​С# - модель памяти С# в теории и практике, часть 2 Игоря Островского, где он показывает прочитанное введение например, что, по его словам, JIT может выполнять так, что два последующих чтения могут иметь разные результаты; цитируя соответствующую часть:

Чтение ВведениеКак я только что объяснил, компилятор иногда сплавляет несколько чтений в один. Компилятор также может разбивать одно чтение на несколько чтений. В .NET Framework 4.5 ознакомление с введением гораздо реже, чем чтение, и происходит только в очень редких особых обстоятельствах. Однако иногда это происходит.

Чтобы понять введение в чтение, рассмотрите следующий пример:

public class ReadIntro {
  private Object _obj = new Object();
  void PrintObj() {
    Object obj = _obj;
    if (obj != null) {
      Console.WriteLine(obj.ToString());
    // May throw a NullReferenceException
    }
  }
  void Uninitialize() {
    _obj = null;
  }
}

Если вы исследуете метод PrintObj, это похоже на то, что значение obj никогда не будет равно null в выражении obj.ToString. Однако эта строка кода может фактически вызвать исключение NullReferenceException. CLR JIT может скомпилировать метод PrintObj, как если бы он был написан следующим образом:

void PrintObj() {
  if (_obj != null) {
    Console.WriteLine(_obj.ToString());
  }
}

Поскольку чтение поля _obj было разделено на два чтения поля, теперь метод ToString может быть вызван нулевой целью.

Обратите внимание, что вы не сможете воспроизвести исключение NullReferenceException с использованием этого примера кода в .NET Framework 4.5 на x86-x64. Прочитать введение очень сложно воспроизвести в .NET Framework 4.5, но оно тем не менее происходит в определенных особых обстоятельствах.

Ответ 2

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

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

Учитывая, что шаблон действительно делает то, что он намеревается сделать, то есть делать снимок события и вызывать все обработчики, если они есть, не выбрасывая NRE из-за отсутствия каких-либо обработчиков.