Вопрос

У меня есть вопрос по поводу следующего примера кода (взято из: http://www.albahari.com/threading/part4.aspx#_NonBlockingSynch)

class Foo
{
   int _answer;
   bool _complete;

   void A()
   {
       _answer = 123;
       Thread.MemoryBarrier();    // Barrier 1
       _complete = true;
       Thread.MemoryBarrier();    // Barrier 2
   }

    void B()
    {
       Thread.MemoryBarrier();    // Barrier 3
       if (_complete)
       {  
          Thread.MemoryBarrier(); // Barrier 4
          Console.WriteLine (_answer);
       }
    }
 }

За этим следует следующее пояснение:

"Барьеры 1 и 4 не позволяют в этом примере написать “0”. Барьеры 2 и 3 обеспечивают гарантию свежести: они гарантируют, что если бы B выполнялся после A, чтение _complete было бы оценено как true."

Я понимаю, как использование барьеров памяти влияет на переупорядочение инструкций, но что это "гарантия свежести" это упоминается?

Далее в статье также используется следующий пример:

static void Main()
{
    bool complete = false; 
    var t = new Thread (() =>
    {
        bool toggle = false;
        while (!complete) 
        {
           toggle = !toggle;
           // adding a call to Thread.MemoryBarrier() here fixes the problem
        }

    });

    t.Start();
    Thread.Sleep (1000);
    complete = true;
    t.Join();  // Blocks indefinitely
}

За этим примером следует следующее объяснение:

"Эта программа никогда не завершается, потому что полная переменная кэшируется в регистре процессора.Вставка вызова Thread.MemoryBarrier внутри цикла while (или блокировка вокруг завершения чтения) исправляет ошибку ".

Итак , еще раз ...что здесь происходит?

Это было полезно?

Решение

В первом случае Барьер 1 обеспечивает _answer написано ДО _complete.Независимо от того, как написан код или как компилятор или среда CLR инструктируют центральный процессор, очереди чтения/записи шины памяти можете изменить порядок запросов.Барьер в основном гласит: "очистите очередь, прежде чем продолжить".Аналогично, Барьер 4 обеспечивает _answer читается ПОСЛЕ _complete.В противном случае CPU2 мог бы изменить порядок вещей и увидеть старый _answer с "новым" _complete.

Барьеры 2 и 3, в некотором смысле, бесполезны.Обратите внимание, что объяснение содержит слово "после".:ie "...если Б побежал за А, ...".Что значит для B бежать за A?Если B и A находятся на одном процессоре, то, конечно, B может быть после.Но в этом случае тот же процессор означает отсутствие проблем с барьером памяти.

Итак, рассмотрим B и A, работающие на разных процессорах.Теперь, очень похоже на теорию относительности Эйнштейна, концепция сравнения времени в разных местах / процессорах на самом деле не имеет смысла.Другой способ подумать об этом - можете ли вы написать код, который может определить, выполнялся ли B после A?Если да, то вы, вероятно, использовали для этого барьеры памяти.В противном случае вы не сможете сказать, а спрашивать не имеет смысла.Это также похоже на принцип Гейзенбурга - если вы можете наблюдать это, вы изменили эксперимент.

Но оставляя физику в стороне, предположим, вы могли бы открыть капот своей машины, и видишь что фактическая ячейка памяти _complete было правдой (потому что A убежал).Теперь запускайте B.без Барьера 3 CPU2 все ЕЩЕ мог БЫ НЕ видеть _complete как истинный.т.е. не "свежий".

Но вы, вероятно, не можете открыть свой компьютер и посмотреть на _complete.Также не сообщайте о своих выводах B на CPU2.Ваше единственное сообщение - это то, что делают сами процессоры.Итак, если они не могут определить "ДО" / "ПОСЛЕ" без барьеров, спрашивая "что произойдет с B, если он запустится после A без барьеров" не имеет смысла.

Кстати, я не уверен, что у вас есть доступное в C #, но то, что обычно делается, и что действительно необходимо для примера кода № 1, - это единый барьер освобождения при записи и единый барьер получения при чтении:

void A()
{
   _answer = 123;
   WriteWithReleaseBarrier(_complete, true);  // "publish" values
}

void B()
{
   if (ReadWithAcquire(_complete))  // subscribe
   {  
      Console.WriteLine (_answer);
   }
}

Слово "подписаться" не часто используется для описания ситуации, но "опубликовать" - да.Я предлагаю вам прочитать статьи Херба Саттера о нарезании резьбы.

Это ставит барьеры в именно так в нужных местах.

Для примера кода # 2 это на самом деле не проблема с барьером памяти, это проблема оптимизации компилятора - это сохранение complete в реестре.Барьер памяти вытеснил бы его, как и volatile, но , вероятно , так было бы при вызове внешней функции - если компилятор не может определить, изменена ли эта внешняя функция complete или нет, он будет перечитывать его по памяти.т.е., может быть, передать адрес complete к некоторой функции (определенной где-то, где компилятор не может изучить ее детали):

while (!complete)
{
   some_external_function(&complete);
}

даже если функция не изменяется complete, если компилятор не уверен, ему нужно будет перезагрузить свои регистры.

т.е. разница между кодом 1 и кодом 2 заключается в том, что код 1 имеет проблемы только тогда, когда A и B выполняются в отдельных потоках.код 2 может иметь проблемы даже на однопоточной машине.

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

Редактировать:Чтобы ответить на комментарий от opc (мой ответ слишком велик для блока комментариев):

Барьер 3 заставляет центральный процессор сбрасывать все ожидающие запросы на чтение (и запись).

Итак, представьте, было ли какое-то другое чтение перед чтением _complete:

void B {}
{
   int x = a * b + c * d; // read a,b,c,d
   Thread.MemoryBarrier();    // Barrier 3
   if (_complete)
   ...

Без барьера процессор мог бы иметь все эти 5 ожидающих запросов на чтение:

a,b,c,d,_complete

Без барьера процессор мог бы изменить порядок этих запросов, чтобы оптимизировать доступ к памяти (т. Е. если _complete и 'a' были в одной строке кэша или что-то в этом роде).

С помощью барьера процессор получает a, b, c, d обратно из памяти еще до того, как _complete будет введен в качестве запроса.УБЕДИТЕСЬ, ЧТО 'b' (например) прочитан ПЕРЕД _complete - т. е. никакого переупорядочивания.

Вопрос в том, какое это имеет значение?

Если a, b, c, d независимы от _complete, то это не имеет значения.Все, что делает барьер, - это ЗАМЕДЛЯЕТ ХОД СОБЫТИЙ.Так что да, _complete читается позже.Таким образом, данные являются более свежий.Включение режима ожидания (100) или какого-нибудь цикла ожидания занятости перед чтением также сделало бы его "более свежим"!:-)

Так что смысл в том, чтобы сохранять относительность.Нужно ли считывать / записывать данные ДО / ПОСЛЕ относительно каких-то других данных или нет?Вот в чем вопрос.

И чтобы не унижать автора статьи - он упоминает "если Б побежал за А ...".Просто не совсем ясно, воображает ли он, что B после A имеет решающее значение для кода, наблюдаемое с помощью to code или просто несущественное.

Другие советы

Пример кода # 1:

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

Процессор 1 запускает A().Он записывает новое значение _complete в свой кэш (но пока не обязательно в основную память).

Процессор 2 запускает B().Он считывает значение _complete .Если это значение ранее находилось в его кэше, оно может быть не свежим (т. Е. не синхронизированным с основной памятью), поэтому оно не получит обновленное значение.

Пример кода # 2:

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

Барьер памяти здесь вынуждает функцию повторно считывать значение переменной из памяти.

Вызов Thread.MemoryBarrier() немедленно обновляет кэши регистров фактическими значениями переменных.

В первом примере "свежесть" для _complete обеспечивается вызовом метода сразу после его установки и непосредственно перед его использованием.Во втором примере начальный false значение для переменной complete будет кэшироваться в собственном пространстве потока и нуждается в повторной синхронизации, чтобы немедленно увидеть фактическое "внешнее" значение "изнутри" запущенного потока.

Гарантия "свежести" просто означает, что барьеры 2 и 3 определяют значения _complete быть видимыми как можно скорее, а не всякий раз, когда они случайно записываются в память.

На самом деле в этом нет необходимости с точки зрения согласованности, поскольку барьеры 1 и 4 гарантируют, что answer будет прочитано после прочтения complete.

Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top