Страницы

Поиск по вопросам

четверг, 4 октября 2018 г.

События и потоки

Меня волнует корректность вроде бы стандартного паттерна для отправки события в C# (по крайней мере до 6-ой версии):
EventHandler localCopy = SomeEvent; if (localCopy != null) localCopy(this, args);
Я читал статью Эрика Липперта Events and races, и знаю, что этот паттерн имеет проблему с вызовом устаревших обработчиков, но меня больше волнует проблема модели памяти и того, разрешено ли компилятору/JIT'теру выбросить локальную копию и переписать код в виде
if (SomeEvent != null) SomeEvent(this, args);
с возможностью NullReferenceException
Согласно спецификации языка C#, §3.10,
The critical execution points at which the order of these side effects must be preserved are references to volatile fields (§10.5.3), lock statements (§8.12), and thread creation and termination.
то есть (перевод мой)
Критические точки выполнения, в которых порядок побочных эффектов должен сохраняться, таковы: обращение к volatile-полям (§10.5.3), инструкция lock (§8.12), а также создание и завершение потоков
Таким образом, критических точек выполнения в рассматриваемом коде нету, то есть оптимизатор не ограничен ими.
Ответ Джона Скита по теме (2009 год) говорит (в моём переводе):
JIT'у не разрешено выполнять оптимизации, о которых вы говорите, из-за условия. Я знаю, что этот вопрос поднимался некоторое время назад, но подобные оптимизации не валидны (Я спрашивал у Джо Даффи или Венса Моррисона, не помню точно.)
Но комментарии ссылаются на вот этот пост (2008 год): Events and Threads (Part 4), который по нашей теме говорит, что JIT'тер CLR 2.0 (и наверное более поздних версий?) не должен вносить при оптимизации операции чтения и записи сверх существующих, то есть, с Microsoft .NET проблем быть не должно.
[Кстати: Я не понимаю, почему запрет на дополнительные чтения полей доказывает корректность рассматриваемого паттерна. Разве оптимизатор не может просто увидеть считанное ранее в другую локальную переменную значение SomeEvent, и выкинуть ровно одно из чтений? Кажется вполне законной оптимизацией.]
Далее, вот эта статья Игоря Островского на MSDN (2012 год): The C# Memory Model in Theory and Practice утверждает (перевод мой):
Оптимизации, не меняющие порядка Некоторые оптимизации могут добавлять или убирать некоторые операции с памятью. Например, компилятор может заменить повторяющееся чтение поля на одно чтение. Или если код читает поле и записывает его значение в локальную переменную, а потом читает эту переменную, компилятор может решить читать вместо этого значение из поля напрямую Поскольку спецификация ECMA C# не запрещает оптимизаций, не меняющих порядка, они, должно быть, разрешены. На деле (я буду говорить об этом во второй части) JIT'тер реально производит такой тип оптимизаций.
Это, кажется, противоречит ответу Джона Скита.
Итак, вопрос:
Является ли обсуждаемый паттерн валидным в современной Майкрософтовской имплементации .NET? Гарантировано ли, что обсуждаемый паттерн валиден на конкурирующих имплементациях .NET (например, Mono), особенно при работе на экзотических процессорах? Что именно (спецификация C#? спецификация CLR? подробности имплементации текущей версии CLR?) гарантирует валидность паттерна?
Приветствуются любые нормативные ссылки по теме.


Ответ

Хороший вопрос, который показал, по крайней мере мне, что в стане .NET со стандартами всё плачевно. Ну да ладно, давайте разбираться с вопросом.
Коротко:
Да, этот паттерн является правильным и безопасным в реализации MS. Нет, никаких гарантий относительно Mono не существует. Более того, мне вообще не удалось найти никакой информации по модели памяти, которая используется в Mono. Гарантия представляется реализацией, которая была введена в .NET 2.0 Майкрософтом(подробности ниже). Никаких общеизвестных исправлений с тех пор не было.

Теперь разберём почему. Чтобы преобразовать
EventHandler localCopy = SomeEvent; if (localCopy != null) localCopy(this, args);
в
SomeEvent(this, args);
компилятор обязан как-то убрать проверку, он может это сделать прочитав SomeEvent(чтение номер раз) и убедившись, что он действительно ненулевой. Но потом ему придётся прочитать его ещё раз(чтение номер 2), чтобы вызвать event. Таким образом, в преобразованной версии, получается 2 чтения SomeEvent, по сравнению с одним в изначальной версии. Уберём за скобки то, что это не сильно умно(обычно чтение убирают, а не добавляют), это ещё и запрещено. Где? Внимание! В статье MSDN от октября 2006 года, которая называется Understand the Impact of Low-Lock Techniques in Multithreaded Apps, которая описывает изменения в модели памяти CLR 2.0.
Статья большая, но нас интересует только следующее:
All the rules that are contained in the ECMA model, in particular the three fundamental memory model rules as well as the ECMA rules for volatile. Reads and writes cannot be introduced. A read can only be removed if it is adjacent to another read to the same location from the same thread. A write can only be removed if it is adjacent to another write to the same location from the same thread. Rule 5 can be used to make reads or writes adjacent before applying this rule. Writes cannot move past other writes from the same thread. Reads can only move earlier in time, but never past a write to the same memory location from the same thread.
Т.к. эту статью писал один из разработчиков и учитывая тот факт, что на неё ссылаются многие книгописатели, это можно считать стандартом де-факто.
Правда, я нашёл ещё одно интересное «чтиво» из современных статей MSDN: The C# Memory Model in Theory and Practice, Part 2, где некий Igor Ostrovsky, пишет следующее:
Read Introduction As I just explained, the compiler sometimes fuses multiple reads into one. The compiler can also split a single read into multiple reads. In the .NET Framework 4.5, read introduction is much less common than read elimination and occurs only in very rare, specific circumstances. However, it does sometimes happen.
И далее по тексту. Но, т.к. я не нашёл никакой «CLR memory model 3» или выше, то можно сделать вывод, что никаких изменений не происходило и уважаемый Игорь просто не прав.

Ещё одним доказательством, что ничего не изменилось с 2006 года является тот факт, что SomeEvent?.Invoke(...) преобразуется как раз в такой код, который и представлен в вопросе, а именно этот паттерн в настоящий момент является доминирующим.

Комментариев нет:

Отправить комментарий