Как заборы памяти влияют на «свежесть» данных? - PullRequest
2 голосов
/ 14 ноября 2009

У меня есть вопрос по поводу следующего примера кода (взят из: 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 (или блокировка чтения) исправляет ошибку."

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

Ответы [ 4 ]

6 голосов
/ 15 ноября 2009

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

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

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

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

Но вы, вероятно, не можете открыть свою машину и посмотреть на _complete. Не сообщайте свои выводы B на CPU2. Ваша единственная связь - это то, что делают сами процессоры. Так что, если они не могут определить BEFORE / AFTER без барьеров, спрашивать «что будет с 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 читается позже . Таким образом, данные свежее . Помещение цикла sleep (100) или некоторого занятого ожидания для ожидания, прежде чем чтение также сделает его «более свежим»! : -)

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

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

1 голос
/ 14 ноября 2009

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

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

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

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

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

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

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

0 голосов
/ 16 ноября 2009

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

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

0 голосов
/ 14 ноября 2009

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

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

...