Оптимизация компилятора и временные назначения в C и С++

В C и С++ см. следующий код:

extern int output;
extern int input;
extern int error_flag;

void func(void)
{
  if (0 != error_flag)
  {
    output = -1;
  }
  else
  {
    output = input;
  }
}
  • Является ли компилятору разрешено компилировать вышеуказанный код так же, как если бы он выглядел ниже?

    extern int output;
    extern int input;
    extern int error_flag;
    
    void func(void)
    {
      output = -1;
      if (0 == error_flag)
      {
        output = input;
      }
    }
    

    Другими словами, компилятору разрешено генерировать (из первого фрагмента) код, который всегда выполняет временное присвоение от -1 до output, а затем присваивает значение input output в зависимости от состояния error_flag

  • Можно ли разрешить компилятору сделать это, если output будет объявлено изменчивым?

  • Разрешено ли компилятору сделать это, если output будет объявлено как atomic_int (stdatomic.h)?

Обновление после комментария Дэвида Шварца:

Если компилятор может добавлять дополнительные записи в переменную, кажется, что из кода C невозможно определить, существует ли гонка данных или нет. Как это определить?

Ответ 1

  • Да, возможно спекулятивное задание. Модификация энергонезависимой переменной не является частью наблюдаемого поведения программы, и поэтому допускается ложная запись. (Ниже приведено определение "наблюдаемого поведения", которое фактически не включает в себя все поведение, которое вы могли бы наблюдать.)

  • Нет. Если output volatile, спекулятивные или ложные мутации не допускаются, поскольку мутация является частью наблюдаемого поведения. (Запись в - или чтение с - аппаратный регистр может иметь последствия, кроме как только сохранение значения. Это один из основных вариантов использования volatile.)

  • (Отредактировано) Нет, спекулятивное назначение невозможно с помощью atomic output. Нагрузки и хранилища переменных atomic являются синхронизированными операциями, поэтому не должно быть возможности загрузить значение такой переменной, которое не было явно сохранено в переменной.

Наблюдаемое поведение

Хотя программа может делать много явно видимых вещей (например, внезапно заканчивая из-за segfault), стандарты C и С++ гарантируют ограниченный набор результатов. Наблюдаемое поведение определено в проекте C11 в разделе 5.1.2.3p6 и в текущем проекте С++ 14 в разделе 1.9p8 [intro.execution] с очень похожей формулировкой:

Наименьшие требования к соответствующей реализации:

- Доступ к неустойчивым объектам оценивается строго в соответствии с правилами абстрактной машины.

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

- Динамика ввода и вывода интерактивных устройств должна происходить таким образом, чтобы запрос вывода был фактически доставлен до того, как программа ждет ввода. Что представляет собой интерактивное устройство, оно определяется реализацией.

В совокупности они называются наблюдаемым поведением программы.

Вышеизложенное взято из стандарта С++; стандарт C отличается тем, что во второй точке он не допускает нескольких возможных результатов, а в третьей точке он явно ссылается на соответствующий раздел стандартных требований к библиотеке. Но детали в сторону, определения координируются; для цели этого вопроса важно, чтобы наблюдался только доступ к изменчивым переменным (вплоть до того, что значение энергонезависимой переменной отправляется на выходное устройство или файл).

Гонки данных

Этот абзац также должен читаться в общем контексте стандартов C и С++, которые освобождают реализацию от всех требований, если программа порождает поведение undefined. Поэтому segfault не рассматривается в определении наблюдаемого поведения выше: segfault - это возможное поведение undefined, но не возможное поведение в соответствующей программе. Таким образом, во вселенной только совместимых программ и соответствующих реализаций нет segfaults.

Это важно, потому что программа с расчетом данных не соответствует. Гонка данных имеет поведение undefined, даже если это кажется безобидным. И поскольку разработчик должен избегать поведения undefined, реализация может оптимизироваться без учета расы данных.

Изложение модели памяти в стандартах C и С++ является плотным и техническим, и, вероятно, не подходит для введения в концепции. (Просмотр материала на сайт Hans Boehm, вероятно, окажется менее трудным.) Извлечение котировок из стандарта является рискованным, потому что детали важны. Но вот небольшой прыжок в болото, из текущего стандарта С++ 14, & sect; 1.10 [intro.multithread]:

  1. Две оценки выражений конфликтуют, если один из них изменяет местоположение памяти, а другой читает или изменяет одно и то же место в памяти.

& hellip;

  1. Два действия потенциально параллельны, если

    - они выполняются разными потоками или

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

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

Вывод здесь состоит в том, что чтение и запись одной и той же переменной необходимо синхронизировать; в противном случае это гонка данных, а результат - undefined. Некоторые программисты могут возражать против строгости этого запрета, утверждая, что некоторые расы данных являются "доброкачественными". Это тема Газета Hans Boehm 2011 HotPar "Как неправильно компрометировать программы с" доброкачественными "расами данных" (pdf) (резюме автора: "Там не являются доброкачественными расами данных" ), и он объясняет это намного лучше, чем мог.

Синхронизация здесь включает в себя использование типов atomic, так что не гонка данных одновременно считывает и изменяет переменную atomic. (Результат чтения непредсказуем, но он должен быть либо значением до модификации, либо значением после этого.) Это предотвращает выполнение компилятором "поэтапной" модификации атомной переменной без какой-либо явной синхронизации.

После некоторых размышлений и исследований мы пришли к выводу, что компилятор не может выполнять спекулятивную запись и для атомных переменных. Следовательно, я изменил ответ на вопрос 3, на который я первоначально ответил "нет".

Другие полезные ссылки:

  • Bartosz Milewski: Работа с Benign Data Races С++ Way

    Милевски имеет дело с точной проблемой спекулятивных записей для атомных переменных и заключает:

    Не может ли компилятор сделать тот же самый грязный трюк и мгновенно сохранить 42 в переменной owner? Нет, это не может! Поскольку переменная объявлена ​​atomic, компилятор больше не может предполагать, что запись can not может наблюдаться другими потоками.

  • Herb Sutter on Безопасность потока и синхронизация

    Как обычно, доступно и хорошо написанное объяснение.

Ответ 2

Да, компилятору разрешено выполнять такую ​​оптимизацию. В общем, вы можете предположить, что компилятор (и процессор тоже) может изменить порядок вашего кода, если он работает в одном потоке. Если у вас более одного потока, вам нужно синхронизировать. Если вы не синхронизируете и ваш код записывается в ячейку памяти, которая написана или читается другим потоком, ваш код содержит гонку данных, в С++ это поведение undefined.

volatile не изменяет проблему гонки данных. Однако IIRC компилятору не разрешено изменять порядок чтения и записи в переменную volatile.

При использовании atomic_int компилятор может выполнять определенные оптимизации. Я не думаю, что компилятор может изобретать записи (что может сломать многопоточную программу). Тем не менее, он все равно может изменить порядок операций, поэтому будьте осторожны.