Home‎ > ‎In Russian‎ > ‎

Вопрос про Memory Barrier

Вопрос из форума:
Если B() будет гарантированно запущен после A(), то гарантируется ли что будет напечатано 123

1)
class Foo {
  int _answer;
  bool _complete;
  void A()   {
    _answer = 123;
    _complete = true;
    Thread.MemoryBarrier();  //  Read/Write Barrier 
  }
  void B()   {
    if (_complete)     {
      Console.WriteLine (_answer);
    }
  }
}

2)
class Foo {
  int _answer;
  bool _complete;
  void A()   {
    _answer = 123;
    _complete = true;
    Write Barrier;  
  }
  void B()   {
    if (_complete)     {
      Console.WriteLine (_answer);
    }
  }
}

3)
class Foo {
  int _answer;
  bool _complete;
  void A()   {
    _answer = 123;
    _complete = true;
    Read Barrier;   
  }
  void B()   {
    if (_complete)     {
      Console.WriteLine (_answer);
    }
  }
}

4)
class Foo {
  int _answer;
  bool _complete;
  void A()   {
    _answer = 123;
    _complete = true;
  }
  void B()   {
    Thread.MemoryBarrier();  //  Read/Write Barrier 
    if (_complete)
    {
      Console.WriteLine (_answer);
    }
  }
}

5)
class Foo {
  int _answer;
  bool _complete;
  void A()   {
    _answer = 123;
    _complete = true;
    Read Barrier;   
  }
  void B()   {
    Write Barrier;
    if (_complete)     {
      Console.WriteLine (_answer);
    }
  }
}

6)
class Foo {
  int _answer;
  bool _complete;
  void A()   {
    _answer = 123;
    _complete = true;
    Write Barrier;   
  }
  void B()   {
    Read Barrier;
    if (_complete)     {
      Console.WriteLine (_answer);
    }
  }
}

Небольшое лирическое отступление. Если В() будет запущен после А(), то будет напечатано 123 по-определению. В распределенных системах без глобального времени понятие "после" как раз и вводится как — если в одной точке пространства мы увидели событие, произошедшее во второй точке пространства, то момент наблюдения произошёл *после* события. Других способов определить понятие *после* просто нет. Поэтому если В() после А(), то будет напечатано 123. Если 123 не будет напечатано, значит В() просто был не после А().
Я думаю, что подразумевался вопрос — если будет что-то напечатано, то гарантировано ли, что это будет 123?

Правильных вариантов тут нет.
Во-первых, основное правило касательно барьеров памяти — это всегда игра двух. Если один поток хаотически замешивает свои обращения к памяти в кучу-малу, то как бы ни старался второй поток установить корректный порядок, он не сможет это сделать(*).
Например, если первый поток будет скомпилирован как:
 _complete = true;
 _answer = 123;
и при этом он будет прерван ОС после выполнения _complete = true, несложно понять, что как бы второй поток не старался, он не сможет гарантировать _answer==123, после того как он увидел _complete==true.
Поэтому все варианты, где только один поток выполняет барьер можно сразу исключить.

Второй момент — барьеры памяти обеспечивают взаимное упорядочивание обращений к памяти, поэтому они должны ставится *между* критическими обращениями (теми, которые мы хотим упорядочить), а не просто где-то.
Критические обращения в данном случае — это обращения к _complete и _answer. Барьеры памяти должны быть *между* ними. Пока эти обращения находятся по одну сторону барьера, барьер имеет на них ровно нулевое влияние.
Этим мы исключаем все остальные варианты.

В целом, это один из основополагающих паттернов межпоточной синхронизации, в частности он используется в очередях производитель-потребитель, да и во всех остальных контейнерах. Правильно он записывается следующим образом:
class Foo
{
  int _answer;
  bool _complete;

  void A()
  {
    _answer = 123;
    Write Barrier;  
    _complete = true;
  }

  void B()
  {
    if (_complete)
    {
      Read Barrier;  
      Console.WriteLine (_answer);
    }
  }
}

Теперь всё на своих местах. Write Barrier в потоке А гарантирует, что _complete будет установлен после установки _answer. Read Barrier в потоке В гарантирует, что _answer будет считан после _complete. Из этого можно заключить, что если В увидел _complete==true, то считывание _answer гарантированно выдаст 123. QED.

Вообще говоря, нотация Read/Write барьеров считается несколько устаревшей. Сейчас считается более "продвинутой" нотация Acquire/Release барьеров, которая как раз поддерживается CLI volatile переменными. Т.е. любое считывание volatile переменной есть load с acquire барьером, а любая запись есть store с release барьером.
Причин к этому много сложных, но вот например самая очевидная. Вообще говоря потоку В обычно надо упорядочить не только считывания данных, но и записи в синхронизируемые данные, т.е. поток В зачастую выглядит как:
  void B()
  {
    if (_complete)
    {
      Read Barrier;  
      Console.WriteLine (_answer);
      _answer = 456;
    }
  }

Read Barrier обеспечивает корректное упорядочивание считывания из _answer, но не упорядочивает запись в _answer. Соотв. мы может получить следующий результат: на экран будет выведено 123, но в переменной _answer будет лежать не 456, а 123.
К чему я это. Если уж речь идёт о CLI/.NET, то более корректный и простой способ записать этот паттерн будет объявление переменной _complete как volatile, всё остальное будет обеспечено автоматически:
class Foo
{
  int _answer;
  volatile bool _complete;

  void A()
  {
    _answer = 123;
    _complete = true; // store с release барьером
  }

  void B()
  {
    if (_complete) // load с acquire барьером
    {
      Console.WriteLine (_answer);
    }
  }
}

Если не вдаваться в детали, то store-release гарантирует, что все *предыдущие* обращения к памяти будут завершены ДО данной операции. load-acquire соотв. гарантирует, что все *последующие* обращения к памяти будут выполнены ПОСЛЕ данной операции. При этом они не различают обращения к памяти на read и write.

---
(*) Вообще говоря есть способ избавить один поток от выполнения аппаратного барьера памяти, но даже он требует выполнения барьера компилятора в обоих потоках. Такая синхронизация называется asymmetric synchronization.


Comments