Вопрос про 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.