Есть ли условие гонки в этой общей схеме, используемой для предотвращения исключения NullReferenceException?

Я спросил этот вопрос и получил strong > интересный (и немного обескураживающий) ответ.

Даниэль заявляет в своем ответе (если я не читаю его неправильно), что спецификация ECMA-335 CLI может позволить компилятору сгенерировать код, который выдает NullReferenceException из следующего DoCallback метод.

class MyClass {
    private Action _Callback;
    public Action Callback { 
        get { return _Callback; }
        set { _Callback = value; }
    }
    public void DoCallback() {
        Action local;
        local = Callback;
        if (local == null)
            local = new Action(() => { });
        local();
    }
}

Он говорит, что для того, чтобы гарантировать, что NullReferenceException не выбрано, ключевое слово volatile должно использоваться на _Callback или lock должно использоваться вокруг строки local = Callback;.

Может ли кто-нибудь подтвердить это? И, если это правда, есть ли разница в поведении между компиляторами Моно и .NET по этой проблеме?

Изменить
Вот ссылка на standard.

Обновить
Я думаю, что это уместная часть спецификации (12.6.4):

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

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

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

Итак... Мне интересно, поддерживает ли этот оператор компилятор для оптимизации свойства Callback (который обращается к простому полю) и переменной local, чтобы создать следующее, которое имеет тот же поведение в одном потоке выполнения:

if (_Callback != null) _Callback();
else new Action(() => { })();

Раздел 12.6.7 в ключе volatile, похоже, предлагает решение для программистов, желающих избежать оптимизации:

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

Ответ 1

В CLR через С# (стр. 264-265) Джеффри Рихтер обсуждает эту конкретную проблему и признает, что локальная переменная может быть заменена:

[T] его код может быть оптимизирован компилятором, чтобы полностью удалить локальную переменную [...]. Если это произойдет, эта версия кода идентична [версии, которая ссылается на событие/обратный вызов непосредственно дважды], поэтому возможно < <20 > .

Рихтер предлагает использовать Interlocked.CompareExchange<T>, чтобы окончательно решить эту проблему:

public void DoCallback() 
{
    Action local = Interlocked.CompareExchange(ref _Callback, null, null);
    if (local != null)
        local();
}

Однако, Рихтер признает, что компилятор Microsoft точно в срок (JIT) не оптимизирует локальную переменную; и, хотя теоретически это может измениться, это почти наверняка никогда не будет, потому что это приведет к тому, что в результате слишком много приложений сломается.

Этот вопрос уже задан и подробно ответил на вопрос "Разрешенная оптимизация компилятора С# для локальных переменных и выборка из памяти". Обязательно прочитайте ответ xanatox и Понять влияние методов Low-Lock в Многопользовательские приложения ". Поскольку вы спросили конкретно о Mono, вы должны обратить внимание на ссылку "[Mono-dev] Memory Model? "сообщение списка рассылки:

Сейчас мы предоставляем свободную семантику, близкую к ecma, поддерживаемую архитектурой, в которой вы работаете.

Ответ 2

Этот код будет не выдавать исключение с нулевой ссылкой. Это безопасный поток:

public void DoCallback() {
    Action local;
    local = Callback;
    if (local == null)
        local = new Action(() => { });
    local();
}

Причина, по которой этот поток является потокобезопасным, и не может вызывать исключение NullReferenceException при обратном вызове, копирует ли он локальную переменную, прежде чем делать это null check/call. Даже если исходный Callback был установлен на null после нулевой проверки, локальная переменная все равно будет действительна.

Однако следующая история:

public void DoCallbackIfElse() {
    if (null != Callback) Callback();
    else new Action(() => { })();
}

В этом случае он рассматривает общедоступную переменную, Callback может быть изменен на null ПОСЛЕ if (null != Callback), который генерирует исключение в Callback();