Является ли нулевой коалесцирующий оператор (??) в С# поточно-безопасным?
Есть ли условие гонки в следующем коде, которое может привести к 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
Мы полагаем, что это безопасно, потому что это одна линия? Обычно это не так. Вы действительно должны использовать оператор блокировки перед доступом к любой разделяемой памяти.