Объяснение ошибки Thread.MemoryBarrier () с OoOP

Итак, после прочтения Threading в C # Альбахари я пытаюсь разобраться в Thread.MemoryBarrier () и обработке вне очереди.

Следуя ответу Брайана Гидеона на Почему нам нужен Thread.MemoerBarrier (), он упоминает следующее код заставляет программу бесконечно зацикливаться в режиме Release и без подключенного отладчика.

class Program
{
    static bool stop = false;

    public static void Main(string[] args)
    {
        var t = new Thread(() =>
        {
            Console.WriteLine("thread begin");
            bool toggle = false;
            while (!stop)
            {
                // Thread.MemoryBarrier() or Console.WriteLine() fixes issue
                toggle = !toggle;
            }
            Console.WriteLine("thread end");
        });
        t.Start();
        Thread.Sleep(1000);
        stop = true;
        Console.WriteLine("stop = true");
        Console.WriteLine("waiting...");
        t.Join();
    }
}

У меня вопрос: почему без добавления Thread.MemoryBarrier () или даже Console.WriteLine () в цикл while проблема решается?

Я предполагаю, что, поскольку на многопроцессорной машине поток работает со своим собственным кешем значений и никогда не извлекает обновленное значение stop, потому что оно имеет свое значение в кеше?

Или основной поток не сохраняет это в памяти?

Также почему Console.WriteLine () исправляет это? Это потому, что он также реализует MemoryBarrier?


person Michal Ciechan    schedule 18.03.2014    source источник


Ответы (4)


Это не решает никаких проблем. Это поддельное исправление, довольно опасное для производственного кода, так как оно может работать, а может и не работать.

Основная проблема в этой строке

static bool stop = false;

Переменная, останавливающая цикл while, не является изменчивой. Это означает, что он может или не может быть прочитан из памяти все время. Его можно кэшировать, чтобы системе представлялось только последнее прочитанное значение (которое может не быть фактическим текущим значением).

Этот код

// Thread.MemoryBarrier() or Console.WriteLine() fixes issue

Может или не может решить проблему на разных платформах. Барьер памяти или запись в консоль просто заставляют приложение читать свежие значения в конкретной системе. В другом месте может быть иначе.


Кроме того, volatile и Thread.MemoryBarrier() предоставляют только слабые гарантии, что означает, что они не обеспечивают 100% гарантии того, что считываемое значение всегда будет самым последним на всех системах и процессорах.

Эрик Липперт говорит

Истинная семантика изменчивых операций чтения и записи значительно сложнее, чем я описал здесь; фактически они не гарантируют, что каждый процессор остановит свою работу и обновит кеши в / из основной памяти. Скорее, они обеспечивают более слабые гарантии того, что доступ к памяти до и после чтения и записи может быть упорядочен относительно друг друга. Некоторые операции, такие как создание нового потока, ввод блокировки или использование одного из методов семейства Interlocked, обеспечивают более надежные гарантии соблюдения порядка. Если вам нужны более подробные сведения, прочтите разделы 3.10 и 10.5.3 спецификации C # 4.0.

person oleksii    schedule 18.03.2014
comment
Я пытаюсь понять, почему volatile не на 100% безопасен? Я имею в виду, что если архитектура ЦП эффективно реализует барьеры чтения и записи, разве этого не должно быть достаточно, чтобы гарантировать отсутствие локально кэшированных копий? - person Michal Ciechan; 18.03.2014
comment
@MichalCiechan: Да, этого достаточно, чтобы гарантировать отсутствие локально кэшированных копий. Этого недостаточно, чтобы гарантировать, что чтение не перемещается назад во времени по отношению к записи. 26 марта в блоге Coverity Development Testing Blog я опубликую статью, в которой проиллюстрировано, почему неупорядоченное чтение и запись может вызывать удивление. Неустойчивый - острый инструмент. Не используйте его, если вы не являетесь экспертом в том, как обработчики могут изменять порядок инструкций. (Я не такой эксперт, поэтому не использую volatile.) - person Eric Lippert; 18.03.2014
comment
Thread.MemoryBarrier - настоящее исправление. Это потому, что это предотвращает оптимизацию движения инвариантного кода цикла. Если бы он не исправил этот тривиальный случай, он был бы практически бесполезен в BCL. Однако Console.WriteLine определенно является поддельным исправлением. OP ошибочно подразумевал, что я утверждал, что это исправление. Я не сделал этого в процитированном ответе на другой вопрос. - person Brian Gideon; 30.07.2014

Компилятор и ЦП могут оптимизировать ваш код, переупорядочивая его любым способом, который они сочтут нужным, при условии, что любые изменения согласованы для одного потока. Вот почему вы никогда не сталкиваетесь с проблемами в однопоточной программе.

В вашем коде есть два потока, которые используют флаг stop. Компилятор или ЦП могут выбрать кэширование значения в регистре ЦП, поскольку в потоке, который вы создаете, поскольку он может обнаружить, что вы не записываете в него в потоке. Что вам нужно, так это какой-то способ сообщить компилятору / процессору, что переменная изменяется в другом потоке, и поэтому он не должен кэшировать значение, а должен читать его из памяти.

Есть несколько простых способов сделать это. Один, если окружает весь доступ к переменной stop в операторе lock. Это создаст полный барьер и гарантирует, что каждый поток видит текущее значение. Другой - использовать класс Interlocked для чтения / записи переменной, так как это также создает полный барьер.

Существуют также определенные методы, такие как Wait и Join, которые также создают барьеры памяти, чтобы предотвратить изменение порядка. В книге Альбахари перечислены эти методы.

person Sean    schedule 18.03.2014
comment
Дополнительная информация: цикл чтения с подъемом - person Legends; 21.12.2017

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

person Remus Rusanu    schedule 18.03.2014
comment
Отличная ссылка, есть отличная ссылка в комментарии автора этого порта - Is Parallel Programming Hard - kernel.org/pub/linux/kernel/people/paulmck/perfbook/ - person Michal Ciechan; 18.03.2014
comment
Искусство многопроцессорного программирования тоже очень хорошо, для алгоритмов. Имейте в виду, что это Java, поэтому некоторые алгоритмы полагаются на сборщик мусора и не могут быть напрямую перенесены на C / C ++. - person Remus Rusanu; 18.03.2014

Начнем с некоторых определений. Ключевое слово volatile создает блокировку получения при чтении и блокировку выпуска при записи. Они определены следующим образом.

  • Acqua-забор: Барьер памяти, в котором другие операции чтения и записи не могут перемещаться перед забором.
  • релиз-забор: барьер памяти, в котором другие операции чтения и записи не могут перемещаться после ограждения.

Метод Thread.MemoryBarrier создает полное ограждение. Это означает, что он создает и блок-забор, и забор-релиз. К сожалению, MSDN говорит об этом.

Синхронизирует доступ к памяти следующим образом: процессор, выполняющий текущий поток, не может переупорядочить инструкции таким образом, чтобы обращения к памяти до вызова MemoryBarrier выполнялись после обращений к памяти, следующих за вызовом MemoryBarrier.

Интерпретация этого приводит нас к мысли, что это только создает ограничение выпуска. Так что это? Полный забор или полузабор? Это, наверное, тема для другого вопроса. Я буду работать в предположении, что это полный забор, потому что многие умные люди сделали это заявление. Но, что более убедительно, сам BCL использует Thread.MemoryBarrier, как если бы он производил полный забор. Так что в этом случае документация, вероятно, неверна. Что еще более забавно, это утверждение фактически подразумевает, что инструкции перед вызовом могут быть каким-то образом зажаты между вызовом и инструкциями после него. Это было бы абсурдно. Я говорю это в шутку (но не совсем), что для Microsoft было бы полезно, чтобы юрист просматривал всю документацию, касающуюся потоковой передачи. Я уверен, что их юридические навыки могут найти хорошее применение в этой области.

Теперь я собираюсь ввести обозначения стрелок, чтобы помочь проиллюстрировать заборы в действии. Стрелка ↑ обозначает ограничитель выпуска, а стрелка ↓ - ограничитель получения. Представьте, что острие стрелки отодвигает доступ к памяти в направлении стрелки. Но, и это важно, доступ к памяти может проходить мимо хвоста. Прочтите определения ограждений выше и убедитесь, что стрелки наглядно представляют эти определения.

Далее мы проанализируем цикл только потому, что это самая важная часть кода. Для этого я собираюсь раскрутить цикл. Вот как это выглядит.

LOOP_TOP:

// Iteration 1
read stop into register
jump-if-true to LOOP_BOTTOM
↑
full-fence // via Thread.MemoryBarrier
↓
read toggle into register
negate register
write register to toggle
goto LOOP_TOP

// Iteration 2
read stop into register
jump-if-true to LOOP_BOTTOM
↑
full-fence // via Thread.MemoryBarrier
↓
read toggle into register
negate register
write register to toggle
goto LOOP_TOP

...

// Iteration N
read stop into register
jump-if-true to LOOP_BOTTOM
↑
full-fence // via Thread.MemoryBarrier
↓
read toggle into register
negate register
write register to toggle
goto LOOP_TOP

LOOP_BOTTOM:

Обратите внимание, что вызов Thread.MemoryBarrier ограничивает перемещение некоторой части доступа к памяти. Например, чтение toggle не может перемещаться перед чтением stop или наоборот, потому что доступ к памяти не может перемещаться по стрелке.

А теперь представьте, что произойдет, если убрать полное ограждение. Компилятор C #, JIT-компилятор или оборудование теперь имеют гораздо больше свободы в перемещении инструкций. В частности, теперь разрешена оптимизация подъема, официально известная как инвариантное движение кода цикла. В основном компилятор обнаруживает, что stop никогда не изменяется, и поэтому чтение выходит из цикла. Теперь он эффективно кэшируется в реестр. Если бы барьер памяти был на месте, то при чтении пришлось бы проталкиваться вверх через стрелку, а спецификация специально это не допускает. Это намного легче визуализировать, если размотать цикл, как я сделал выше. Помните, что вызов Thread.MemoryBarrier будет происходить на каждой итерации цикла, поэтому вы не можете просто сделать выводы о том, что произойдет, только на одной итерации.

Проницательный читатель заметит, что компилятор может менять местами чтение toggle и stop таким образом, что stop "обновляется" в конце цикла, а не в начале, но это не имеет отношения к контекстному поведению цикла. . Он имеет ту же семантику и дает тот же результат.

У меня вопрос: почему без добавления Thread.MemoryBarrier () или даже Console.WriteLine () в цикл while проблема решается?

Поскольку барьер памяти накладывает ограничения на оптимизацию, которую может выполнять компилятор. Это запретило бы движение инвариантного кода цикла. Предполагается, что Console.WriteLine создает барьер памяти, что, вероятно, верно. Без барьера памяти компилятор C #, JIT-компилятор или оборудование могут свободно поднять чтение stop за пределы самого цикла.

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

Короче ... да. Но учтите, что это никак не связано с количеством процессоров. Это можно продемонстрировать на одном процессоре.

Или основной поток не сохраняет это в памяти?

Нет. Основной поток зафиксирует запись. Вызов Thread.Join гарантирует это, потому что он создаст барьер памяти, который не позволяет перемещению записи упасть ниже соединения.

Также почему Console.WriteLine () исправляет это? Это потому, что он также реализует MemoryBarrier?

да. Вероятно, это создает барьер памяти. Я храню список генераторов барьеров памяти здесь.

person Brian Gideon    schedule 30.07.2014