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

Введение


В этой статье я хочу рассказать об использовании некоторых конструкций, применяющихся для осуществления неблокирующей синхронизации. Речь пойдёт о ключевом слове volatile, функциях VolatileRead, VolatileWrite и MemoryBarrier. Мы рассмотрим, какие проблемы вынуждают нас воспользоваться этими языковыми конструкциями и варианты их решения. При обсуждении барьеров памяти вкратце рассмотрим модель памяти .NET.

Оптимизации вносимые компилятором


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

class ReorderTest
{
   private int _a;

   public void Foo()
   {
       var task = new Task(Bar);
       task.Start();
       Thread.Sleep(1000);
       _a = 0;
       task.Wait();
   }

   public void Bar()
   {
       _a = 1;
       while (_a == 1)
       {
       }
   }
}

Запустив этот пример можно убедится, что программа зависает. Причина кроется в том, что компилятор кэширует переменную _a в регистре процессора.
Для решения подобных проблем C# предоставляет ключевое слово volatile. Применение этого ключевого слова к переменной запрещает компилятору как-либо оптимизировать обращения к ней.

Вот так будет выглядеть исправленное объявление переменной _a.
private volatile  int _a;

Запрет оптимизаций компилятора не является единственным эффектом от применения этого ключевого слова. Другие эффекты буду рассмотрены чуть позже.

Перестановка инструкций


Рассмотрим теперь случай, когда источником проблем является перестановка инструкций процессором.
Пусть имеется следующий код:

class ReorderTest2
{
   private int _a;
   private int _b;

   public void Foo()
   {
       _a = 1;
       _b = 1;
   }

   public void Bar()
   {
       if (_b == 1)
       {
           Console.WriteLine(_a);
       }
   }
}

Процедуры Foo и Bar запускаются одновременно из разных потоков.
Является ли данный код корректным, то есть, можем ли мы с уверенностью сказать, что программа никогда не выведет нуль? Если бы речь шла об однопоточных программах, то для проверки этого кода было бы достаточно единожды запустить его на выполнение. Но, так как мы имеем дело с многопоточностью, этого недостаточно. Вместо этого мы должны понять, есть ли у нас гарантии того, что программа будет работать корректно.

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

Как уже упоминалось, некорректное поведение многопоточной программы может быть вызвано перестановками инструкций на процессоре. Рассмотрим эту проблему подробнее.
Любой современный процессор может переставлять инструкции чтения и записи памяти в целях оптимизации. Поясню это на примере.
int a = _a;
_b = 10;

В данном коде вначале читается переменная _a, затем записывается _b. Но при исполнении данной программы процессор может переставить местами инструкции чтения и записи, то есть вначале будет записана переменная _b, и только потом прочтена _a. Для однопоточной программы такая перестановка не имеет значения, но для многопоточной программы это может превратиться в проблему. Сейчас мы рассмотрели перестановку загрузка – запись. Аналогичные перестановки возможны и для других сочетаний инструкций.

Совокупность правил перестановок таких инструкций называется моделью памяти. Платформа .NET имеет собственную модель памяти, которая абстрагирует нас от моделей памяти конкретного процессора.
Так выглядит модель памяти .NET
Тип перестановки Перестановка разрешена
Загрузка-загрузка Да
Загрузка-запись Да
Запись-загрузка Да
Запись-запись Нет

Теперь можно рассмотреть наш пример с точки зрения модели памяти .NET. Так как перестановка запись-запись запрещена, то запись в переменную _а всегда будет происходить до записи в переменную _b, и здесь программа отработает корректно. Проблема находится в процедуре Bar. Так как перестановка инструкций чтения не запрещена, то переменная _b может быть прочитана до _a.
После перестановки код будет исполняться так, как будто он был написан следующим образом:
var tmp = _a;
if (_b == 1)
{
    Console.WriteLine(tmp);
}

Когда мы говорим о перестановках инструкций, то имеется ввиду перестановка инструкций одного потока, читающих/пишущих разные переменных. Если в разных потоках идёт запись в одну и ту же переменную, то их порядок в любом случае случаен. И если мы говорим о чтении и записи одной и той же переменной, к примеру, вот так:
var a = GetA();
UseA(a);

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

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

Для решения данной проблемы существует универсальный метод — добавление барьера памяти(memory barrier, memory fence).
Существует несколько видов барьеров памяти: полный, release fence и accure fence.
Полный барьер гарантирует, что все чтения и записи расположенные до/после барьера будут выполнены так же до/после барьера, то есть никакая инструкция обращения к памяти не может перепрыгнуть барьер.
Теперь разберемся с двумя другими видами барьеров:
Accure fence гарантирует что инструкции, стоящие после барьера, не будут перемещены в позицию до барьера.
Release fence гарантирует, что инструкции, стоящие до барьера, не будут перемещены в позицию после барьера.
Еще пару слов о терминологии. Термин volatile write означает выполнение записи в память в сочетании с созданием release fence. Термин volatile read означает чтение памяти в сочетании с созданием accure fence.

.NET предоставляет следующие методы работы с барьерами памяти:
  • метод Thread.MemoryBarrier() создает полный барьер памяти
  • ключевое слово volatile превращает каждую операцию над переменной, помеченной этим словом в volatile write или volatile read соответсвенно.
  • метод Thread.VolatileRead() выполняет volatile read
  • метод Thread.VolatileWrite() выполняет volatile write

Вернемся к нашему примеру. Как мы уже поняли, проблема может возникнуть из-за перестановки инструкций чтения. Для её решения добавим барьер памяти между чтениями _a и _b. После этого у нас появляется гарантия того, что поток, в котором исполняется метод Bar, увидит записи в верном порядке.

class ReorderTest2
{
   private int _a;
   private int _b;

   public void Foo()
   {
       _a = 1;
       _b = 1;
   }

   public void Bar()
   {
       if (_a == 1)
       {
           Thread.MemoryBarrier();
           Console.WriteLine(_b);
       }
   }
}

Использование полного барьера памяти здесь избыточно. Для исключения перестановки инструкций чтения вполне достаточно воспользоваться volatile read при чтении _a. Этого можно достичь с помощью метода Thread.VolatileRead или ключевого слова volatile.

Методы Thread.VolatileWrite и Thread.VolatileRead


Ознкомимся с методами Thread.VolatileWrite и Thread.VolatileRead более подробно.
В MSDN о VolatileWrite написанно: “Записывает значение непосредственно в поле, так что оно становится видимым для всех процессоров компьютера.”
На самом деле это описание не совсем корректно. Эти методы гарантируют две вещи: отсутствие оптимизаций компилятора1 и отсутствие перестановок инструкций в соответствии с свойставми volatile read или write. Строго говоря метод VolatileWrite не гарантирует, что значение немедленно станет видимым для других процессоров, а метод VolatileRead не гарантирует, что значение не будет прочитанно из кеша2. Но в силу отсутствия оптимизаций кода компилятором и когерентности кэшей процессора мы можем считать что описание из MSDN корректно.

Рассмотрим, как реализованы эти методы:

[MethodImpl(MethodImplOptions.NoInlining)]
public static int VolatileRead(ref int address)
{
   int num = address;
   Thread.MemoryBarrier();
   return num;
}

[MethodImpl(MethodImplOptions.NoInlining)]
public static void VolatileWrite(ref int address, int value)
{
   Thread.MemoryBarrier();
   address = value;
}

Что ещё интересного можно здесь увидеть?
Во-первых, здесь используется полный барьер памяти. Как мы говорили, volatile write должен создавать release fence. Так как release fence является частным случаем полного барьера, то эта реализация корректна, но избыточна. Если бы тут ставился release fence, у процессора/компилятора было бы больше возможностей для оптимизации. Почему команда разработчиков .NET реализовала эти функции именно через полный барьер, сказать сложно. Но важно помнить, что это просто детали текущей реализации, и никто не гарантирует, что в будущем она не измениться.

Оптимизации компилятора и процессора

Хочу ещё раз отметить: и ключевое слово volatile и все три рассмотренные функции установки барьеров памяти воздействуют как на оптимизации процессора, так и на оптимизации компилятора.
То есть, к примеру, вот этот код является вполне корректным решением проблемы показанной в первом примере:

public void Bar()
{
   _a = 1;
   while (_a == 1)
   {
        Thread.MemoryBarrier();
   }
}


Опасности volatile


Взглянув на реализацию методов VolatileWrite и VolatileRead становится понятно, что вот такая пара инструкций может быть переставлена:
Thread.VolatileWrite(b)
Thread.VolatileRead(a)

Так как это поведение заложено в определении терминов volatile read и write то это не является багом и аналогичным поведением обладают операции с переменными, помеченными ключевым словом volatile.
Но на практике такое поведение может оказаться неожиданным.
Рассмотрим пример:

class Program
{
   volatile int _firstBool;
   volatile int _secondBool;
   volatile string _firstString;
   volatile string _secondString;

   int _okCount;
   int _failCount;

   static void Main(string[] args)
   {
       new Program().Go();
   }

   private void Go()
   {
           
       while (true)
       {
           Parallel.Invoke(DoThreadA, DoThreadB);
           if (_firstString == null && _secondString == null)
           {
               _failCount++;
           }
           else
           {
               _okCount++;
            }
            Console.WriteLine("ok - {0}, fail - {1}, fail percent - {2}",  
                               _okCount, _failCount, GetFailPercent());

            Clear();
        }
   }

   private float GetFailPercent()
   {
       return (float)_failCount / (_okCount + _failCount) * 100;
   }

   private void Clear()
   {
       _firstBool = 0;
       _secondBool = 0;
       _firstString = null;
       _secondString = null;
   }

    private void DoThreadA()
    {
       _firstBool = 1;
       //Thread.MemoryBarrier();
       if (_secondBool == 1)
       {
           _firstString = "a";
       }
   }

   private void DoThreadB()
   {
       _secondBool = 1;
       //Thread.MemoryBarrier();
       if (_firstBool == 1)
       {
           _secondString = "a";
       }
   }
}

Если инструкции программы выполнялись бы именно в том порядке, в котором они определены, то хотя бы одна строка всегда оказывалась бы равной “а”. На самом деле, из-за перестановки инструкций это оказывается не всегда так. Замена ключевого слова volatile на соответствующие методы, как и ожидалось, не изменяет результата.
Чтобы исправить поведение этой программы достаточно раскомментировать строки с полными барьерами памяти.

Производительность Thread.Volatile* и ключевого слово volatile


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


Ссылки


1 См. стр. 514 Joe Duffy. Concurrent Programming on Windows
2 См VolatileWrite implemented incorrectly

Испльзованная литература:


  1. Joseph Albahari. Threading in C#
  2. Vance Morrison. Understand the Impact of Low-Lock Techniques in Multithreaded Apps
  3. Pedram Rezaei. CLR 2.0 memory model
  4. MS Connect: VolatileWrite implemented incorrectly
  5. ECMA-335 Common Language Infrastructure (CLI)
  6. C# Language Specification
  7. Jeffrey Richter. CLR via C# Third Edition
  8. Joe Duffy. Concurrent Programming on Windows
  9. Joseph Albahari. C# 4.0 in a nutshell
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 17

    +2
    Был бы рад, если бы мне указали неясные или неточные места в статье. В свою очередь постараюсь улучшить статью в соответствии с высказанными пожеланиями.
      +2
      Есть мелкая неточность
      ->Если инструкции программы выполнялись бы именно в том порядке, в котором они определены, то обе строки всегда оказывались бы равными “а”.
      Обе строчки практически никогда не будут равны «a». Корректно написать — хотя бы одна строчка всегда будет «а».

      Если в следущей строчке заменить && на ||, то на однопроцессорной машине всегда будет 100 выводить.
       if (_firstString == null && _secondString == null)
      


      Но тем не менее, пример показателен, в 0.0007% случаев обе операции чтения происходят раньше операций записи.
        0
        Большое спасибо! Исправил.
      0
      Отлличая грамотная статья
        0
        Сколько же нервов можно потратить, если не знать такие вещи при разработке многопоточных приложений! У одного Албахари написано в целом понятно, не вдаваясь в подробности про барьеры памяти и их тонкости, а вы же копнули гораздо глубже. Обычно таких статей очень не хватает, и частенько от недопонимания причин неработоспособности проблемного места людьми с горя вешается
        lock (monitor)
        {
        //... а у нас тут все хорошо =)
        }

        , хорошо еще, если не на большой участок кода.
        В общем, спасибо, в закладки =)
          0
          БОльшое спасибо за опдборку ссылок давно пытался найти источники по многопоточности в .NET. Но вот банальные запросы вроде ".net memory model" ничего дельного на на поверхность не выносили :(
            0
            Я бы посоветовал прочесть «Concurrent Programming on Windows». По статьям в интернете на мой взгляд разбираться сложнее. Проверил на собственном опыте:)
              0
              Ну меня пока интересует исключительно .NET и CLR. Или она вопреки названию не посвящена нативным механизмам ОС?
                0
                Часть книги посвящена многопоточности как таковой, без привязки к ОС. В другой части параллельно рассматриваются конструкции ОС и .NET, причем, по умолчанию язык примеров — c#.
            +1
            Отличная техническая статья в топе? Глазам своим не верю!
            Без тени желтизны, политики и холивара? Фантастика!

            Ах, из песочницы… М-да, что-то я расчувствовался, простите.

            P.S. Добро пожаловать, rumatavz. (Или с возвращением? :)
              0
              Да, может это вторая личность ализара? )
              +1
              Мне кажется, что в секции «Перестановка инструкций» некоторая неточность. Если оба метода запускаются кем-то из разных потоков, то не нужен Task. Если же Foo запускает Bar через Task, то для «одновременности» нужно вызвать task.Start(); до присвоения полей.

              Немного не понял вот это — «На большинстве платформ… все записи и чтения являются volatile write и volatile read». Тогда почему все же необходимо ключевое слово volatile?
                0
                Спасибо, исправил.

                Во первых, на большинстве платформ — это не на всех. Отлавливать потом баги, появляющиеся только в продакшене не очень весело. Во вторых, volatile гарантирует отсутствие оптимизаций компилятора, а это касается любой платформы.
                  0
                  Да я даже не сразу догадался… по привычке в дебаге скомпилил первый пример — не зависает… )

                  Хорошая статья, давно у меня было подозрение что наша софтина в продакшине иногда ведёт себя очень странно…
                0
                Насколько я понимаю, читая www.albahari.com/threading, чтение и запись volatile — данных также могут переставляться местами?
                  0
                  В этой поседовательности — нет. Только наоборот — запись и идущее следом чтение может быть переставлено.
                  Тут приведена таблица возможных перестановок.
                    0
                    Все понял, спасибо :)

                Only users with full accounts can post comments. Log in, please.