Подпишитесь на мой телеграм-канал, там я пишу о дотнете и веб-разработке.

Друзья:
//devdigest platform - новости и полезные статьи о дотнете.

Volatile, модели и барьеры памяти

November 13, 2019

Сегодня будем разбираться с volatile и всем, что с ним связано. Тема эта интересна тем, что чтобы полностью её понимать необходимо опуститься вплоть до уровня процессора и даже узнать чем отличаются разные процессорные архитектуры в плане работы с памятью. Так как материал объёмный и сложный, то не буду пытаться, что-то объяснить сам, а буду давать ссылки.

Что о volatile нам рассказал Рихтер

У Рихтера в книге для volatile отведёно 7 страниц и этого явно недостаточно, чтобы хорошенько разобраться с темой.

Компилятор C#, JIT-компилятор и даже сам процессор могут оптимизировать ваш код. В процессе оптимизации кода компилятором C#, JIT-компилятором и процессором гарантируется сохранение его назначения. То есть с точки зрения одного потока метод делает то, зачем мы его написали, хотя способ реализации может отличаться от описанного исходном коде. Однако при переходе к многопоточной конфигурации ситуация может измениться.

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

a = c;
b = d;
flag = true;

.NET не гарантирует, что чтения и записи выше будут произведены именно в этом порядке, поэтому если вы хотите написать код, в котором один поток сначала читает какие-то данные и потом проставляет флаг в true, а второй поток проверяет значение флага и начинает работать только тогда, когда он выставлен в true, то без использования специальных средств у вас нет гарантий, что этот код будет работать так как вы его задумали.

Что же может сделать этот код работоспособным? - Методы Volatile.Write и Volatile.Read

Метод Volatile.Write заставляет записать значение в параметр location непосредственно в момент обращения. Бодее ранние загрузки и сохранения программы должны происходить до вызова этого метода. Метод Volatile.Read заставляет считать значение параметра address непосредственно в момент обращения. Более поздние загрузки и сохранения программы должны происходить после вызова этого метода.

Или ключевое слово volatile применённое к полям

JIT-компилятор гарантирует, что доступ к полям, помеченным данным ключевым словом, будет происходить в режиме волатильного чтения или записи, поэтому в явном виде вызывать статические методы Read и Write класса Volatile больше не требуется.

Volatile и Модель памяти

Разобраться в теме гораздо глубже поможет доклад Валерия Петрова Модель памяти .NET

Из доклада можно узнать:

  • Почему процессоры переставляют выполняемые инструкции местами
  • Какие оптимизации могут произвести с вашим кодом Компилятор/JIT/CPU
  • Что такое модель памяти и при чём тут она
  • Как работает ключевое слово volatile и методы Volatile.Write и Volatile.Read и как правильно их использовать

Кроме того, что в докладе очень доступная подача материала, мне нравится ещё и то, что Валерий для подтверждения своих слов приводит ссылки на пункты спецификации и цитаты из неё.

Также я нашёл презентацию Валерия Петрова, но видимо она сделана к какому-то другому докладу, потому что слайдов в ней намного больше и больше разного материала затронуто.

Хочется остановится на определении модели памяти.

Согласно википедии:

In computing, a memory model describes the interactions of threads through memory and their shared use of the data. A memory model allows a compiler to perform many important optimizations. Compiler optimizations like loop fusion move statements in the program, which can influence the order of read and write operations of potentially shared variables. Changes in the ordering of reads and writes can cause race conditions. Without a memory model, a compiler is not allowed to apply such optimizations to multi-threaded programs in general, or only in special cases.

Моя “расслабленная” интерпретация этого определения: :

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

На хабре также есть статья, которая довольно близка к докладу Валерия по кругу разбираемых вопросов:

Барьеры памяти и неблокирующая синхронизация в .NET от Дмитрия Костикова.

В ней материал тоже подаётся вполне доступно, но есть несколько комментариев от меня:

  1. Написано, что в модели памяти .NET разрешены все перестановки кроме write-write - Валерий Петров упоминает, в своём докладе, что об этом часто пишут в статьях, но неизвестно откуда взялся этот факт и насколько он соответствует действительности, в спецификациях или каких-либо других источниках его подтверждение найти не удаётся.

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

  3. В самом конце статьи в разделе “Производительность Thread.Volatile* и ключевого слово volatile” написано, что: “На большинстве платформ (точнее говоря, на всех платформах, поддерживаемых Windows, кроме умирающей IA64) все записи и чтения являются volatile write и volatile read соответственно. Таким образом, во время выполнения ключевое слово volatile не оказывает никакого влияния на производительность.” - текст неактуальный на данный момент, так как с тех пор появилась поддержка ARM-процессоров, а так как в статье, не были затронуты особенности разных процессорных архитектур в плане перестановок инструкций и то как на них влияет volatile, то этот параграф всё-равно будет непонятен неподготовленному читателю. Также лично мне не кажется удачной формулировка, что запись и чтения на платформах являются волатильными, но об этом позже.

Какие ещё есть статьи, которые, в принципе, можно пропустить

  • Статья Джо Албахари Threading in C# PART 4: ADVANCED THREADING первая часть, которой посвящена неблокирующей синхронизации в общем и volatile в частности - есть утверждения, которые либо не понятны, либо которые я не знаю как подтвердить.
  • Модель памяти C# в теории и на практике Игоря Островского - к этой статье тоже есть вопросы в плане используемых утверждений и формулировок.
  • C# - The C# Memory Model in Theory and Practice, Part 2 - вторая часть статьи Игоря Островского про модель памяти, в этой статье разбираются три вида оптимизаций, которые может произвести с кодом компилятор, а также особенности работы volatile на архитектурах x86/x64, Itanium, ARM - материал про особенности конкретных архитектур может представлять интерес.

Если вы прочитали/прослушали материалы выше, то теперь вы знаете интересные факты о том, что в .NET

  • Вызов Volatile.Write/Volatile.Read идентичны использованию ключевого слова volatile в плане получаемых эффектов на выполнение кода, а вот вызовы Thread.VolatileWrite/Thread.VolatileRead ведут себя по другому.
  • Волатильная запись и последующее волатильное чтение могут быть переставлены местами (но это не только в .NET)

Барьеры памяти

По определению David Howells и David Howells в статье LINUX KERNEL MEMORY BARRIERS:

Independent memory operations are effectively performed in random order, but this can be a problem for CPU-CPU interaction and for I/O. What is required is some way of intervening to instruct the compiler and the CPU to restrict the order.

Memory barriers are such interventions. They impose a perceived partial ordering over the memory operations on either side of the barrier.

Such enforcement is important because the CPUs and other devices in a system can use a variety of tricks to improve performance, including reordering, deferral and combination of memory operations; speculative loads; speculative branch prediction and various types of caching. Memory barriers are used to override or suppress these tricks, allowing the code to sanely control the interaction of multiple CPUs and/or devices.

Или в моей расслабленной интерпретации: барьеры памяти - это инструкции, способные заставить компилятор и даже процессор прекратить выполнять оптимизации и гарантировать, что определённые операции чтения и записи могут остаться с какой-либо из сторон барьера памяти.

Использование ключевого слова volatile или методов Volatile.Read/Write - это один из способов установить барьер памяти, Thread.MemoryBarier - другой.

Статья на эту тему Memory Barriers in .NET Nadeem Afana.

Статья интересна тем, что рассматривает вопрос работы барьеров памяти довольно близко к тому, как они работают на уровне процессоров.

Мои замечания к статье:

  • Автор тоже упоминает, что существует модель памяти .NET в которой запрещены перестановки запись-запись.
  • Автор упоминает, что для lock, Interlocked и прочих вещей генерируется полный барьер памяти - ECMA-335 говорит нам другое в разделе I.12.6.5 Locks and threads.

Если статья вас заинтересовала, но некоторые слова вы не поняли, например, такие STORE Buffer и Cache Coherence, и есть желание разобраться дальше, то читайте статью Memory Barriers: a Hardware View for Software Hackers Paul E. McKenney (или русский перевод первой части статьи) - тут всё прямо с алгоритмами того, как процесс происходит внутри процессора.

Дополнительный материал по барьерам памяти

LINUX KERNEL MEMORY BARRIERS David Howells, Paul E. McKenney

Волатильное чтение и запись на архитектуре процессора x86

Во многих статьях пишут, что на архитектуре процессора x86 все операции чтения и записи осуществляются как волатильное чтение и волатильная запись, поэтому использование волатильного чтения и записи в коде программы будет иметь влияние только на компилятор, но не на инструкции процессора. К сожалению, никто не даёт ссылок на источник этого утверждения, я попытался найти этот источник в итоге нашёл только описание модели памяти x86: Intel® 64 and IA-32 Architectures Software Developer’s Manual (раздел 8.2) и в нём нет формулировки про волатильное чтение и запись, есть только список разрешённых перестановок и фактически разрешена только перестановка запись и последующее чтение, что совпадает с разрешёнными перестановками при волатильных чтениях и записях (волатильная запись и последующее волатильное чтение могут быть переставлены) - видимо из-за этого совпадения разрешённых/запрещённых перестановок и возникла формулировка про то что операции чтения/записи на архитектуре x86 волатильные.

Что ещё можно прочитать