Синглтон дважды проверяет проблему параллелизма

Следующее предложение взято из jetbrains.net. После прочтения этой и некоторых других статей в Интернете я до сих пор не понимаю, как можно вернуть значение null после того, как первый поток войдет в блокировку. Кто-то, кто понимает это, может помочь мне и объяснить это более гуманным способом?

"Рассмотрим следующий фрагмент кода:

public class Foo
{
  private static Foo instance;
  private static readonly object padlock = new object();

  public static Foo Get()
  {
    if (instance == null)
    {
      lock (padlock)
      {
        if (instance == null)
        {
          instance = new Foo();
        }
      }
    }
    return instance;
  }
};

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

Чтобы избежать этого, значение экземпляра должно быть изменчивым. "

2 ответа

Решение

Возврате null это не проблема. Проблема в том, что новый экземпляр может находиться в частично сконструированном состоянии, как это воспринимается другим потоком. Рассмотрим эту декларацию Foo,

class Foo
{
  public int variable1;
  public int variable2;

  public Foo()
  {
    variable1 = 1;
    variable2 = 2;
  }
}

Вот как код может быть оптимизирован компилятором C#, JIT или аппаратным обеспечением. 1

if (instance == null)
{
  lock (padlock)
  {
    if (instance == null)
    {
      instance = alloc Foo;
      instance.variable1 = 1; // inlined ctor
      instance.variable2 = 2; // inlined ctor
    }
  }
}
return instance;

Во-первых, обратите внимание, что конструктор встроен (потому что это было просто). Теперь, надеюсь, легко увидеть, что instance получает ссылку до того, как его составляющие поля инициализируются внутри конструктора. Это правильная стратегия, потому что чтение и запись могут свободно перемещаться вверх и вниз, если они не пересекают границы lock или изменить логический поток; что они не делают. Так что другой поток мог видеть instance != null и попытайтесь использовать его до полной инициализации.

volatile устраняет эту проблему, потому что он рассматривает чтение как ограничение получения и запись как ограничение выпуска.

  • acqu-fence: барьер памяти, в котором другие объекты чтения и записи не могут перемещаться перед ограничителем.
  • release-fence: барьер памяти, в котором другие объекты чтения и записи не могут двигаться за забором.

Так что, если мы отметим instance как volatile тогда ограждение препятствует вышеуказанной оптимизации. Вот как будет выглядеть код с аннотациями барьера. Я использовал стрелку to для обозначения защитного ограждения и стрелку ↓ для обозначения защитного ограждения. Обратите внимание, что ничто не может плавать за стрелкой or или вверх за стрелкой ↓. Думайте о наконечнике стрелы как о отталкивающем все.

var local = instance;
↓ // volatile read barrier
if (local == null)
{
  var lockread = padlock;
  ↑ // lock full barrier
  lock (lockread)
  ↓ // lock full barrier
  {
    local = instance;
    ↓ // volatile read barrier
    if (local == null)
    {
      var ref = alloc Foo;
      ref.variable1 = 1; // inlined ctor
      ref.variable2 = 2; // inlined ctor
      ↑ // volatile write barrier
      instance = ref;
    }
  ↑ // lock full barrier
  }
  ↓ // lock full barrier
}
local = instance;
↓ // volatile read barrier
return local;

Записи в составляющие переменные Foo все еще может быть переупорядочен, но обратите внимание, что барьер памяти теперь предотвращает их возникновение после назначения instance, Используя стрелки в качестве руководства, представьте различные стратегии оптимизации, которые разрешены и запрещены. Помните, что никакие операции чтения или записи не могут проходить вниз за стрелкой or или вверх за стрелкой ↓.

Thread.VolatileWrite решил бы и эту проблему и мог бы использоваться на языках без volatile Ключевое слово, как VB.NET. Если вы посмотрите на то, как VolatileWrite реализован, вы бы увидели это.

public static void VolatileWrite(ref object address, object value)
{
  Thread.MemoryBarrier();
  address = value;
}

Поначалу это может показаться нелогичным. В конце концов, барьер памяти помещается перед назначением. Как насчет того, чтобы получить задание, зафиксированное в основной памяти? Не будет ли правильнее поставить барьер после задания? Если это то, что говорит вам ваша интуиция, тогда это неправильно. Вы видите, что барьеры памяти не связаны строго с получением "свежего чтения" или "совершенной записи". Все дело в заказе команд. Это самый большой источник путаницы, который я вижу.

Также может быть важно упомянуть, что Thread.MemoryBarrier на самом деле генерирует полный забор барьер. Так что, если бы я использовал обозначения выше со стрелками, это выглядело бы так.

public static void VolatileWrite(ref object address, object value)
{
  ↑ // full barrier
  ↓ // full barrier
  address = value;
}

Так технически зовет VolatileWrite делает больше, чем пишет volatile поле будет делать. Помни что volatile не допускается в VB.NET, например, но VolatileWrite является частью BCL, поэтому его можно использовать на других языках.


1 Эта оптимизация в основном теоретическая. Спецификация ECMA технически допускает это, но реализация CLI Microsoft спецификации ECMA рассматривает все записи так, как если бы они уже имели семантику ограничения выпуска. Вполне возможно, что другая реализация CLI все еще может выполнить эту оптимизацию, хотя.

Билл Пью написал несколько статей на эту тему, и является ссылкой на эту тему.

Примечательной ссылкой является декларация "Двойная проверка блокируется".

Грубо говоря, вот в чем проблема:

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

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

В Java синхронизацию можно принудительно synchronized, Это дорого, и альтернатива объявлению поля как volatile в этом случае изменение этой ячейки всегда видно для всех потоков.

НО, семантика изменчивых изменений между Java 4 и 5. В Java 4 вам нужно определить оба a, а также ref как изменчивый, для проверки doulbe, чтобы работать в примере, который я описал.

Это не было интуитивно понятно, и большинство людей ref как изменчивый. Таким образом, они изменяют это и в Java 5+, если изменяемое поле изменено (ref) запускает синхронизацию других измененных полей (a).

РЕДАКТИРОВАТЬ: Я вижу только сейчас, когда вы спрашиваете C#, а не Java... Я оставляю свой ответ, потому что, возможно, это все же полезно.

Другие вопросы по тегам