Подтвердить что ты не робот

Когда использовать volatile для противодействия оптимизации компилятора в С#

Я провел много недель, делая многопоточное кодирование в С# 4.0. Однако для меня остается один вопрос, который остается без ответа.

Я понимаю, что ключевое слово volatile не позволяет компилятору хранить переменные в регистрах, тем самым избегая непреднамеренного чтения устаревших значений. Записи всегда неустойчивы в .Net, поэтому любая документация, в которой указывается, что она также избегает записи сталий, является избыточной.

Я также знаю, что оптимизация компилятора несколько "непредсказуема". Следующий код иллюстрирует стойло из-за оптимизации компилятора (при запуске компиляции релиза вне VS):

class Test
{
    public struct Data
    {
        public int _loop;
    }

    public static Data data;

    public static void Main()
    {
        data._loop = 1;
        Test test1 = new Test();

        new Thread(() =>
        {
            data._loop = 0;
        }
        ).Start();

        do
        {
            if (data._loop != 1)
            {
                break;
            }

            //Thread.Yield();
        } while (true);

        // will never terminate
    }
}

Код ведет себя так, как ожидалось. Однако, если я раскомментирую //Thread.Yield(); line, то цикл выйдет.

Кроме того, если я поставлю оператор Sleep перед циклом do, он будет завершен. Я не понимаю.

Естественно, что украшение _loop с volatile также приведет к завершению цикла (в показанном шаблоне).

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

ИЗМЕНИТЬ

IL для кода, как показано (киоски):

L_0038: ldsflda valuetype ConsoleApplication1.Test/Data ConsoleApplication1.Test::data
L_003d: ldfld int32 ConsoleApplication1.Test/Data::_loop
L_0042: ldc.i4.1 
L_0043: beq.s L_0038
L_0045: ret 

IL с выходом() (не останавливается):

L_0038: ldsflda valuetype ConsoleApplication1.Test/Data ConsoleApplication1.Test::data
L_003d: ldfld int32 ConsoleApplication1.Test/Data::_loop
L_0042: ldc.i4.1 
L_0043: beq.s L_0046
L_0045: ret 
L_0046: call bool [mscorlib]System.Threading.Thread::Yield()
L_004b: pop 
L_004c: br.s L_0038
4b9b3361

Ответ 1

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

Во-первых, это не просто компилятор, который перемещает инструкции. Большие 3 актера в игре, которые вызывают переупорядочение команд:

  • Компилятор (например, С# или VB.NET)
  • Время выполнения (например, CLR или Mono)
  • Оборудование (например, x86 или ARM)

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

Например, спецификация ECMA для CLI обеспечивает довольно слабые гарантии. Но Microsoft решила ужесточить эти гарантии в CLR.NET Framework. Помимо нескольких сообщений в блогах я не видел много официальной документации по правилам, которым придерживается CLR. Моно, конечно, может использовать другой набор правил, которые могут или не могут приблизить его к спецификации ECMA. И, конечно, может быть некоторая свобода в изменении правил в будущих выпусках, пока формальная спецификация ECMA по-прежнему рассматривается.

Со всем сказанным у меня есть несколько замечаний:

  • Компиляция с конфигурацией Release более вероятно приведет к переупорядочению команд.
  • У более простых методов более вероятно, что их инструкции будут переупорядочены.
  • Подъем чтения изнутри цикла во внешний цикл является типичным типом оптимизации переупорядочения.

И почему я все еще могу получить цикл для выхода с тем, что я считаю нечетные меры?

Это потому, что эти "нечетные меры" делают одну из двух вещей:

  • создание неявного барьера памяти
  • обход компилятора или возможности выполнения определенных оптимизаций

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

Кроме того, такие вещи, как Thread.Yield и Thread.Sleep создают неявные барьеры памяти. Я начал список таких механизмов здесь. Готов поспорить, если вы поместите вызов Console.WriteLine в свой код, это также вызовет выход цикла. Я также видел, что пример "non terminating loop" ведет себя по-разному в разных версиях .NET Framework. Например, держу пари, если вы запустили этот код в 1.0, он завершится.

Вот почему использование Thread.Sleep для имитации чередования потоков может фактически маскировать проблему барьера памяти.

Update:

Прочитав некоторые из ваших комментариев, я думаю, вы можете быть в замешательстве относительно того, что действительно делает Thread.MemoryBarrier. То, что он делает, создает барьер с полным заграждением. Что именно это значит? Барьер с полным заграждением представляет собой состав двух полузакрытий: забор и забор. Я определю их сейчас.

  • Захват забора: барьер памяти, в котором другие чтения и записи не могут перемещаться перед забором.
  • Забор заготовки: барьер памяти, в котором другие чтения и записи не могут перемещаться после забора.

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

Если вы посмотрите на код для Thread.VolatileRead, вот что вы увидите.

public static int VolatileRead(ref int address)
{
    int num = address;
    MemoryBarrier();
    return num;
}

Теперь вам может быть интересно, почему вызов MemoryBarrier после фактического чтения. Ваша интуиция может сказать вам, что для получения "свежего" чтения address вам понадобится вызов MemoryBarrier, который должен произойти до этого чтения. Но, увы, ваша интуиция ошибается! В спецификации говорится, что волатильное считывание должно создавать барьер для забора. И в определении, которое я дал вам выше, это означает, что вызов MemoryBarrier должен быть после чтения address, чтобы предотвратить перемещение других чтений и записей перед ним. Вы видите, что волатильные чтения - это не просто получение "свежего" чтения. Речь идет о предотвращении перемещения инструкций. Это невероятно запутанно; Я знаю.

Ответ 2

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

Любое из упомянутых вами "решений" (Sleep, Yield) будет включать в себя барьер памяти, заставляющий компилятор обновлять _loop.

Минимальное решение (непроверенное):

    do
    {
       System.Threading.Thread.MemoryBarrier();

        if (data._loop != 1)
        {
            break;
        }
    } while (true);

Ответ 3

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

Полный забор, вероятно, слишком тяжелый, чтобы сделать одно волатильное чтение.

Сказав это, можно найти хорошее объяснение того, что может сделать оптимизация: http://igoro.com/archive/volatile-keyword-in-c-memory-model-explained/

Ответ 4

Кажется, что много разговоров о барьерах памяти на аппаратном уровне. Запонки памяти здесь неактуальны. Приятно сказать аппаратным средствам не делать ничего смешного, но вначале это не планировалось, потому что вы, конечно, собираетесь запускать этот код на x86 или amd64. Здесь вам не нужен забор (и это очень редко, но это может произойти). Все, что вам нужно в этом случае, - перезагрузить значение из памяти.
Проблема здесь в том, что компилятор JIT является забавным, а не аппаратным.

Чтобы заставить JIT перестать шутить, вам нужно что-то, что либо просто (1) просто пытается обмануть компилятор JIT для перезагрузки этой переменной (но это зависит от деталей реализации), либо что (2) генерирует память барьер или чтение-с-приобретать то, что понимает компилятор JIT (даже если в потоке команд не осталось никаких заборов).

Чтобы ответить на ваш реальный вопрос, существуют только фактические правила о том, что должно произойти в случае 2.