Требуется ли блокировка при отложенной инициализации для глубоко неизменяемого типа?

StackOverflow https://stackoverflow.com/questions/652195

Вопрос

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

Я хотел бы реализовать отложенное инициализированное свойство для типа, например, так:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            m_PropName = temp;
        }
        return m_PropName;
    }
}

Из того, что я могу сказать:

m_PropName = temp; 

... является потокобезопасным.Я не слишком беспокоюсь о том, что два потока будут инициализироваться одновременно, потому что это будет редкостью, оба результата будут идентичны с логической точки зрения, и я бы предпочел не использовать блокировку, если в этом нет необходимости.

Сработает ли это?Каковы плюсы и минусы?

Редактировать: Спасибо за ваши ответы.Вероятно, я продолжу использовать блокировку.Однако я удивлен, что никто не упомянул о возможности того, что компилятор поймет, что переменная temp не нужна, и просто назначит прямо m_PropName .Если бы это было так, то поток чтения, возможно, мог бы прочитать объект, создание которого еще не завершено.Предотвращает ли компилятор такую ситуацию?

(Ответы, похоже, указывают на то, что среда выполнения не позволит этому произойти.)

Редактировать: Поэтому я решил использовать взаимосвязанный метод CompareExchange, вдохновленный эта статья Джо Даффи.

В основном:

private ReadOnlyCollection<SomeImmutableType> m_PropName = null;
public ReadOnlyCollection<SomeImmutableType> PropName
{
    get
    {
        if(null == m_PropName)
        {
            ReadOnlyCollection<SomeImmutableType> temp = /* do lazy init */;
            System.Threading.Interlocked(ref m_PropName, temp, null);
        }
        return m_PropName;
    }
}

Предполагается, что это гарантирует, что все потоки, вызывающие этот метод в данном экземпляре объекта, получат ссылку на один и тот же объект, поэтому оператор == будет работать.Возможно, работа будет потрачена впустую, и это прекрасно - это просто делает этот алгоритм оптимистичным.

Как отмечено в некоторых комментариях ниже, это зависит от модели памяти .NET 2.0 для работы.В противном случае m_PropName должно быть объявлено изменчивым.

Это было полезно?

Решение

Это сработает.Запись в ссылки на C # гарантированно будет атомарной, как описано в разделе 5.5 спецификация.Вероятно, это все еще не очень хороший способ сделать это, потому что ваш код будет более запутанным для отладки и чтения в обмен на, вероятно, незначительное влияние на производительность.

У Джона Скита отличный Страница о внедрении синглтонов в C #.

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

Редактировать:Как отмечено в комментариях, даже если вы говорите, что не возражаете, если будут созданы 2 версии вашего объекта, эта ситуация настолько нелогична, что этот подход никогда не следует использовать.

Другие советы

Вы должны использовать блокировку.В противном случае вы рискуете получить два экземпляра m_PropName существующий и используемый разными потоками.Во многих случаях это может и не быть проблемой;однако, если вы хотите иметь возможность использовать == вместо того , чтобы .equals() тогда это будет проблемой.Редкие условия гонки - не лучшая ошибка, которую можно иметь.Их трудно отлаживать и воспроизводить.

В вашем коде, если два разных потока одновременно получают ваше свойство PropName (скажем, на многоядерном процессоре), тогда они могут получать разные новые экземпляры свойства, которые будут содержать идентичные данные, но не будут одним и тем же экземпляром объекта.

Одним из ключевых преимуществ неизменяемых объектов является то, что == эквивалентно .equals(), позволяя использовать более производительные == для сравнения.Если вы не синхронизируете при отложенной инициализации, то вы рискуете потерять это преимущество.

Вы также теряете неизменность.Ваш объект будет инициализирован дважды разными объектами (которые содержат одинаковые значения), поэтому поток, который уже получил значение вашего свойства, но который получит его снова, может получить другой объект во второй раз.

Мне было бы интересно услышать другие ответы на этот вопрос, но я не вижу в этом проблемы.Дублирующая копия будет удалена и получит GCed.

Вам нужно создать поле volatile хотя.

Относительно этого:

Однако я удивлен, что никто не упомянул о возможности компилятора осознать, что переменная temp не нужна, и просто присвоить прямо m_PropName.Если бы это было так, то поток чтения мог бы возможно, прочитать объект, который еще не был завершен для создания.Предотвращает ли компилятор такую ситуацию?

Я подумывал упомянуть об этом, но это не имеет никакого значения.Оператор new не возвращает ссылку (и поэтому присвоение полю не происходит) до тех пор, пока конструктор не завершит работу - это гарантируется средой выполнения, а не компилятором.

Однако язык / среда выполнения на самом деле НЕ гарантирует, что другие потоки не смогут увидеть частично созданный объект - это зависит от того, что делает конструктор.

Обновить:

ОП также задается вопросом, является ли на этой странице есть полезная идея.Их окончательный фрагмент кода является экземпляром Дважды проверенный замок это классический пример идеи, которую тысячи людей рекомендуют друг другу, не имея ни малейшего представления о том, как это сделать правильно.Проблема в том, что SMP-машины состоят из нескольких процессоров со своими собственными кэшами памяти.Если бы им приходилось синхронизировать свои кэши каждый раз при обновлении памяти, это свело бы на нет преимущества наличия нескольких процессоров.Таким образом, они синхронизируются только при "барьере памяти", который возникает, когда снимается блокировка, или происходит операция блокировки, или volatile доступен доступ к переменной.

Обычный порядок событий таков:

  • Кодировщик обнаруживает дважды проверенную блокировку
  • Программист обнаруживает барьеры памяти

В промежутке между этими двумя событиями они выпускают много неработающего программного обеспечения.

Кроме того, многие люди верят (как и этот парень), что вы можете "устранить блокировку", используя взаимосвязанные операции.Но во время выполнения они являются барьером памяти и поэтому заставляют все процессоры останавливаться и синхронизировать свои кэши.У них есть преимущество перед блокировками в том, что им не нужно выполнять вызов в ядро ОС (они являются только "пользовательским кодом"), но они могут снизить производительность точно так же, как и любой метод синхронизации.

Краткие сведения:многопоточный код выглядит примерно в 1000 раз проще для написания, чем он есть на самом деле.

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

Я думаю, что здесь забывается ключевая концепция:В соответствии с концепциями дизайна C #, вы не должны делать потокобезопасными элементы вашего экземпляра по умолчанию. По умолчанию только статические элементы должны быть потокобезопасными.Если вы не получаете доступ к каким-либо статическим / глобальным данным, вам не следует добавлять дополнительные блокировки в свой код.

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

Кстати, это может не сильно сократить объем кода, но я поклонник оператора null-coalesce .Тело для вашего добытчика могло бы стать этим вместо:

m_PropName = m_PropName ?? new ...();
return m_PropName;


Это избавляет от лишнего "if (m_PropName == null) ..." и, на мой взгляд, делает его более кратким и читабельным.

Я не эксперт по C #, но, насколько я могу судить, это создает проблему только в том случае, если вы требуете, чтобы был создан только один экземпляр ReadOnlyCollection.Вы говорите, что созданный объект всегда будет одним и тем же, и не имеет значения, создают ли два (или более) потока новый экземпляр, поэтому я бы сказал, что это нормально делать без блокировки.

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

К сожалению, вам нужна блокировка.Есть много довольно тонких ошибок, когда вы не блокируете должным образом.Для устрашающего примера посмотрите на это ответ.

Можно безопасно использовать отложенную инициализацию без блокировки, если поле будет записано только в том случае, если оно либо пустое, либо уже содержит либо значение, которое должно быть записано или, в некоторых случаях, эквивалент.Обратите внимание, что никакие два изменяемых объекта не эквивалентны;поле, содержащее ссылку на изменяемый объект, может быть записано только со ссылкой на тот же объект (это означает, что запись не будет иметь никакого эффекта).

Существует три общих шаблона, которые можно использовать для отложенной инициализации, в зависимости от обстоятельств:

  1. Используйте блокировку, если вычисление значения для записи было бы дорогостоящим, и вы хотите избежать ненужных затрат таких усилий.Шаблон блокировки с двойной проверкой хорош в системах, модель памяти которых его поддерживает.
  2. Если кто-то хранит неизменяемое значение, вычислите его, если это кажется необходимым, и просто сохраните его.Другие потоки, которые не видят хранилище, могут выполнить избыточное вычисление, но они просто попытаются записать поле со значением, которое уже есть.
  3. Если кто-то хранит ссылку на дешевый в создании объект изменяемого класса, создайте новый объект, если это кажется необходимым, а затем используйте `Interlocked.CompareExchange`, чтобы сохранить его, если поле все еще пустое.

Обратите внимание, что если можно избежать блокировки любого доступа, отличного от первого, в потоке, то обеспечение потокобезопасности lazy reader не должно приводить к каким-либо значительным затратам на производительность.Хотя изменяемые классы обычно не являются потокобезопасными, все классы, которые утверждают, что являются неизменяемыми, должны быть на 100% потокобезопасными для любой комбинации действий чтения.Любой класс, который не может удовлетворить такому требованию потокобезопасности, не должен претендовать на неизменяемость.

Это определенно проблема.

Рассмотрим этот сценарий:Поток "A" обращается к свойству, и коллекция инициализируется.Прежде чем назначить локальный экземпляр полю "m_PropName", поток "B" обращается к свойству, за исключением того, что оно завершается.Поток "B" теперь имеет ссылку на этот экземпляр, который в данный момент хранится в "m_PropName"...пока поток "A" не продолжится, в этот момент "m_PropName" перезаписывается локальным экземпляром в этом потоке.

Сейчас есть пара проблем.Во-первых, у потока "B" больше нет правильного экземпляра, поскольку объект-владелец считает, что "m_PropName" является единственным экземпляром, однако он выпустил инициализированный экземпляр, когда поток "B" завершился раньше потока "A".Другой - если коллекция изменилась между тем, как потоки "A" и "B" получили свои экземпляры.Тогда у вас неверные данные.Могло бы быть даже хуже, если бы вы наблюдали или изменяли коллекцию, доступную только для чтения, внутренне (чего, конечно, вы не можете с помощью ReadOnlyCollection, но могли бы, если бы вы заменили ее какой-либо другой реализацией, которую вы могли бы наблюдать через события или изменять внутренне, но не внешне).

Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top