это неправильная реализация из-за отсутствия гарантий того, что разные потоки увидят самое свежее значение свойства IsInitialized.Вопрос в том, «Прав ли я?».
Вы правы, что это неправильная реализация двойной проверки блокировки.Вы ошибаетесь несколькими тонкими способами о том, почему это неправильно.
Во-первых, давайте отвлечем вас от вашей ошибки.
Вера в то, что в многопоточной программе есть «самое свежее» значение любой переменной, является плохим убеждением по двум причинам.Первая причина заключается в том, что да, C # дает гарантии относительно определенных ограничений на то, как операции чтения и записи могут быть переупорядочены.Однако эти гарантии не включают никаких обещаний, что существует глобально согласованный порядок и может быть выведен всеми потоками .Это допустимо в модели памяти C # для чтения и записи переменных и для ограничений на чтение и запись.Но в тех случаях, когда эти ограничения недостаточно сильны, чтобы навязывать ровно один порядок чтения и записи, допустимо, чтобы "канонический" порядок не наблюдался всеми потоками.Разрешается двум потокам соглашаться с тем, что все ограничения были соблюдены, но по-прежнему расходятся во мнениях относительно того, какой порядок был выбран.Это логически означает, что представление о том, что для каждой переменной существует единственное каноническое «самое свежее» значение, просто неверно.Различные потоки могут расходиться во мнениях относительно того, какие записи «более свежие», чем другие.
Вторая причина заключается в том, что даже без этого странного свойства модель допускает, что два потока расходятся во мнениях относительно последовательности операций чтения и записи, все же неверно утверждать, что в любой программе с низкой блокировкой у вас есть способ прочитать «самое свежее» значение.Все примитивные операции, которые у вас есть, гарантируют, что определенные операции записи и чтения не будут перемещаться вперед или назад во времени после определенных точек кода.Ничто там не говорит ничего о «свежем», что бы это ни значило.Лучшее, что вы можете сказать, это то, что некоторые чтения читают значение более свежее .Понятие «свежайший» не определяется моделью памяти.
Другой способ, которым вы ошибаетесь, действительно очень тонкий. Вы проделываете большую работу, рассуждая о том, что может произойти, основываясь на очистке кешей процессоров .Но нигде в документации C # не говорится ни слова о процессорах, очищающих кэш!Это деталь реализации чипа, которая может изменяться каждый раз, когда ваша C # -программа работает на другой архитектуре.Не думайте о том, что процессоры очищают кеши, если вы не знаете, что ваша программа будет работать только на одной архитектуре, и что вы полностью понимаете эту архитектуру.Скорее, рассуждает об ограничениях, наложенных моделью памяти .Мне известно, что документации по модели крайне не хватает, но об этом вы должны рассуждать, потому что это то, от чего вы действительно можете зависеть.
Другой способ ошибиться - хотя да,реализация нарушена, она не нарушена, потому что вы не читаете актуальное значение инициализированного флага.Проблема в том, что инициализированное состояние , которое контролируется флагом, не подлежит ограничениям на перемещение во времени!
Давайте сделаем ваш пример немного болеебетон:
private C c = null;
protected virtual void OnInitialize()
{
c = new C();
}
И сайт использования:
this.InitializeIfRequired();
this.c.Frob();
Теперь мы подошли к проблеме real .Ничто не мешает чтению IsInitialized
и c
перемещаться во времени.
Предположим, что потоки Alpha и Bravo оба выполняют этот код.Нить Браво выигрывает гонку, и первое, что он делает, читает c
как null
.Помните, что это разрешено, потому что нет ограничений на чтение и запись , потому что Bravo никогда не собирается вводить блокировку .
Реально, как это могло произойти? Компилятору C # или джиттеру разрешено перемещать инструкцию чтения ранее, но они этого не делают. Вкратце возвращаясь к реальному миру кэшированных архитектур, чтение c
может быть логически перемещено перед чтением флага, поскольку c
уже находится в кэше. Возможно это было близко к другой переменной, которая была прочитана недавно. Или, возможно, предсказание ветвления предсказывает, что флаг заставит вас пропустить блокировку, и процессор предварительно выберет значение. Но опять же, не имеет значения, каков сценарий реального мира; это все детали реализации чипа. Спецификация C # позволяет выполнять это чтение рано, поэтому предположим, что в какой-то момент оно будет выполнено рано!
Вернемся к нашему сценарию. Мы сразу переключаемся на нить Альфа.
Thread Alpha работает так, как вы ожидаете. Он видит, что флаг говорит, что требуется инициализация, берет блокировку, инициализирует c
, устанавливает флаг и уходит.
Теперь поток Bravo запускается снова, флаг теперь говорит, что инициализация не требуется, и поэтому мы используем версию c
, которую мы читали ранее, и разыменование null
.
Двойная проверка блокировки правильна в C # , если вы строго следуете точной двойной проверке схемы блокировки . В тот момент, когда вы слегка отклоняетесь от него, вы теряете себя в ужасных, невоспроизводимых ошибках в состоянии гонки, как я только что описал. Только не ходи туда:
- Не делить память между потоками. Вывод, который я извлекаю из знания всего, что я только что сказал вам, это Я не достаточно умен, чтобы писать многопоточный код, который разделяет память и работает по проекту . Я достаточно умен, чтобы писать многопоточный код, который работает случайно , и это для меня неприемлемо.
- Если вам необходимо разделить память между потоками, заблокируйте каждый доступ без исключения. Это не так дорого! А знаете что дороже? Работа с рядом невоспроизводимых фатальных сбоев, которые все теряют пользовательские данные.
- Если вам нужно разделить память между потоками и у вас должна быть ленивая инициализация с низкой блокировкой, боже мой, не пишите это самостоятельно. Используйте
Lazy<T>
; он содержит правильную реализацию ленивой инициализации с низкой блокировкой, на которую можно положиться во всех процессорных архитектурах.
Дополнительный вопрос:
Если блокировка (this.mutex) очищает буферы памяти процессоров, тогда все в порядке, но гарантировано ли это?
Чтобы уточнить, этот вопрос касается правильности чтения инициализированного флага в сценарии двойной проверки блокировки. Давайте снова рассмотрим ваши заблуждения здесь.
Инициализированный флаг гарантированно будет читать правильно внутри замка, потому что он записан внутри замка.
Однако правильный способ думать об этом, как я упоминал ранее, это , а не , чтобы рассуждать о сбрасывании кэшей. Правильный способ рассуждать об этом заключается в том, что спецификация C # накладывает ограничения на то, как чтения и записи могут перемещаться во времени относительно блокировок.
В частности, чтение внутри блокировки не может быть перемещено в до блокировки, а запись внутри блокировки не может быть перемещена в после блокировки. Эти факты в сочетании с тем фактом, что блокировки обеспечивают взаимное исключение, достаточны для того, чтобы сделать вывод о правильности чтения инициализированного флага внутри блокировки.
Опять же, если вам некомфортно делать подобные выводы, а мне нет! - тогда не пишите код низкой блокировки.