Почему энергозависимо не рассмотренный полезным в многопоточном C или программировании на C++?

Как продемонстрировано в этом ответе я недавно отправил, я, кажется, смущен утилитой (или отсутствие этого) volatile в многопоточных контекстах программирования.

Мое понимание - это: любое время переменная может быть заменена вне потока управления части кода, получающего доступ к нему, та переменная, как должны объявлять, volatile. Обработчики сигналов, регистры ввода-вывода и переменные, измененные другим потоком, все составляют такие ситуации.

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

Как и где я неправильно?

159
задан Community 23 May 2017 в 11:33
поделиться

6 ответов

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

Однако примитивы, которые мы должны были бы использовать для остальных свойств , также предоставляют те, которые выполняет volatile , поэтому в этом нет необходимости.

Для поточно-безопасного доступа к совместно используемым данным нам нужна гарантия того, что:

  • чтение / запись действительно происходит (что компилятор не просто сохранит значение в регистре, а отложит обновление основной памяти на гораздо более позднее время). )
  • , что переупорядочения не происходит. Предположим, что мы используем переменную volatile в качестве флага, чтобы указать, готовы ли некоторые данные к чтению. В нашем коде мы просто устанавливаем флаг после подготовки данных, так что все выглядит нормально. Но что, если команды переупорядочены так, чтобы флаг был установлен первым ?

volatile действительно гарантирует первую точку. Это также гарантирует, что не происходит переупорядочения между разными энергозависимыми операциями чтения / записи . Все обращения к энергозависимой памяти будут происходить в том порядке, в котором они указаны. Это все, что нам нужно для того, для чего предназначен volatile : для управления регистрами ввода-вывода или оборудованием с отображением памяти, но это не помогает нам в многопоточном коде, где объект volatile является часто используется только для синхронизации доступа к энергонезависимым данным.Эти обращения все еще могут быть переупорядочены относительно энергозависимых .

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

Однако барьеры памяти также гарантируют, что все ожидающие чтения / записи выполняются при достижении барьера, поэтому он фактически дает нам все необходимое само по себе, делая volatile ненужным. Мы можем просто полностью удалить квалификатор volatile .

Начиная с C ++ 11, атомарные переменные ( std :: atomic ) дают нам все соответствующие гарантии.

207
ответ дан 23 November 2019 в 21:36
поделиться

Чтобы ваши данные были согласованными в параллельной среде, вам необходимо применить два условия:

1) Атомарность, т.е. если я читаю или записываю некоторые данные в память, эти данные получают чтение / запись за один проход и не может быть прервано или конкурировать, например, из-за переключения контекста

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

volatile не соответствует ни одному из вышеперечисленного - или, в частности, стандарты c или c ++ относительно того, как volatile должны вести себя, не включают ни одно из вышеперечисленного.

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

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

C # и java AFAIK исправляют это, заставляя volatile придерживаться пунктов 1) и 2), однако то же самое нельзя сказать о компиляторах c / c ++, поэтому в основном поступайте так, как считаете нужным.

Для более глубокого (хотя и не беспристрастного) обсуждения этого вопроса прочтите this

6
ответ дан 23 November 2019 в 21:36
поделиться

Ваше понимание действительно неверно.

Свойство изменчивых переменных: «чтение и запись в эту переменную являются частью воспринимаемого поведения программы». Это означает, что эта программа работает (при наличии соответствующего оборудования):

int volatile* reg=IO_MAPPED_REGISTER_ADDRESS;
*reg=1; // turn the fuel on
*reg=2; // ignition
*reg=3; // release
int x=*reg; // fire missiles

Проблема в том, что это не то свойство, которое нам нужно от чего-либо поточно-ориентированного.

Например, поточно-безопасный счетчик будет просто (код, подобный ядру linux, не знаю эквивалента в C ++ 0x):

atomic_t counter;

...
atomic_inc(&counter);

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

atomic_inc(&counter);
atomic_inc(&counter);

все еще можно оптимизировать до

atomically {
  counter+=2;
}

, если оптимизатор достаточно умен (он не меняет семантику кода).

6
ответ дан 23 November 2019 в 21:36
поделиться

Я не думаю, что вы ошибаетесь - volatile необходимо, чтобы гарантировать, что поток A увидит изменение значения, если значение будет изменено чем-то другим, кроме потока A. Насколько я понимаю, volatile - это, по сути, способ чтобы сообщить компилятору: «не кэшируйте эту переменную в регистре, вместо этого всегда считывайте / записывайте ее из оперативной памяти при каждом доступе».

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

Таким образом, volatile - это нормально, если вы помните, что «наблюдаемые» изменения в volatile-переменной могут произойти не в то время, когда вы думаете, что они произойдут. В частности, не пытайтесь использовать изменчивые переменные как способ синхронизации или упорядочивания операций между потоками, потому что это не будет работать надежно.

Лично я в основном (только?) Использую флаг volatile как логическое значение "pleaseGoAwayNow". Если у меня есть рабочий поток, который непрерывно зацикливается, я заставлю его проверять изменчивое логическое значение на каждой итерации цикла и завершать его, если логическое значение когда-либо истинно. Затем основной поток может безопасно очистить рабочий поток, установив для логического значения true, а затем вызвав pthread_join (), чтобы дождаться завершения рабочего потока.

11
ответ дан 23 November 2019 в 21:36
поделиться

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

Типичный способ многопоточного программирования - не защищать каждую совместно используемую переменную на машинном уровне, а скорее вводить защитные переменные, которые направляют выполнение программы. Вместо volatile bool my_shared_flag; вы должны иметь

pthread_mutex_t flag_guard_mutex; // contains something volatile
bool my_shared_flag;

Это не только инкапсулирует «жесткую часть», но и принципиально необходимо: C не включает атомарных операций , необходимых для реализации мьютекса ; у него есть только volatile , чтобы обеспечить дополнительные гарантии относительно обычных операций.

Теперь у вас есть что-то вроде этого:

pthread_mutex_lock( &flag_guard_mutex );
my_local_state = my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag
my_shared_flag = ! my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

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

  1. другой поток имеет доступ к нему.
  2. Это означает, что ссылка на него должна была когда-то использоваться (с помощью оператора и ).
    • (Или ссылка была сделана на содержащую структуру)
  3. pthread_mutex_lock - это библиотечная функция.
  4. Это означает, что компилятор не может определить, получает ли pthread_mutex_lock каким-либо образом эту ссылку.
  5. Это означает, что компилятор должен предположить , что pthread_mutex_lock изменяет общий флаг !
  6. Значит, переменная должна быть перезагружена из памяти. volatile , хотя и имеет значение в этом контексте, является посторонним.
7
ответ дан 23 November 2019 в 21:36
поделиться

Вы также можете рассмотреть это из документации ядра Linux .

Программисты на C часто использовали volatile для обозначения того, что переменная может быть изменена вне текущего потока выполнения; в результате у них иногда возникает соблазн использовать его в коде ядра, когда используются совместно используемые структуры данных. Другими словами, они, как известно, рассматривают изменчивые типы как своего рода простую атомарную переменную, которой они не являются. Использование volatile в коде ядра почти никогда не корректно; этот документ описывает почему.

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

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

Рассмотрим типичный блок кода ядра:

 spin_lock (& ​​the_lock); 
do_something_on (& shared_data); 
do_something_else_with (& shared_data); 
spin_unlock (& ​​the_lock); { {1}} 

Если весь код следует правилам блокировки, значение shared_data не может неожиданно измениться, пока удерживается the_lock. Любой другой код , который может захотеть поиграть с этими данными, будет ожидать блокировки. Примитивы спин-блокировки действуют как барьеры памяти - они явно написаны для этого - это означает, что доступ к данным не будет оптимизирован для них. Таким образом, компилятор может подумать, что он знает, что будет в shared_data, но вызов spin_lock (), поскольку он действует как барьер памяти , заставит его забыть все, что он знает. При доступе к этим данным не возникнет проблем с оптимизацией .

Если бы shared_data была объявлена ​​изменчивой, блокировка все равно была бы необходимой. Но компилятор также не сможет оптимизировать доступ к shared_data в критическом разделе, когда мы знаем, что никто другой не может с ним работать. Пока блокировка удерживается, shared_data не является изменчивым. При работе с общими данными правильная блокировка делает энергозависимые ненужными - и потенциально опасными.

Класс энергозависимой памяти изначально предназначался для отображаемых в память регистров ввода-вывода . Внутри ядра доступ к регистрам также должен быть защищен блокировками, но также не требуется, чтобы компилятор "оптимизировал" доступ к регистрам в критической секции.Но внутри ядра доступ к памяти ввода-вывода всегда осуществляется через функции доступа ; доступ к памяти ввода-вывода напрямую через указатели не одобряется и работает не на всех архитектурах. Эти методы доступа написаны для предотвращения нежелательной оптимизации, поэтому, опять же, volatile не требуется .

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

 while (my_variable! = What_i_want) 
cpu_relax (); 
 

Вызов cpu_relax () может снизить нагрузку на процессор энергопотребление или отдача от гиперпотокового двойного процессора; он также служит барьером памяти , поэтому, опять же, volatile не нужен. Конечно, ожидание в ожидании - это вообще антисоциальный акт с самого начала.

Есть еще несколько редких ситуаций, когда volatile имеет смысл в ядре:

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

  • Встроенный ассемблерный код, изменяющий память, но не имеющий других видимых побочных эффектов, рискует быть удаленным GCC. Добавление ключевого слова volatile в операторы asm предотвратит это удаление.

  • Переменная jiffies является особенной в том смысле, что она может иметь другое значение при каждом обращении к ней, но ее можно прочитать без какой-либо специальной блокировки . Таким образом, jiffies могут быть непостоянными, но добавление других переменных этого типа категорически не одобряется.Джиффис считается проблемой "глупого наследия" (слова Линуса) в этом отношении; починить его будет больше хлопот, чем оно того стоит.

  • Указатели на структуры данных в когерентной памяти, которые могут быть изменены устройствами ввода-вывода, иногда вполне законно могут быть энергозависимыми. Кольцевой буфер , используемый сетевым адаптером, где этот адаптер изменяет указатели на , указывающие, какие дескрипторы были обработаны,является примером такого типа ситуации.

Для большей части кода не применимо ни одно из приведенных выше обоснований для изменчивости. В результате использование volatile, скорее всего, будет рассматриваться как ошибка, а потребует дополнительной проверки кода. Разработчики, испытывающие искушение использовать volatile, должны сделать шаг назад и подумать о том, чего они на самом деле пытаются достичь.

49
ответ дан 23 November 2019 в 21:36
поделиться