Является ли нулевой коалесцирующий оператор (??) в С# поточно-безопасным?

Есть ли условие гонки в следующем коде, которое может привести к NullReferenceException?

- или -

Возможно ли, чтобы переменная Callback была установлена ​​в значение null после того, как оператор нулевого коалесцирования проверяет нулевое значение, но перед вызовом функции?

class MyClass {
    public Action Callback { get; set; }
    public void DoCallback() {
        (Callback ?? new Action(() => { }))();
    }
}

ИЗМЕНИТЬ

Это вопрос, который возник из любопытства. Обычно я не кодирую этот код.

Меня не волнует, что переменная Callback становится устаревшей. Я беспокоюсь о том, что Exception выбрасывается из DoCallback.

РЕДАКТИРОВАТЬ № 2

Вот мой класс:

class MyClass {
    Action Callback { get; set; }
    public void DoCallbackCoalesce() {
        (Callback ?? new Action(() => { }))();
    }
    public void DoCallbackIfElse() {
        if (null != Callback) Callback();
        else new Action(() => { })();
    }
}

Метод DoCallbackIfElse имеет условие расы, которое может вызывать NullReferenceException. Имеет ли метод DoCallbackCoalesce такое же условие?

И вот вывод IL:

MyClass.DoCallbackCoalesce:
IL_0000:  ldarg.0     
IL_0001:  call        UserQuery+MyClass.get_Callback
IL_0006:  dup         
IL_0007:  brtrue.s    IL_0027
IL_0009:  pop         
IL_000A:  ldsfld      UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate1
IL_000F:  brtrue.s    IL_0022
IL_0011:  ldnull      
IL_0012:  ldftn       UserQuery+MyClass.<DoCallbackCoalesce>b__0
IL_0018:  newobj      System.Action..ctor
IL_001D:  stsfld      UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate1
IL_0022:  ldsfld      UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate1
IL_0027:  callvirt    System.Action.Invoke
IL_002C:  ret         

MyClass.DoCallbackIfElse:
IL_0000:  ldarg.0     
IL_0001:  call        UserQuery+MyClass.get_Callback
IL_0006:  brfalse.s   IL_0014
IL_0008:  ldarg.0     
IL_0009:  call        UserQuery+MyClass.get_Callback
IL_000E:  callvirt    System.Action.Invoke
IL_0013:  ret         
IL_0014:  ldsfld      UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate3
IL_0019:  brtrue.s    IL_002C
IL_001B:  ldnull      
IL_001C:  ldftn       UserQuery+MyClass.<DoCallbackIfElse>b__2
IL_0022:  newobj      System.Action..ctor
IL_0027:  stsfld      UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate3
IL_002C:  ldsfld      UserQuery+MyClass.CS$<>9__CachedAnonymousMethodDelegate3
IL_0031:  callvirt    System.Action.Invoke
IL_0036:  ret    

Мне кажется, что call UserQuery+MyClass.get_Callback вызывается только один раз при использовании оператора ??, но дважды при использовании if...else. Я что-то делаю неправильно?

Ответ 1

public void DoCallback() {
    (Callback ?? new Action(() => { }))();
}

гарантированно эквивалентно:

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

Может ли это вызывать исключение NullReferenceException, зависит от модели памяти. Модель фреймворка Microsoft.NET документирована так, чтобы никогда не вводить дополнительные чтения, поэтому значение, протестированное против null, является тем же значением, которое будет вызываться, и ваш код будет безопасным. Однако модель памяти CLI ECMA-335 менее строгая и позволяет среде выполнения исключать локальную переменную и дважды обращаться к полю Callback (я предполагаю, что это поле или свойство, которое обращается к простому полю).

Вы должны отметить поле Callback volatile, чтобы обеспечить надлежащий барьер памяти - это делает код безопасным даже в слабой модели ECMA-335.

Если это не критический код производительности, просто используйте блокировку (чтение Callback в локальную переменную внутри блокировки достаточно, вам не нужно удерживать блокировку при вызове делегата) - все, что требуется, требует подробных знаний о моделях памяти знать, является ли это безопасным, и точные детали могут измениться в будущих версиях .NET(в отличие от Java, Microsoft не полностью указала модель памяти .NET).

Ответ 2

Update

Если исключить проблему получения устаревшего значения по мере уточнения вашего редактирования, то опция null-coalescing всегда будет работать надежно (даже если точное поведение не может быть определено). Альтернативная версия (если не null затем вызывает ее), однако не будет, и рискует NullReferenceException.

Оператор с нулевым коалесцированием приводит к тому, что Callback оценивается только один раз. Делегаты неизменны:

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

Кроме того, делегаты являются ссылочными типами, поэтому простое чтение или запись гарантировано будет атомарным (спецификация языка С#, пункт 5.5):

Считывание и запись следующих типов данных: atomic: bool, char, байтов, sbyte, short, ushort, uint, int, float и ссылочных типов.

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

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

Все это отражается в IL для двух методов.

Оригинальный ответ

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

public int x = 1;

int y = x == 1 ? 1 : 0;

Принцип тот же: сначала оценивается условие, а затем получается результат выражения (и позже используется). Если что-то происходит, что заставляет условие меняться, это слишком поздно.

Ответ 3

В этом коде не вижу состояния гонки. Существует несколько потенциальных проблем:

  • Callback += someMethod; не является атомарным. Простое назначение.
  • DoCallback может вызывать устаревшее значение, но оно будет непротиворечивым.
  • Проблема с устаревшим значением можно избежать, сохранив блокировку на весь период обратного вызова. Но это очень опасный шаблон, который приглашает мертвые блокировки.

Более четким способом записи DoCallback будет:

public void DoCallback()
{
   var callback = Callback;//Copying to local variable is necessary
   if(callback != null)
     callback();
}

Это также немного быстрее, чем исходный код, поскольку он не создает и не вызывает делегата no-op, если Callback - null.


И вы можете захотеть заменить свойство событием, чтобы получить атомные += и -=:

 public event Action Callback;

При вызове += для свойства происходит следующее: Callback = Callback + someMethod. Это не является атомарным, так как Callback может быть изменено между чтением и записью.

При вызове += в поле, таком как событие, происходит вызов метода Subscribe события. Подписка на события гарантированно будет атомарной для таких событий, как события. На практике для этого используется метод Interlocked.


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

Ответ 4

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