Является ли volatile bool для управления потоками ошибочным?
В результате моего ответа на этот вопрос я начал читать о ключевом слове volatile
и каков консенсус относительно этого. Я вижу, что есть много информации об этом, какая-то старая, которая сейчас кажется неправильной, и много новой, которая говорит, что ей почти нет места в многопоточном программировании. Следовательно, я хотел бы уточнить конкретное использование (не могу найти точный ответ здесь на SO).
Я также хочу отметить, что я понимаю требования к написанию многопоточного кода в целом и почему volatile
не решает проблемы. Тем не менее, я вижу код с помощью volatile
для управления потоками в базах кода, в которых я работаю. Кроме того, это единственный случай, когда я использую volatile
ключевое слово, так как все другие общие ресурсы правильно синхронизированы.
Скажем, у нас есть такой класс:
class SomeWorker
{
public:
SomeWorker() : isRunning_(false) {}
void start() { isRunning_ = true; /* spawns thread and calls run */ }
void stop() { isRunning_ = false; }
private:
void run()
{
while (isRunning_)
{
// do something
}
}
volatile bool isRunning_;
};
Для простоты некоторые вещи опущены, но существенным является то, что создается объект, который делает что-то во вновь порожденном потоке, проверяя ( volatile
булево, чтобы знать, должно ли это остановиться. Это логическое значение устанавливается из другого потока всякий раз, когда он хочет, чтобы работник остановился.
Насколько я понимаю, что причина для использования volatile
в этом конкретном случае просто избежать какой-либо оптимизации, которая бы кэшировала его в регистре для цикла. Отсюда получается бесконечный цикл. Нет необходимости правильно синхронизировать вещи, потому что рабочий поток в итоге получит новое значение?
Я хотел бы понять, считается ли это полностью неправильным и правильно ли использовать синхронизированную переменную? Есть ли разница между компилятором / архитектурой / ядрами? Может быть, это просто небрежный подход, который стоит избегать?
Я был бы счастлив, если бы кто-то разъяснил это. Спасибо!
РЕДАКТИРОВАТЬ
Мне было бы интересно увидеть (в коде), как вы решите это.
6 ответов
volatile
может быть использован для таких целей. Однако это расширение стандарта C++ от Microsoft:
Microsoft Specific
Объекты, объявленные как volatile, являются (...)
- Запись в энергозависимый объект (volatile write) имеет семантику Release; (...)
- Чтение летучего объекта (volatile read) имеет семантику Acquire; (...)
Это позволяет использовать энергозависимые объекты для блокировок и выпусков памяти в многопоточных приложениях. (добавлено)
То есть, насколько я понимаю, когда вы используете компилятор Visual C++, volatile bool
для большинства практических целей atomic<bool>
,
Следует отметить, что в более новых версиях VS добавлен переключатель /volatile, который управляет этим поведением, так что это верно только в том случае, если /volatile:ms
активен
Вам не нужна синхронизированная переменная, а скорее атомная переменная. К счастью, вы можете просто использовать std::atomic<bool>
,
Ключевая проблема заключается в том, что если более одного потока одновременно получают доступ к одной и той же памяти, то, если доступ не является атомарным, вся ваша программа перестает быть в четко определенном состоянии. Возможно, вам повезло с булом, который в любом случае обновляется атомарно, но единственный способ быть оскорбительным в том, что вы все делаете правильно, - это использовать атомарные переменные.
"Просмотр кодовых баз, в которых вы работаете", вероятно, не очень хорошая мера, когда дело доходит до изучения параллельного программирования. Параллельное программирование - чертовски сложная задача, и очень немногие понимают его полностью, и я готов поспорить, что подавляющее большинство кодов, написанных на доморощенном языке (т.е. не использующих выделенные параллельные библиотеки повсюду), в некотором смысле неверно. Проблема заключается в том, что эти ошибки могут быть чрезвычайно трудно обнаружить или воспроизвести, поэтому вы никогда не узнаете.
Изменить: Вы не говорите в своем вопросе, как обновляется bool, поэтому я предполагаю худшее. Если вы поместите всю операцию обновления в глобальную блокировку, например, тогда, конечно, не будет одновременного доступа к памяти.
С помощью volatile
достаточно только на одном ядре, где все потоки используют один и тот же кеш. На многоядерных, если stop()
вызывается на одном ядре и run()
выполняется на другом, может потребоваться некоторое время для синхронизации кэшей ЦП, что означает, что два ядра могут видеть два разных представления isRunning_
, Это означает run()
будет работать некоторое время после его остановки.
Если вы используете механизмы синхронизации, они гарантируют, что все кэши получат одинаковые значения, за счет некоторой задержки программы. Важнее ли для вас производительность или правильность, зависит от ваших реальных потребностей.
Есть три основные проблемы, с которыми вы сталкиваетесь при многопоточности:
1) Синхронизация и безопасность потоков. Переменные, которые совместно используются несколькими потоками, должны быть защищены от записи в несколько потоков одновременно и защищены от чтения во время неатомарных записей. Синхронизация объектов может быть выполнена только через специальный объект семафор / мьютекс, который гарантированно сам по себе является атомарным. Ключевое слово volatile не помогает.
2) Инструкция по трубопроводам. ЦП может изменить порядок выполнения некоторых инструкций, чтобы ускорить выполнение кода. В многопроцессорной среде, где один поток выполняется для каждого процессора, процессоры передают инструкции, не зная, что другой процессор в системе делает то же самое. Защита от инструктажа называется барьерами памяти. Все хорошо объяснено в Википедии. Барьеры памяти могут быть реализованы либо через выделенные объекты барьера памяти, либо через объект семафора / мьютекса в системе. Компилятор мог бы, возможно, выбрать вызов барьера памяти в коде, когда используется ключевое слово volatile, но это было бы скорее специальным исключением, а не нормой. Я бы никогда не предположил, что ключевое слово volatile сделало это без проверки в руководстве по компилятору.
3) Незнание компилятором функций обратного вызова. Как и в случае аппаратных прерываний, некоторые компиляторы могут не знать, что была выполнена функция обратного вызова, и обновили значение в середине выполнения кода. Вы можете иметь такой код:
// main
x=true;
while(something)
{
if(x==true)
{
do_something();
}
else
{
do_seomthing_else();
/* The code may never go here: the compiler doesn't realize that x
was changed by the callback. Or worse, the compiler's optimizer
could decide to entirely remove this section from the program, as
it thinks that x could never be false when the program comes here. */
}
}
// thread callback function:
void thread (void)
{
x=false;
}
Обратите внимание, что эта проблема появляется только на некоторых компиляторах, в зависимости от настроек оптимизатора. Эта конкретная проблема решается с помощью ключевого слова volatile.
Таким образом, ответ на вопрос таков: в многопоточной программе ключевое слово volatile не помогает с синхронизацией / безопасностью потоков, скорее всего, оно не действует как барьер памяти, но может предотвратить опасные предположения оптимизатора компилятора.
Это будет работать для вашего случая, но для защиты критического раздела этот подход неверен. Если бы это было правильно, то можно использовать изменчивый bool почти во всех случаях, когда используется мьютекс. Причина этого в том, что переменная переменная не гарантирует применения каких-либо барьеров памяти или какого-либо механизма согласованности кэша. Наоборот, мьютекс делает. Другими словами, как только мьютекс заблокирован, аннулирование кэша передается всем ядрам, чтобы поддерживать согласованность между всеми ядрами. С изменчивым это не тот случай. Тем не менее, Андрей Александреску предложил очень интересный подход к использованию volatile для обеспечения синхронизации на общем объекте. И, как вы увидите, он делает это с мьютексом; volatile используется только для предотвращения доступа к интерфейсу объекта без синхронизации.
Я думаю, что в этом коде нет ничего плохого, и он работает нормально. Однако, как вы сказали, такой способ записи больше не рекомендуется, поскольку эффективность невысока и ремонтопригодность невысока. Если вы уже можете использовать ATOMIC, просто откажитесь от такого способа написания причины:
- Индивидуальное обновление кэша ЦП не будет немедленно синхронизировано с многоядерным процессором.
- Когда вы почувствуете, что этот код работает нормально, вы увлечетесь, добавите другие переменные поля, и ЦП может выполниться не по порядку, поэтому возможно, что ваши недавно добавленные другие переменные все еще будут нулевым указателем, когда ваш изменчивый BOOL значение верно.