Подтвердить что ты не робот

Можно ли наблюдать частично построенный объект из другого потока?

Я часто слышал, что в модели памяти .NET 2.0 записи всегда используют заборные блоки. Это правда? Означает ли это, что даже без явных барьеров памяти или блокировок невозможно наблюдать частично сконструированный объект (только для ссылочных типов) в потоке, отличном от того, на котором он создан? Я, очевидно, исключаю случаи, когда конструктор теряет ссылку this.

Например, допустим, что у нас был неизменный ссылочный тип:

public class Person
{
    public string Name { get; private set; }
    public int Age { get; private set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

Можно ли со следующим кодом наблюдать любой выход, отличный от "John 20" и "Jack 21", например "null 20" или "Jack 0"?

// We could make this volatile to freshen the read, but I don't want
// to complicate the core of the question.
private Person person;

private void Thread1()
{
    while (true)
    {
        var personCopy = person;

        if (personCopy != null)
            Console.WriteLine(personCopy.Name + " " + personCopy.Age);
    }
}

private void Thread2()
{
    var random = new Random();

    while (true)
    {
        person = random.Next(2) == 0
            ? new Person("John", 20)
            : new Person("Jack", 21);
    }
}

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

4b9b3361

Ответ 1

Я часто слышал, что в модели памяти .NET 2.0 записи всегда используют освободить заборы. Это правда?

Это зависит от того, к какой модели вы обращаетесь.

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

  • Спецификация ECMA имеет расслабленную модель, в которой записи не предоставляют эту гарантию.
  • Было указано, что реализация CLR, предоставляемая Microsoft, усиливает модель, делая записи имеющими семантику освобождения.
  • Архитектуры x86 и x64 укрепили модель, сделав записи барьерами, препятствующими выпуску, и читали барьеры заборного ограждения.

Таким образом, возможно, что другая реализация CLI (например, Mono), работающая на эзотерической архитектуре (например, ARM, для которой сейчас нацелена Windows 8), будет не предоставлять семантику релиза-освобождения при записи. Заметьте, что я сказал, что это возможно, но не обязательно. Но между всеми моделями памяти в игре, такими как разные программные и аппаратные уровни, вам нужно закодировать самую слабую модель, если вы хотите, чтобы ваш код был действительно портативным. Это означает кодирование модели ECMA и отсутствие каких-либо предположений.

Мы должны сделать список слоев модели памяти в игре просто явным.

  • Компилятор: С# (или VB.NET или что-то еще) может перемещать инструкции.
  • Runtime: Очевидно, что время выполнения CLI через JIT-компилятор может перемещать инструкции.
  • Оборудование: И, конечно же, процессор и архитектура памяти также входят в игру.

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

Да (квалифицированный): Если среда, в которой работает приложение, является достаточно узкой, тогда может быть возможно наблюдать частично сконструированный экземпляр из другого потока. Это одна из причин, по которой дважды проверенный шаблон блокировки будет небезопасным без использования volatile. В действительности, однако, я сомневаюсь, что вы столкнулись с этим главным образом из-за того, что реализация CLI в Microsoft не изменит порядок инструкций таким образом.

Было бы возможно, если следующий код будет наблюдать за любым выходом кроме "John 20" и "Jack 21", например "null 20" или "Jack 0"?

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

Хотя, я должен отметить, что, поскольку person не помечен как volatile, возможно, что ничего не печатается вообще, потому что поток чтения всегда может видеть его как null. В действительности, однако, я уверен, что вызов Console.WriteLine заставит компиляторы С# и JIT избежать операции подъема, которая в противном случае могла бы перемещать чтение person вне цикла. Я подозреваю, что вы уже хорошо знаете об этом нюансе.

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

Я не знаю. Это довольно загруженный вопрос. Мне неудобно отвечать в любом случае без лучшего понимания контекста, стоящего за ним. Я могу сказать, что я обычно избегаю использования volatile в пользу более явных инструкций памяти, таких как операции Interlocked, Thread.VolatileRead, Thread.VolatileWrite и Thread.MemoryBarrier. Опять же, я также стараюсь избежать кода без блокировки вообще в пользу механизмов синхронизации более высокого уровня, таких как lock.

Update:

Один из способов, которым я хотел бы визуализировать вещи, - предположить, что компилятор С#, JITer и т.д. оптимизируют как можно более агрессивно. Это означает, что Person.ctor может быть кандидатом на inlining (поскольку он прост), который даст следующий псевдокод.

Person ref = allocate space for Person
ref.Name = name;
ref.Age = age;
person = instance;
DoSomething(person);

И поскольку в спецификации ECMA не существует семантики релиз-забора, тогда другие чтения и записи могут "плавать" ниже назначения до person, давая следующую допустимую последовательность инструкций.

Person ref = allocate space for Person
person = ref;
person.Name = name;
person.Age = age;
DoSomething(person);

Итак, в этом случае вы можете увидеть, что person назначается до его инициализации. Это справедливо, поскольку с точки зрения исполняемого потока логическая последовательность остается согласованной с физической последовательностью. Никаких непредвиденных побочных эффектов нет. Но по причинам, которые должны быть очевидны, эта последовательность будет катастрофической для другого потока.

Ответ 2

У тебя нет надежды. Замените консольную запись на проверку ошибок, установите дюжину копий Thread1(), используйте машину с 4 ядрами, и вы обязательно найдете несколько частично сконструированных экземпляров Person. Используйте гарантированные методы, упомянутые в других ответах и ​​комментариях, чтобы ваша программа была безопасной.

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

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

Ответ 3

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

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

Точно так же на уровне CPU я был бы удивлен, если бы переупорядочение происходило вокруг команды перехода, поскольку у процессора нет возможности узнать, является ли ветка "вызовом подпрограммы" и поэтому вернется к следующей инструкции. Выполнение нестандартного поведения допускает грубо неправильное поведение в случае "нетрадиционных" переходов.