Можно ли наблюдать частично построенный объект из другого потока?
Я часто слышал, что в модели памяти.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
и (в большинстве случаев) просто продолжить мою работу?
3 ответа
Я часто слышал, что в модели памяти.NET 2.0 записи всегда используют ограждения выпуска. Это правда?
Это зависит от того, на какую модель вы ссылаетесь.
Во-первых, давайте точно определим барьер для ограждения. Семантика освобождения предусматривает, что никакое другое чтение или запись, появляющиеся перед барьером в последовательности команд, не могут двигаться после этого барьера.
- Спецификация ECMA имеет упрощенную модель, в которой записи не предоставляют эту гарантию.
- Где-то упоминалось, что реализация CLR, предоставляемая Microsoft, усиливает модель, делая записи семантикой ограничения выпуска.
- Архитектуры x86 и x64 усиливают модель, создавая барьеры "запись-забор" и "барьеры-захват".
Поэтому возможно, что другая реализация CLI (например, Mono), работающая на эзотерической архитектуре (например, ARM, на которую теперь будет ориентироваться Windows 8), не обеспечит семантику ограничения выпуска при записи. Обратите внимание, что я сказал, что это возможно, но не уверен. Но между всеми играющими моделями памяти, такими как различные программные и аппаратные уровни, вы должны написать код для самой слабой модели, если вы хотите, чтобы ваш код был действительно переносимым. Это означает кодирование по модели ECMA и не делать никаких предположений.
Мы должны сделать список слоев модели памяти в игре просто явным.
- Компилятор: C# (или VB.NET или любой другой) может перемещать инструкции.
- Время выполнения: Очевидно, что среда выполнения CLI через JIT-компилятор может перемещать инструкции.
- Аппаратное обеспечение: И, конечно, архитектура процессора и памяти также играет роль.
Означает ли это, что даже без явных барьеров или блокировок памяти невозможно наблюдать частично построенный объект (с учетом только ссылочных типов) в потоке, отличном от того, в котором он создан?
Да (квалифицировано): Если среда, в которой выполняется приложение, достаточно неясна, то возможно, что частично созданный экземпляр будет наблюдаться из другого потока. Это одна из причин, по которой дважды проверенная схема блокировки была бы небезопасной без использования volatile
, В действительности, однако, я сомневаюсь, что вы когда-нибудь столкнетесь с этим главным образом потому, что реализация CLI от Microsoft не будет переупорядочивать инструкции таким образом.
Можно ли с помощью следующего кода наблюдать какие-либо выходные данные, кроме "John 20" и "Jack 21", скажем, "null 20" или "Jack 0"?
Опять же, это квалифицировано да. Но по какой-то причине, как указано выше, я сомневаюсь, что вы когда-нибудь будете наблюдать такое поведение.
Хотя, я должен отметить, что, потому что person
не помечен как volatile
Вполне возможно, что ничего не печатается вообще, потому что поток чтения всегда может видеть это как null
, В действительности, однако, я уверен, что Console.WriteLine
вызов заставит компиляторы C# и JIT избежать операции подъема, которая в противном случае могла бы сдвинуть чтение person
вне петли. Я подозреваю, что вы уже хорошо знаете этот нюанс.
Означает ли это также, что я могу просто сделать все общие поля глубоко неизменяемых ссылочных типов изменчивыми и (в большинстве случаев) продолжать свою работу?
Я не знаю. Это довольно загруженный вопрос. Мне неудобно отвечать в любом случае без лучшего понимания контекста, стоящего за этим. Что я могу сказать, это то, что я обычно избегаю volatile
в пользу более четких инструкций памяти, таких как Interlocked
операции, Thread.VolatileRead
, Thread.VolatileWrite
, а также Thread.MemoryBarrier
, Опять же, я также стараюсь вообще избегать кода без блокировки в пользу механизмов синхронизации более высокого уровня, таких как lock
,
Обновить:
Один из способов визуализации вещей - предположить, что компилятор C#, JITer и т. Д. Будут оптимизированы настолько агрессивно, насколько это возможно. Это означает, что Person.ctor
может быть кандидатом на встраивание (так как это просто), что приведет к следующему псевдокоду.
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
присваивается до его инициализации. Это верно, потому что с точки зрения исполняющего потока логическая последовательность остается согласованной с физической последовательностью. Там нет непреднамеренных побочных эффектов. Но по причинам, которые должны быть очевидны, эта последовательность была бы катастрофической для другого потока.
Ну, по крайней мере на уровне IL, конструктор вызывается непосредственно в стеке, а результирующая ссылка не генерируется (и может быть сохранена) до завершения построения. Как таковой, он не может быть переупорядочен на уровне (IL) компилятора (для ссылочных типов.)
Что касается уровня джиттера, я не уверен, хотя меня удивило бы, если бы он переупорядочил назначение поля и вызов метода (именно таков конструктор.) Будет ли компилятор действительно смотреть на метод и все его возможные пути выполнения чтобы убедиться, что поле никогда не используется вызываемым методом?
Аналогично, на уровне ЦП, я был бы удивлен, если бы переупорядочение происходило вокруг инструкции перехода, поскольку ЦП не может узнать, является ли ветвь "подпрограммным вызовом", и поэтому вернется к следующей инструкции. Выполнение не по порядку допускало бы крайне некорректное поведение в случае "нестандартных" прыжков.
У тебя нет надежды. Замените вашу консольную запись проверкой ошибок, установите дюжину копий Thread1(), используйте машину с 4 ядрами, и вы обязательно найдете несколько частично созданных экземпляров Person. Используйте гарантированные методы, упомянутые в других ответах и комментариях, чтобы сохранить вашу программу в безопасности.
И люди, которые пишут компиляторы, и люди, которые создают процессоры, в своих стремлениях к большей скорости сговариваются, чтобы ухудшить ситуацию. Без явных инструкций, чтобы сделать иначе, люди компилятора будут переупорядочивать ваш код любым способом, чтобы сэкономить наносекунду. Процессор люди делают то же самое. Последнее, что я прочитал, одно ядро, как правило, может выполнять 4 инструкции одновременно. (А может даже если не сможет.)
При нормальных обстоятельствах у вас редко будут проблемы с этим. Однако я обнаружил, что незначительная проблема, которая появляется только раз в 6 месяцев, может быть действительно серьезной проблемой. И, что интересно, проблема "один раз в миллиард" может возникать несколько раз в минуту, что гораздо предпочтительнее. Я предполагаю, что ваш код попадет в последнюю категорию.