Когда использовать volatile для противодействия оптимизации компилятора в C # - PullRequest
15 голосов
/ 07 декабря 2011

Я потратил большое количество недель, занимаясь многопоточным кодированием на C # 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 (); линия, затем цикл завершится.

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

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

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

EDIT

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

Ответы [ 4 ]

11 голосов
/ 07 декабря 2011

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

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

  • Компилятор (например, C # или VB.NET)
  • Runtime (например, CLR или Mono)
  • Аппаратное обеспечение (например, x86 или ARM)

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

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

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

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

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

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

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

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

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

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

Обновление:

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

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

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

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

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

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

2 голосов
/ 07 декабря 2011

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

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

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

2 голосов
/ 07 декабря 2011

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

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

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

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

        if (data._loop != 1)
        {
            break;
        }
    } while (true);
0 голосов
/ 07 декабря 2011

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

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

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

...