Вопрос из форума:
Если 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.