Может ли метод оптимизации встраивания вызывать условия гонки?

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

Я думаю об использовании этого метода расширения, чтобы безопасно поднять событие:

public static void SafeRaise(this EventHandler handler, object sender, EventArgs e)
{
    if (handler != null)
        handler(sender, e);
}

Но Майк Розенблюм поднимает эту озабоченность в ответ Джона Скита:

Вам, ребята, нужно добавить [MethodImpl (MethodImplOptions.NoInlining)] атрибут этих методов расширения или ваша попытка скопировать делегировать временную переменную оптимизироваться JITter, с учетом нулевой ссылки исключение.

Я провел несколько тестов в режиме Release, чтобы узнать, могу ли я получить условие гонки, когда метод расширения не отмечен NoInlining:

int n;
EventHandler myListener = (sender, e) => { n = 1; };
EventHandler myEvent = null;

Thread t1 = new Thread(() =>
{
    while (true)
    {
        //This could cause a NullReferenceException
        //In fact it will only cause an exception in:
        //    debug x86, debug x64 and release x86
        //why doesn't it throw in release x64?
        //if (myEvent != null)
        //    myEvent(null, EventArgs.Empty);

        myEvent.SafeRaise(null, EventArgs.Empty);
    }
});

Thread t2 = new Thread(() =>
{
    while (true)
    {
        myEvent += myListener;
        myEvent -= myListener;
    }
});

t1.Start();
t2.Start();

Я некоторое время запускал тест в режиме Release и никогда не имел исключения NullReferenceException.

Итак, был ли неправильный Майк Розенблюм в своем комментарии, а метод вложения не может вызвать расовое состояние?

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

while (true)
{
    EventHandler handler = myEvent;
    if (handler != null)
        handler(null, EventArgs.Empty);
}

или

while (true)
{
    if (myEvent != null)
        myEvent(null, EventArgs.Empty);
}

Ответы

Ответ 1

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

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

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

Thread 1                      Thread 2

                              myEvent += myListener;

if (myEvent != null) // No, it not null here...

                              myEvent -= myListener; // Now it null!

myEvent(null, EventArgs.Empty); // Bang!

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

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

EventHandler handler = myEvent;
if (handler != null)
{
    handler(null, EventArgs.Empty);
}

Теперь не имеет значения, изменит ли поток 2 значение myEvent - он не может изменить значение обработчика, поэтому вы не получите NullReferenceException.

Если JIT делает inline SafeRaise, он будет привязан к этому фрагменту, потому что встроенный параметр заканчивается как новая локальная переменная. Проблема была бы только в том случае, если JIT неправильно вложил ее, сохранив два отдельных чтения myEvent.

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

Ответ 2

Это проблема с моделью памяти.

В основном возникает вопрос: если мой код содержит только одно логическое чтение, может ли оптимизатор ввести другое чтение?

Удивительно, но ответ: возможно

В спецификации CLR ничто не мешает оптимизаторам это делать. Оптимизация не прерывает однопоточную семантику, и шаблоны доступа к памяти гарантируются только для летучих полей (и даже для упрощения, которое не является 100% истинным).

Таким образом, независимо от того, используете ли вы локальную переменную или параметр, код не является потокобезопасным.

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

Тем не менее, использование [MethodImplOptions] кажется странным взломом, так как предотвращение введения оптимизатором чтения является лишь побочным эффектом неинтеграции. Вместо этого я бы использовал поле volatile или Thread.VolatileRead.

Ответ 3

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