Является ли нулевой оператор объединения (??) в C # потокобезопасным?

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

-- or --

Возможно ли, чтобы для переменной 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. Я делаю что-то неправильно?


person ken    schedule 12.05.2012    source источник
comment
?? не является тернарным или тернарным оператором. Это оператор объединения с нулем, который является бинарным оператором (т.е. принимает 2 операнда, а не 3). Тернарный условный оператор ?:.   -  person BoltClock    schedule 12.05.2012
comment
О, и возможный дубликат C # '??' потокобезопасный оператор?   -  person BoltClock    schedule 12.05.2012
comment
Дублирование stackoverflow.com/questions / 4619593 /   -  person Rob P.    schedule 12.05.2012


Ответы (4)


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.

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

person Daniel    schedule 12.05.2012
comment
Спасибо! Это наиболее четкий ответ на заданный мной вопрос. Вы говорите, что даже указанный вами эквивалентный метод может вызвать исключение NullReferenceException в модели памяти ECMA-335, если не используется ключевое слово volatile (или блокировка)? - person ken; 13.05.2012
comment
@ken Да. Модель ECMA-335 практически не дает никаких гарантий для несинхронизированного доступа к энергонезависимой памяти. (см. ECMA-335 §12.6.4) - person Daniel; 13.05.2012
comment
Я задал еще один вопрос, относящийся к этому ответу: stackoverflow.com/q/10589565/651789 - person ken; 15.05.2012

Обновлять

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

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

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

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

Чтение и запись следующих типов данных являются атомарными: bool, char, byte, sbyte, short, ushort, uint, int, float и ссылочные типы.

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

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

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

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

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

public int x = 1;

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

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

person Jon    schedule 12.05.2012
comment
+1, да, result = x ?? y это то же самое, что if (x == null) result = y; else result = x; - person Olivier Jacot-Descombes; 12.05.2012
comment
Я не вижу состояния гонки. Возможно, есть некоторые проблемы устаревания, связанные с моделью памяти, но нет простого состояния гонки. - person CodesInChaos; 12.05.2012
comment
Я согласен с @CodeInChaos: вы доказываете, что значение может стать устаревшим или недействительным. - person IAbstract; 12.05.2012
comment
@CodeInChaos: как я вижу, поток может быть вытеснен непосредственно перед тем, как результат выражения будет вызван и Callback изменится в этот момент, что делает Action, который скоро будет вызван, неверным. Разве это не состояние гонки? - person Jon; 12.05.2012
comment
Обратный вызов всегда будет вызываться для согласованного, но потенциально устаревшего значения. Это так же потокобезопасно (небезопасно), как и стандартный шаблон подписки на события. В частности, одним из потенциальных условий гонки является то, что обратный вызов может быть вызван после отмены подписки. - person CodesInChaos; 12.05.2012
comment
@Jon: состояние гонки возникает, когда рабочие задерживают ввод блока кода. - person IAbstract; 12.05.2012
comment
Да, объединение нулей - это процесс с несколькими инструкциями, поэтому он не является потокобезопасным. - person Peter Ritchie; 12.05.2012
comment
То, что CodeInChaos описал в своем ответе (назначьте локальную переменную, затем проверьте локальную на нуль [который может использовать объединение нулей]), является типичным шаблоном, позволяющим избежать условий гонки - person Peter Ritchie; 12.05.2012
comment
@ OlivierJacot-Descombes - Смотрите мое обновление. Не похоже, что вывод IL двух операторов одинаков, если только я не сделал что-то не так ... - person ken; 12.05.2012
comment
@PeterRitchie ?? в исходном коде так же потокобезопасен, как и шаблон, который я описываю в своем ответе. Единственное отличие - производительность и удобочитаемость. - person CodesInChaos; 12.05.2012
comment
Проблема не в обратном вызове. Проблема в том, что другой поток может изменить значение Callback между тестом Callback != null или Callback ?? и его фактическим вызовом. Тест Callback != null может дать true, но к тому времени, когда вы вызовете Callback(), он мог быть установлен в null! Примечание call UserQuery+MyClass.get_Callback получает делегат обратного вызова, но не вызывает его. - person Olivier Jacot-Descombes; 12.05.2012
comment
@Jon - Спасибо за обновление. Мне известно о возможности исключения NullReferenceException в методе DoCallbackIfElse, поэтому я привел его в качестве примера. - person ken; 13.05.2012

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

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

Более ясным способом написания DoCallback было бы:

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

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


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

 public event Action Callback;

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

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


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

person CodesInChaos    schedule 12.05.2012
comment
Почему Callback += someMethod не атомарно, а SomeEvent += someMethod атомарно? - person IAbstract; 12.05.2012
comment
@IAbstract Потому что для событий вы на самом деле не вызываете Callback = Callback + someMethod. Но скорее Callback.Subscribe(someMethod), который для событий, подобных полям, гарантированно будет атомарным. - person CodesInChaos; 12.05.2012
comment
Ах ... да, спасибо за напоминание! ... возможно, вставьте эту дополнительную информацию в свой ответ, чтобы другие знали, почему. :) - person IAbstract; 12.05.2012
comment
Спасибо. Меня не беспокоит устаревание переменной Callback. Меня беспокоит, что в DoCallback возникнет исключение из-за нулевой переменной обратного вызова (см. Мое обновление). Итак, вы говорите, что исключение никогда не будет создано? - person ken; 12.05.2012
comment
@ken Ваш public void DoCallbackIfElse() сломан, может выкинуть NullReferenceException. Ваш исходный код, а мой вариант - нет. Локальная переменная важна, поэтому вы не читаете дважды из свойства Callback. - person CodesInChaos; 12.05.2012
comment
@CodeInChaos - я в курсе. Вот почему я привел его в качестве примера и получившийся IL-код. Некоторые ответы (и комментарии), казалось, подразумевали, что эти два метода должны создавать один и тот же IL, чего на самом деле нет. Теперь я понимаю, что метод DoCallbackCoalesce никогда не вызовет исключение NullReferenceException, что меня и заинтересовало. Спасибо! - person ken; 13.05.2012

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

person gcochard    schedule 12.05.2012