Требуется ли блокировка при ленивой инициализации для глубоко неизменяемого типа? - PullRequest
10 голосов
/ 17 марта 2009

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

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

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. Если бы это было так, то поток чтения мог бы прочитать объект, который еще не закончен. Предотвращает ли компилятор такую ​​ситуацию?

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

Edit: Поэтому я решил использовать метод Interlocked 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 должно быть объявлено как volatile.

Ответы [ 8 ]

5 голосов
/ 17 марта 2009

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

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

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

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

5 голосов
/ 17 марта 2009

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

У Джона Скита есть отличная страница по реализации одиночных кнопок в C #.

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

РЕДАКТИРОВАТЬ: Как отмечено в комментариях, даже если вы говорите, что не возражаете, если две версии вашего объекта будут созданы, эта ситуация настолько нелогична, что этот подход никогда не должен использоваться.

5 голосов
/ 17 марта 2009

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

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

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

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

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

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

Обновление:

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

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

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

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

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

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

1 голос
/ 18 марта 2009

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

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

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

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

m_PropName = m_PropName ?? new ...(); <br> return m_PropName;

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

0 голосов
/ 10 октября 2012

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

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

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

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

0 голосов
/ 17 марта 2009

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

0 голосов
/ 17 марта 2009

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

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

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

0 голосов
/ 17 марта 2009

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

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...