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

Как видно из этого вопроса: Вызов событий C # с расширением метод - это плохо?

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

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

or

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

person Jeff Cyr    schedule 24.02.2010    source источник


Ответы (3)


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

Однако я не думаю, что это проблема. Несколько лет назад это было проблемой, но я считаю, что это было расценено как ошибочное прочтение модели памяти. Существует только одно логическое «чтение» переменной, и JITter не может оптимизировать его, так что значение изменяется между одним чтением копии и вторым чтением копии.

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

Thread 1                      Thread 2

                              myEvent += myListener;

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

                              myEvent -= myListener; // Now it's null!

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

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

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

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

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

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

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

person Jon Skeet    schedule 24.02.2010
comment
Однако встраивание может изменить характеристики производительности и усугубить ранее существовавшее состояние гонки. - person Joel; 24.02.2010
comment
Ну, раз уж я начал эту неразбериху, думаю, мне стоит ответить, а? Джон, твое объяснение имеет смысл. Если обработчик событий не был помечен как «изменчивый», не было бы причин для повторного получения значения после его первой проверки в операторе «if». Это было бы верно независимо от того, был ли метод встроен или нет. Тем не менее, моим источником для этого был Джувал Лоуи в его книге «Программирование компонентов .NET», предназначенной для .NET 2.0, но все еще очень актуальной и заслуживающей внимания. Жуваль подробно останавливается на этом вопросе, в частности, на с. 250–252. - person Mike Rosenblum; 25.02.2010
comment
@Mike: Я знаю, что это было некоторое обсуждение во время выпуска .NET 2.0 из-за ошибочной оптимизации x64 JIT (исправлено перед выпуском, я почти уверен). Я подозреваю, что в этом одном случае Жуваль ошибается. Даже лучшие авторы могут иногда ошибаться :) - person Jon Skeet; 25.02.2010
comment
@Jon, да, я согласен, похоже, что Juval мог ошибиться здесь или сам полагался на другой, казалось бы, надежный источник. Однако я даже не близок к тому, чтобы быть в его лиге, поэтому не решаюсь делать окончательные выводы. Обработка событий обычно не требует высокой скорости отклика, поэтому я не возражаю включить сюда «MethodImplAttribute», хотя я на 100% согласен с вашими рассуждениями и вашими выводами. - person Mike Rosenblum; 25.02.2010
comment
Собственно, если эта аргументация верна, зачем нам вообще нужно создавать отдельную временную переменную? Если поле, содержащее обработчики событий, не помечено как «изменчивое», разве это не нужно? Так почему же это общепринятое мнение? Но если необходимо сохранить копию обработчиков событий в локальной временной переменной , я думаю, это возвращает нас к тому, что защита от встраивания снова станет серьезным соображением. Наконец, код Джеффа Сира показывает, что ошибки действительно возникают в режиме отладки, но не в режиме выпуска, что, по-видимому, тоже стоит понять. - person Mike Rosenblum; 25.02.2010
comment
@Mike: значение переменной instance может меняться между проверкой на нулевое значение и вызовом, если другой поток занят чем-то. Значение переменной local не может измениться - никакой другой поток не имеет к нему доступа. Встраивание не имеет к этому никакого отношения - это все равно будет локальная переменная даже после встраивания, просто локальная для другого метода. Что касается проблемы отладки / выпуска: диагностика проблем с потоками сложна, а работа под отладчиком радикально меняет то, что будет происходить с точки зрения временных интервалов и т. Д. - person Jon Skeet; 25.02.2010
comment
@Jon: Хорошо, верно, моя проблема: делегаты являются ссылочными типами, поэтому проблема «изменчивости» не актуальна. Это возвращает нас к JITer. Ясно, что отладка и выпуск будут делать разные вещи, согласился, но я думаю, что полагаться на то, как могут отличаться временные интервалы, отрывочно. Я не могу воспроизвести оператор Джеффа Сира, который может получить исключение в режиме отладки. Даже добавление Thread.Sleep ((new Random ()). Next (0, 1000)) в нескольких ключевых местах не может вызвать исключения ни в режимах отладки, ни в режимах выпуска. Так что это кажется безопасным. Однако я бы предпочел, чтобы где-нибудь об этом было написано исчерпывающее описание. - person Mike Rosenblum; 25.02.2010
comment
@Mike Вы пробовали с двухъядерным процессором? Состояние гонки было бы очень редким с одноядерным процессором, но с двухъядерным я получаю исключение NullReferenceException менее чем за секунду. В моем примере кода, когда я использую if (myEvent != null) myEvent(null, EventArgs.Empty);, в Debug он генерирует исключение для платформ x86 и x64, но в Release он выдает только на платформе x86, странно, а? - person Jeff Cyr; 25.02.2010
comment
@Jeff: Да, для двухъядерных процессоров и, да, определенно странно. Но тот факт, что вы доказали, что эта проблема абсолютно может возникнуть, очень вызывает беспокойство. Отсутствие атрибута MethodImplAttribute противоречит общепринятому мнению, а стоимость его использования практически равна нулю. Следовательно, кто-то должен запустить это через ШАХМАТЫ, скомпилированные для множества платформ. Лучше всего было бы найти, где в спецификации JITer явно говорится о безопасности, но я думаю, что спецификация JITer относительно свободна, и ее точная реализация может измениться в разных версиях. Пока не будет доказано, продолжайте использовать его. - person Mike Rosenblum; 26.02.2010
comment
@Jeff: А, хорошо, вы зацепились за эту проблему, используя x86, скомпилированный в режиме Release, но не в x64. Очень интересно ... Я предполагаю, что JITer, возможно, был обновлен для защиты от этого для x64? Конечно, просто догадываюсь - это может быть не во всех случаях, и даже x64 может быть уязвим во многих обстоятельствах. Было бы замечательно, если бы спецификация JITer четко указала на это - и объяснила разницу между x86 и x64. Пока не будет уверен в том, что здесь происходит, я думаю, что у всех нас нет другого выбора, кроме как продолжать использовать 'MethodImplAttribute'. - person Mike Rosenblum; 26.02.2010
comment
@Mike: Я не уверен, что мы говорим об одном и том же, я постараюсь уточнить. При использовании метода расширения SafeRaise у меня никогда не было исключения NullReferenceException. Исключение генерируется (кроме выпуска x64), когда if (myEvent != null) myEvent(null, EventArgs.Empty); раскомментирован в строке 12 из моего примера кода тестового стенда. Поэтому я не думаю, что MethodImplAttribute требуется для метода расширения. - person Jeff Cyr; 26.02.2010
comment
Да, извините, я неправильно понял. Попался сейчас. Таким образом, проблема существует (как и ожидалось), когда вы не передаете обработчик событий в отдельный метод. Но «MethodImplAttribute» не является необходимым, поэтому я предполагаю, что предполагаемое встраивание не происходит. Но мне интересно, а почему бы и нет? Достаточно ли оператора if внутри метода для предотвращения встраивания? Будет ли такое встраивание невозможным в будущей версии JITer? Я предполагаю, что это безопасно, но я все же предпочел бы быть уверенным, как из любопытства, так и потому, что атрибут почти нулевой стоимости и гарантирует 100% безопасность. - person Mike Rosenblum; 26.02.2010
comment
Опять же, дело не во встраивании. Это вопрос того, можно ли корректно оптимизировать копирование в локальную переменную, чтение из локального, тестовое или локальное для чтения из поля, тестирования, чтения из поля. Я не верю, что это правильная оптимизация. - person Jon Skeet; 26.02.2010
comment
@Jon: Но Джефф отмечает, что эта базовая оптимизация действительно происходит в режиме отладки как для x86, так и для x64 и (что вызывает большее беспокойство) в режиме выпуска для x86, но не в режиме выпуска для x64. Таким образом, они, возможно, отметили это как проблемную оптимизацию и предотвратили это для x64, или эта оптимизация больше не происходит по другим причинам (то есть, это чистая удача). Если такая оптимизация может произойти, то истинную защиту обеспечивает только предотвращение встраивания. Таким образом, мы можем либо полагаться на веру, что JITer не может встраиваться в этом случае, либо выбрать принудительно, что он не может, через атрибут. - person Mike Rosenblum; 26.02.2010
comment
@Jeff: в закомментированном коде myEvent - это захваченная переменная, которая может быть изменена другими потоками. Это не независимая локальная переменная. Я отредактирую свой ответ, чтобы объяснить это. Опять же, это не имеет отношения к встраиванию. - person Jon Skeet; 26.02.2010
comment
@Jon: Я знал это. Мне просто любопытно, почему состояние гонки возникает в версии x86 и никогда не возникает в версии x64. Джиттер x64 должен делать что-то другое, чтобы предотвратить возникновение состояния гонки. Спасибо за ваше время! - person Jeff Cyr; 27.02.2010
comment
@Jeff: Извините, этот комментарий был предназначен для @Mike, а не для вас. Дох! - person Jon Skeet; 27.02.2010
comment
@Jon: Да, я слежу за всем этим, но я не на вашем уровне, поэтому я не могу знать, что можно оптимизировать и / или встроить. Фуив, Лоуи в своей книге начинает с утверждения (возможно, неверно), что EventHandler handler = m_click; if (handler != null){handler(this, EventArgs.Empty)}; можно было бы оптимизировать временную переменную. Затем он переходит к созданию метода RaiseEvent(EventHandler), но затем утверждает, что встраивание может привести нас к тому же самому месту. Затем он объясняет, что MethodImplAttribute - это решение. Думаю, это его первоначальное предположение было неверным. - person Mike Rosenblum; 27.02.2010
comment
@Mike: Да, здесь проблема в исходном предположении. В нормальной ситуации (когда у нас нет захваченных переменных) достаточно использовать локальную переменную. - person Jon Skeet; 27.02.2010
comment
@Jon: Это достаточно хорошо для реализации Microsoft .NET framework. Но я не смог найти в спецификации CLI ничего, что гарантировало бы невозможность такой оптимизации - такая гарантия дается только для изменчивых полей, а не для обычных. - person Daniel; 02.03.2010
comment
@ Дэниел: Я посмотрю, но я бы предположил, что любая такая оптимизация - это полный помешательство. Если вы не можете рассчитывать на то, что значение чисто локальной переменной не изменится между двумя чтениями, то здесь не на что можно положиться. - person Jon Skeet; 02.03.2010

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

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

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

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

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

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

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

person Daniel    schedule 02.03.2010
comment
Дэниел, спасибо за разъяснения ... Я знала, что здесь, должно быть, происходит какое-то разделение волос. Но я не понимаю, как здесь может помочь использование изменчивого поля? Мы уже явно вводим временную переменную, чтобы предотвратить чтение поля во второй раз. Если мы действительно относимся к полю как к изменчивому и признаем, что запись из других потоков возможна, то каждая проверка на null сразу же бесполезна .. Нам пришлось бы просто вызвать делегат без проверки на null и проглотить любое исключение NullReferenceException в пределах try- блок catch (что не очень хорошая идея). - person Mike Rosenblum; 04.03.2010
comment
Вам нужна локальная переменная И изменчивое поле. Одно чтение из изменчивого поля гарантирует чтение только один раз. Чтения из обычных полей не имеют такой гарантии в спецификации CLR (также известной как: оптимизаторы могут вводить чтения и удалять вашу локальную переменную). Но нормальные поля действительно имеют эту гарантию в модели памяти MS, поэтому разница в основном теоретическая (AFAIK даже Mono реализует модель памяти MS). - person Daniel; 04.03.2010

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

person Vlad    schedule 24.02.2010
comment
+1 По большому счету, этот ответ на самом деле имел бы наибольший смысл, помимо любой специфики этого конкретного случая. Тем не менее, есть исключения, например, при использовании кода отражения, например, при обходе стека. - person Mike Rosenblum; 25.02.2010