Какие предположения должен сделать код относительно модели памяти ЦП, и как эти предположения должны быть документированы?
Из того, что я прочитал, архитектуры процессоров Intel обеспечивают более сильную модель памяти, чем требуется для реализации.net. В какой степени для кода целесообразно использовать гарантии, которые дают процессоры Intel, или в какой степени код должен добавить барьеры памяти, которые не потребуются для реализации Intel, в случае, если код переносится на платформу с более слабым модель памяти? Было бы целесообразно определить статический класс с методами, например, "выполнить барьер памяти, если используется модель со слабой памятью", и требовать, чтобы код был связан с версией "сильной модели" или "слабой модели" этой библиотеки в зависимости от ситуации? В качестве альтернативы, можно ли использовать Reflection для генерации такого статического класса при запуске программы таким образом, чтобы компилятор JIT мог при использовании сильной модели "встроить-развернуть" "барьер памяти, если он слабый", инструкции к нулю (т.е. пропустить их полностью из кода JITted)?
Если бы у меня были мои барабанщики, .net предоставил бы вариант MemoryLock
Класс с некоторыми операциями полублокировки, которые потребовали бы, чтобы все потоки, которые содержат полублокировку, следовали модели памяти этого полублокировки. В системе с очень сильной моделью памяти полублокировки ничего не делают. В системе с очень слабой моделью памяти любому потоку, желающему войти в полублокировку, в котором уже есть другой поток, придется ждать, пока не выйдет первый поток, или это может быть запланировано с помощью процессора или ядра (на основе на модель, указанную полублокировкой), которую использовал первый поток. Обратите внимание, что в отличие от обычной блокировки, MemoryLock
никогда не сможет зайти в тупик, так как любая комбинация конфликтующих требований блокировки может быть решена путем планирования всех потоков на одном и том же процессоре, и система может освободить любую MemoryLock
удерживается потоком, который умирает (так как цель MemoryLock
будет защищать ресурсы от доступа способами, которые будут нарушать модель памяти, и мертвый поток, конечно, не может сделать такой доступ).
Конечно, такого не существует в.net 4.0; Учитывая это, каков наилучший способ справиться с ситуацией, которая существует? Миграция кода, предназначенного для более сильной модели памяти, в систему с более слабой моделью при отсутствии некоторых средств для применения более сильной модели может стать причиной катастрофы, но добавит много Lock
или же MemoryBarrier
вызовы, которые не нужны для исходной целевой платформы кода, не кажутся очень привлекательными. Единственный способ, которым я знаю, чтобы код вызывал сильную модель памяти, - это чтобы каждый поток устанавливал свою привязку к процессору. Если бы был способ установить параметр процесса, чтобы.net использовал только одно ядро за раз, это могло бы быть полезным (особенно если это означало, что JIT мог заменить операции с блокировкой шины более быстрыми эквивалентами без блокировки шины), но единственное известное мне средство настройки привязки к ЦП ограничило бы программу использованием определенного выбранного ЦП для всех его потоков, даже если этот ЦП был сильно загружен другими приложениями, а какой-то другой ЦП находился в режиме ожидания.
добавление
Рассмотрим следующий код:
// Поток 1 - Предположим, что при запуске SharedPerson указывает на Person "Smiley", "George" var newPerson = new Person(); newPerson.LastName = "Симпсон"; newPerson.FirstName = "Барт"; // MaybeMemoryBarrier1 SharedPerson = newPerson; // Поток 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
(предполагая все необходимые блокировки и барьеры памяти для самой очереди); после этого процессоры делают:
// Поток 1 var newJob = JobQueue.GetJob(); // Получает JobInfo, который был написан Thread2 newJob.StartTime = DateTime.Now(); // Восьмибайтовая структура может занимать строчку кэша // Никогда не будет изменено после написания // MaybeMemoryBarrier1 CurrentJob = newJob; // Поток 2 var wasJob = CurrentJob; // MaybeMemoryBarrier2 var wasStartTime = CurrentJob.StartTime();
Если поток 1 имеет барьер памяти, а поток 2 - нет, есть ли гарантия, что когда поток 2 увидит JobInfo
запись, которую он создал, появляется в CurrentJob
будет правильно читать StartTime
поле (и не будет видеть кэшированное или частично кэшированное значение, оставшееся со времени Thread 2
манипулировал этим объектом?
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 должны быть назначены до того, как произойдет энергозависимая запись).
Я не верю, что ваше понимание верно. Модель памяти.NET, кажется, позволяет переупорядочивать хранилища, то есть на некоторых несуществующих процессорах с крайне слабой моделью памяти SharedPerson может храниться в thread1 до того, как FirstName
а также LastName
члены сохраняются, в результате чего получается "Bart"/null или null/"Simpson", или даже null/null. Но я не верю, что слабая модель памяти может привести к непоследовательным записям ("Джордж"/"Симпсон") в вашем примере, учитывая, что thread2 создает локальную ссылку на SharedPerson
и читает из этого, в то время как thread1 выполняет атомную замену SharedPerson
с новым экземпляром.
Спецификация CLI гласит:
Соответствующий CLI должен гарантировать, что доступ для чтения и записи к правильно выровненным областям памяти, не превышающим размер собственного слова (размер типа native int), является атомарным (см. §12.6.2), когда все доступы записи в местоположение одинаковы размер
Тем не менее, насколько я знаю, такой модели памяти не существует ни на одной поддерживаемой платформе, и блог Криса Брамма здесь предлагает подобное.