Pull to refresh

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

Reading time 7 min
Views 57K

Введение


В этой статье я хочу рассказать об использовании некоторых конструкций, применяющихся для осуществления неблокирующей синхронизации. Речь пойдёт о ключевом слове 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
Tags:
Hubs:
+61
Comments 18
Comments Comments 18

Articles