Какие предположения должен делать код о модели памяти ЦП и как такие предположения должны быть задокументированы?

Из того, что я читал, архитектуры процессоров Intel обеспечивают более сильную модель памяти, чем требуется в реализациях .net. Насколько правильно для кода использовать гарантии, предоставляемые процессорами Intel, или в какой степени код должен добавлять барьеры памяти, которые не потребуются для реализации Intel, в случае, если код переносится на платформу с более слабой модель памяти? Было бы уместно определить статический класс с методами, например, «выполнить барьер памяти при использовании слабой модели памяти» и потребовать, чтобы код был связан либо с «сильной моделью», либо с версией «слабой модели» этой библиотеки, в зависимости от ситуации? В качестве альтернативы, можно было бы использовать Reflection для генерации такого статического класса при запуске программы таким образом, чтобы JIT-компилятор мог, при использовании сильной модели, "встроить-развернуть" инструкции "барьер памяти, если слабый" до нуля (т. Е. Опустить их полностью из кода JITted)?

Если бы у меня были мои druthers, .net предоставил бы вариант класса MemoryLock с некоторыми операциями с полублокировкой, которые потребовали бы, чтобы все потоки, которые удерживают полублокировку, следовали этой модели памяти с полублокировкой. В системе с очень сильной моделью памяти полублокировки ничего не сделают. В системе с очень слабой моделью памяти любой поток, желающий войти в полублокировку, в которой уже был другой поток, должен был бы ждать, пока либо первый поток не завершит работу, либо его можно будет запланировать с помощью ЦП или ядра (на основе на модели, заданной полублоком), который использовал первый поток. Обратите внимание, что в отличие от обычной блокировки, MemoryLock никогда не может быть заблокирован, поскольку любая комбинация конфликтующих требований блокировки может быть разрешена путем планирования всех потоков для запуска на одном и том же ЦП, и система может освободить любой MemoryLock, удерживаемый потоком, который умирает (поскольку цель из MemoryLock будет защищать ресурсы от доступа способами, которые нарушили бы модель памяти, и мертвый поток, конечно, не может сделать такой доступ).

Конечно, в .net 4.0 такого не существует; учитывая это, как лучше всего справиться с существующей ситуацией? Перенос кода, который разработан для более сильной модели памяти, в систему с более слабой моделью, при отсутствии некоторых средств для принудительного применения более сильной модели, был бы рецептом катастрофы, но добавление большого количества Lock или MemoryBarrier вызовов, которые были бы ненужными для исходная целевая платформа кода не кажется очень привлекательной. Единственный известный мне способ заставить код использовать сильную модель памяти - это установить для каждого потока привязку к процессору. Если бы существовал способ установить параметр процесса, чтобы .net использовала только одно ядро ​​за раз, это могло бы быть полезно (особенно если это означало, что JIT может заменить заблокированные операции с блокировкой шины более быстрыми эквивалентами без блокировки шины) , но единственный известный мне способ установки сродства ЦП ограничит программу использованием определенного выбранного ЦП для всех своих потоков, даже если этот ЦП был сильно загружен другими приложениями, а какой-то другой ЦП бездействовал.

Дополнение

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

// Thread 1 -- Assume that at start SharedPerson points to a Person "Smiley", "George"
  var newPerson = new Person();
  newPerson.LastName = "Simpson";
  newPerson.FirstName = "Bart";
  // MaybeMemoryBarrier1
  SharedPerson = newPerson;

// Thread 2
  var wasPerson = SharedPerson;
  // MaybeMemoryBarrier2
  var wasLastName = wasPerson.FirstName;
  var WasFirstName = wasPerson.LastName;

Насколько я понимаю, даже при отсутствии барьеров памяти, код, работающий на процессоре Intel, гарантирует, что записи не будут повторно упорядочены; следовательно, в теме 2 читаемый человек будет либо «Смайлик», «Джордж», либо «Симпсон», «Барт». Однако модель памяти .net слабее, и программа .net может оказаться запущенной на процессоре, где поток 2 может увидеть неполный объект (поскольку запись в SharedPerson могла произойти до записи в newPerson.FirstName). Добавление барьера памяти в MaybeMemoryBarrier1 позволило бы избежать этой опасности, но барьеры памяти влияют на производительность независимо от того, нужны они на самом деле или нет.

Я не думаю, что минимально необходимая модель памяти .net настолько слабая, чтобы требовать MaybeMemoryBarrier2 в тех случаях, когда поток 2 гарантированно никогда не получит доступ к объекту, на который ссылается SharedPerson, до чтения самого SharedPerson (как это было бы в случае приведенный выше код, поскольку новый экземпляр не подвергается воздействию внешнего кода до того, как он будет сохранен в SharedPerson). С другой стороны, предположим, что ситуация немного изменилась, поэтому Thread 2 создал JobInfo запись, которую затем поместил в очередь для Thread 1 (при условии наличия всех необходимых блокировок и барьеров памяти для самой очереди); после этого процессоры делают:

// Thread 1
  var newJob = JobQueue.GetJob(); // Gets JobInfo that was written by Thread2
  newJob.StartTime = DateTime.Now(); // Eight-byte struct might straddle cache line
                                     // Will never be changed once written
  // MaybeMemoryBarrier1
  CurrentJob = newJob;

// Thread 2
  var wasJob = CurrentJob;
  // MaybeMemoryBarrier2
  var wasStartTime = CurrentJob.StartTime();

Если у потока 1 есть барьер памяти, а у потока 2 нет, есть ли гарантия, что когда поток 2 увидит запись JobInfo, созданную им, появившуюся в CurrentJob, он правильно прочитает свое поле StartTime (и не увидит ни кешированного, ни частичного кэшированное значение, оставшееся с того времени, когда Thread 2 манипулировал этим объектом?


person supercat    schedule 15.06.2012    source источник
comment
Это очень широкий вопрос: можете ли вы привести конкретный пример того, как приложение .NET будет надежно правильно работать на платформе Intel и отказывать (надежно или иначе) на другой платформе? Я не уверен, что такие случаи есть, но если есть, мне было бы интересно увидеть один. Я ожидал, что у вас будет практически нулевое улучшение производительности за счет микрооптимизации модели памяти архитектуры (помимо тех оптимизаций, которые JIT уже сделал бы для любой текущей архитектуры).   -  person Chris Shain    schedule 15.06.2012
comment
@ChrisShain: пример добавлен. На самом деле меня беспокоит не столько производительность, сколько правильность (или, если быть более точным, возможность обеспечить правильность на машинах с потерянной архитектурой, без серьезного снижения производительности на узких). Устранение барьеров памяти между всеми доступами к памяти позволит добиться корректности в любой памяти модель, но может вызвать замедление на порядок.   -  person supercat    schedule 15.06.2012
comment
Есть ли что-нибудь, что явно говорит о том, что сама среда CLR не может переупорядочивать операции чтения и записи (за исключением очевидных ограничений), если ничто явно не препятствует этому (например, нет барьеров для памяти)?   -  person cHao    schedule 15.06.2012
comment
Мой вам совет - всегда защищать разделяемые / передаваемые объекты между потоками с помощью барьеров. В противном случае, даже на x86 / x64, если вы не отметите что-то нестабильным, сгенерированный код можно оптимизировать до неправильного поведения.   -  person Robin Caron    schedule 16.06.2012
comment
@RobinCaron: Конечно, если потоки активно передают данные, барьеры памяти кажутся уместными. Меня больше всего беспокоили ситуации, когда проблемы с потоками могут привести к тому, что неизменяемые данные не будут отображаться как неизменяемые. Например, если два потока могут одновременно пытаться сохранить ссылки на логически эквивалентные логически неизменяемые объекты в одном и том же месте хранения, они могут использовать Interlocked.CompareExchange, чтобы гарантировать победу одного из них, но не имеет значения, если оба хранилища выполняются отдельно. С другой стороны, если проблемы с потоками означают, что некоторые потоки могут не ...   -  person supercat    schedule 16.06.2012
comment
... воспринимать объекты как эквивалентные, наличие обоих хранилищ может быть плохим. Кроме того, если нет какого-либо способа заставить библиотеку времени выполнения рандомизировать последовательность памяти в максимальной степени, разрешенной стандартом (даже сверх того, что обычно делает ЦП), проверка кода, чтобы убедиться, что нет никаких потенциально опасных ситуаций. быть трудным.   -  person supercat    schedule 16.06.2012


Ответы (2)


TL; DR: вы должны писать код только для модели памяти .net; не сильнее.

Верно, что архитектура x86 имеет более сильную модель памяти, чем модель, описанная .net.

Но даже если вы никогда не планируете переносить свой код на другие платформы (например, ARM), вам не следует думать о модели памяти x86. Потому что ваш компилятор и JITer могут выполнять оптимизацию, нарушающую модель x86. Так что вы не в безопасности даже на процессоре Intel.

Например, JIT может решить полностью исключить локальную переменную newPerson в вашем примере, что будет эквивалентно этому коду:

SharedPerson = new Person();
SharedPerson.LastName = "Simpson";
SharedPerson.FirstName = "Bart";

Вы видите, насколько это сломано? Даже с ранее инициализированным SharedPerson поток 2 может видеть FirstName и LastName == null (если он читает до того, как они установлены)! Эта оптимизация совершенно законна и не меняет однопоточного поведения.

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

Чтобы атомарно опубликовать ссылку на другие потоки, вы должны использовать изменчивую запись. Если SharedPerson нестабилен, ваш код в порядке (нет необходимости в дополнительных явных барьерах памяти). И обратите внимание, что на x86 изменчивая запись - это просто обычная запись, поэтому она предоставляется «бесплатно»: среда выполнения не добавляет никаких инструкций. Но он запрещает оптимизацию средой выполнения .net (приведенный выше пример становится недопустимым, потому что никакая предыдущая операция с памятью не может перемещаться после энергозависимой записи. Поэтому .LastName и .FirstName должны быть назначены до того, как произойдет энергозависимая запись) .

person jods    schedule 01.06.2013
comment
Я могу понять вашу точку зрения о возможности переупорядочения компилятора, хотя было бы неудачно, если бы единственный способ предотвратить такую ​​оптимизацию компилятора заключался бы в добавлении барьера памяти, поскольку барьеры памяти могут иметь значительные затраты времени выполнения даже [ особенно] в тех случаях, когда они не нужны. - person supercat; 03.06.2013
comment
Я обратился к этому в своем ответе. Вы можете предотвратить эту оптимизацию, используя изменчивый SharedPerson. Неустойчивое поле имеет ноль накладных расходов на x86-x64, сгенерированный собственный код такой же, как и обычное поле. - person jods; 03.06.2013
comment
Я думал, что объявление изменчивого поля в C # не только предотвратит переупорядочение, но и заставит компилятор добавить барьеры памяти для чтения и записи, поскольку бывают случаи, когда барьеры памяти необходимы даже в модели памяти x86. - person supercat; 03.06.2013
comment
Насколько я понимаю, изменчивая семантика была смоделирована после архитектуры x86. В отличие от Java, которая теперь обеспечивает полный барьер памяти для изменчивого доступа, .net обеспечивает только семантику получения при чтении и освобождение при записи. Эти полузаборы означают, что энергозависимое чтение может продвинуться дальше предыдущей энергозависимой записи. Если это важно в вашем случае, вы должны вручную добавить полный барьер памяти между ними. На других архитектурах с более слабыми моделями памяти (например, Itanium) для энергозависимого доступа требуется другая инструкция, чем для обычного доступа. - person jods; 03.06.2013
comment
Вы можете прочитать эту статью: Модель памяти C # в теории и на практике, часть 2, особенно раздел "Реализация модели памяти C # на x86-x64". Цитирую: одна интересная мелочь заключается в том, что семантика изменчивости C # близко соответствует гарантиям переупорядочения оборудования, обеспечиваемым оборудованием x86-x64. В результате для чтения и записи изменчивых полей не требуется специальных инструкций на x86: достаточно обычных операций чтения и записи (например, с использованием инструкции MOV). - person jods; 03.06.2013
comment
Просто раскопайте это, чтобы сказать, что модель памяти Java в отношении энергозависимого чтения / записи не является полным барьером памяти, она приобретает / освобождает. Таким образом, недостаточно просто читать или просто писать с использованием изменчивого поля, вы должны писать, а затем читать то же поле для синхронизации. По сути, то же, что и в ECMA .NET. - person acelent; 28.11.2016

Я не верю, что ваше понимание правильное. Модель памяти .NET, похоже, позволяет переупорядочивать хранилища, то есть на каком-то несуществующем ЦП с чрезвычайно слабой моделью памяти SharedPerson может быть сохранен потоком 1 до того, как будут сохранены его члены FirstName и LastName, что приведет к "Bart" / null или null / "Симпсон" или даже null / null. Но я не верю, что слабая модель памяти может привести к несогласованной записи («Джордж» / «Симпсон») в вашем примере, учитывая, что thread2 создает локальную ссылку на SharedPerson и читает отсюда, в то время как поток1 выполняет атомарную замену SharedPerson новым экземпляром.

В спецификации интерфейса командной строки говорится:

Соответствующий интерфейс командной строки должен гарантировать, что доступ для чтения и записи к правильно выровненным ячейкам памяти, не превышающим собственный размер слова (размер типа native int), является атомарным (см. §12.6.2), когда все доступы для записи в местоположение одинаковы. размер

Тем не менее, насколько мне известно, такой модели памяти не существует ни на одной поддерживаемой платформе, и блог Криса Брамме здесь предлагает подобное.

person Chris Shain    schedule 15.06.2012
comment
Непоследовательное появление могло бы произойти в сценарии, когда за некоторое время до того, как любой из указанных кодов для любого потока был запущен, некоторый код в потоке 2 прочитал объект, на который в конечном итоге будет ссылаться SharedPerson (так что Smiley, George был в кэше этого потока, возможно, охватывая граница строки кэша), и через некоторое время после этого чтения одна из строк кеша, содержащая часть этого экземпляра, стала недействительной (поэтому George все еще был кэширован, а Smiley нет). Если SharedName относится к экземпляру объекта, который ранее использовался в потоке 2, ЦП должен только ... - person supercat; 15.06.2012
comment
... прочитать часть объекта, которая больше не кэшировалась. Как я уже отмечал, этой опасности можно избежать, если не перерабатывать предметы. С другой стороны, кажется несколько неприятным, что код, подобный тому, что показан в Thread 2, будет зависеть от SharedPerson, не указывающего на переработанный объект. Хотя для рециркуляции обычно требуются замки, замки, необходимые для рециркуляции, не обязательно предотвращают указанную выше проблему. - person supercat; 15.06.2012
comment
Кроме того, даже если никакие существующие реализации .net не будут переупорядочивать записи, любой код, который предполагает, что записи не будут переупорядочены, может быть нарушен при любой реализации .net, которая делает то, что реализациям явно разрешено делать. - person supercat; 15.06.2012
comment
Нет, здесь вы точно ошибаетесь. Thread2 при выполнении var wasPerson = SharedPerson; либо будет выполнять чтение SharedPerson из основной памяти, либо нет (если SharedPerson уже кэширован). В любом случае значение исходного экземпляра SharedPerson не изменяется в вашем примере. Все, что происходит, - это когда-нибудь, когда (или после) thread1 выполняет SharedPerson = newPerson;, значение ссылки в основной памяти изменяется, чтобы указывать на новый экземпляр (в другом месте памяти, опять же в основной памяти). Это атомарная операция. - person Chris Shain; 15.06.2012
comment
... продолжение сверху ... Когда поток 2 читает SharedPerson из основной памяти, он либо получает исходный экземпляр SharedPerson (со ссылками на строки Джорджа Смайли), либо новый экземпляр (ссылки которого могут быть или не быть установлены на Барта Симпсона. , но никогда не устанавливаются на Джорджа Смайли). Я думаю, что вы работаете в предположении, что var wasPerson = SharedPerson; создает переменную, значение которой будет обновляться при изменении SharedPerson, а это не так. - person Chris Shain; 15.06.2012
comment
Вы правы - мой пример в комментариях был ошибочным. Предположим, что общее поле указывает на запись JobInfo, которая должна предоставлять информацию о задании, выполняемом в настоящее время потоком 1. Поток 2 генерирует запись JobInfo и помещает ее в очередь; Поток 1 через некоторое время считывает его из очереди, обновляет некоторые поля (например, JobStartTime, RemoteServerTaskID) в этой записи, выполняет барьер памяти, а затем устанавливает общее поле CurrentJob, указывающее на него. Гарантирует ли слабая модель памяти, что если поток 2 увидит обновление до CurrentJob, он также увидит ... - person supercat; 15.06.2012
comment
... обновления полей, которые были выполнены потоком 1, независимо от того, имеет ли поток 2 собственный барьер памяти (и не будет пытаться использовать кешированные значения с момента последнего обращения к этому объекту)? - person supercat; 15.06.2012
comment
Вот где я не уверен - теоретически очень слабая модель памяти может позволить thread1 переупорядочить обновления задания до того, как оно будет установлено на CurrentJob, но сейчас мы говорим о полностью теоретической ситуации. - person Chris Shain; 15.06.2012
comment
позвольте нам продолжить это обсуждение в чате - person supercat; 16.06.2012