С. Мейерс - Эффективный и современный C++ (1114942), страница 67
Текст из файла (страница 67)
Испоnьзуйте std::atomic дnя параnnеnьности, volatile-дnя особой памяти273В качестве конкретного примера того, как отличаются поведения s t d : : а tornicи vol a t i l e в многопоточной программе, рассмотрим простые счетчики каждого вида,увеличиваемые несколькими потоками. Инициализируем каждый из них значением О:std: : atornic<int> ас ( О ) ; 11 " счетчик atornic"volatile int vc ( O ) ;/ / " счетчик volat ile"Затем увеличим каждый счетчик по разу в двух одновременно работающих потоках:/ * --- - - Поток 1 -- --- * / / * ----- Поток 2 ----- * /++ас;++ас ;++vc ;++vc;По завершении обоих потоков значение а с (т.е.
значение std : : atomic) должно бытьравно 2, поскольку каждый инкремент осуществляется как атомарная, неделимая операция. Значение vc, с другой стороны, не обязано быть равным 2, поскольку его инкрементможет не быть атомарным. Каждый инкремент состоит из чтения значения vc, увеличения прочитанного значения и записи результата обратно в vc.
Но для объектов volatileне гарантируется атомарность всех трех операций, так что части двух инкрементов vcмогут чередоваться следующим образом.1 . Поток 1 считывает значение vc, равное О.2. Поток 2 считывает значение vc, все еще равное О.3.
Поток 1 увеличивает О до 1 и записывает это значение в vc.4. Поток 1 увеличивает О до 1 и записывает это значение в vc.Таким образом, окончательное значение vc оказывается равным 1, несмотря на два инкремента.Это не единственный возможный результат. В общем случае окончательное значениеvc непредсказуемо, поскольку переменная vc включена в гонку данных, а стандарт однозначно утверждает, что гонка данных ведет к неопределенному поведению; это означает,что компиляторы могут генерировать код, выполняющий буквально все что угодно. Конечно, компиляторы не используют эту свободу для чего-то вредоносного.
Но они могутвыполнить оптимизации, вполне корректные при отсутствии гонки данных, и эти оптимизации приведут к неопределенному и непредсказуемому поведению при наличиигонки данных.RМW-операции - не единственная ситуация, в которой применение std : : atornic ведет к успешным параллельным вычислениям, а volat i le - к неудачным. Предположим,что одна задача вычисляет важное значение, требуемое для второй задачи. Когда перваязадача вычисляет значение, она должна сообщить об этом второй задаче. В разделе 7.5поясняется, что одним из способов, которым один поток может сообщить о доступности требуемого значения другому потоку, является применение std : : atornic<boo l>. Кодв задаче, выполняющей вычисление значения, имеет следующий вид:std: : atomic<Ьool > valAvailaЬle ( false) ;auto imptVa lue274=cornputeimportantValue ( ) ; / / Вычисление значенияГnава 7.
Параnnеnьные вычисnенияtrue ;valAva ilaЫe11 Сообщение об этом11 другому потокуКак люди, читая этот код, мы знаем, что критично важно, чтобы присваиваниеimptValue имело место до присваивания valAva i l aЬle, но все компиляторы видят здесьпросто пару присваиваний независимым переменным. В общем случае компиляторыимеют право переупорядочить такие независимые присваивания. Иначе говоря, последовательность присваиваний (где а, Ь, х и у соответствуют независимым переменным)а = Ь;х = у;компиляторы могут переупорядочить следующим образом:х = у;а = Ь;Даже если такое переупорядочение выполнено н е будет, это может сделать аппаратноеобеспечение (или сделать его видимым таковым для других ядер.
если таковые имеютсяв наличии), поскольку иногда это может сделать код более быстрым.Однако применение s t d : : a t omic накладывает ограничения на разрешенные переупорядочения кода, и одно такое ограничение заключается в том, что никакой код, предшествующий в исходном тексте записи переменной s t d : : а t omi с, не может иметь место(или выглядеть таковым для друтих ядер) после нее7. Это означает, что в нашем кодеauto imptValue = compute importantVa l ue ( ) ; / / Вычисление значенияvalAvailaЫetrue ;11 Сообщение об этом11 другому потоку=компиляторы должны не только сохранять порядок присваиваний i m p t V a l u eи valAva i laЬle, но и генерировать код, который гарантирует, что так же поведет себяи аппаратное обеспечение.
В результате объявление va lAva i l a Ы e как s t d : : at o m i cгарантирует выполнение критичного требования к упорядоченности - что значениеimptVal ue должно быть видимо всеми потоками как измененное не позже, чем значениеvalAva i l aЬle.Объявление va l Ava i l aЬl e как vo l a t i l e не накладывает такое ограничение на переупорядочение кода:volatile bool valAvailaЬle ( false ) ;auto imptValue=compute importantValue ( ) ;7 Это справедливо только для s td : : а tomic, использующихпоследовательную согласованность,которая является применяемой по умопчанию (и единственной) моделью согпасованности дняобъектов std: : atomic, испопьзующих показанный в этой книге синтаксис.
C++ l l поддерживаеттакже модели согпасованности с более гибкими правилами переупорядочения кода. Такие слабые(или смягченные) модели делают возможным создание программного обеспечения, работающегоболее быстро на некоторых аппаратных архитектурах, но применение таких модепей дает программное обеснечение, которое гораздо труднее правильно понимать и поддерживать. Тонкиеошибки в коде с ослабленной атомарностью не явпяются редкостью даже для экспертов, так чтовы должны придерживаться, наскопько это возможно, последовательной согпасованности.7.6.
Испопьзуйте std::atomic дпя параплельности, volatile-дл я особой памяти275valAva ilaЫetrue ; / / Другие потоки могут увидеть это/ / присваивание до присваивания imptVa lue !Здесь компиляторы могут изменить порядок присваиваний переменным imptVa lueи valAvai laЬle, но даже если они этого не сделают, они могут не сгенерировать машинный код, который предотвратит возможность аппаратному обеспечению сделать так, чтодругие ядра увидят изменение valAvai l aЫe до изменения imptValue.Эти две проблемы - отсутствие гарантии атомарности операции и недостаточные ограничения на переупорядочение кода - поясняют, почему vola t i le бесполезендля параллельного программирования, но не поясняют, для чего же этот квалификаторполезен. В двух словах - чтобы сообщать компиляторам, что они имеют дело с памятью,которая не ведет себя нормально."Нормальная", "обычная" память обладает тем свойством, что если вы записываетев нее значение, то оно остается неизменным, пока не будет перезаписано.
Так что еслиу меня есть обычный intiпt х ;и компилятор видит последовательность операцийauto у = х; // Чтение х/ / Чтение х еще разу = х;то он может оптимизировать генерируемый код, убрав присваивание переменной у, поскольку оно является излишним из-за инициализации у.Обычная память обладает также тем свойством, что если вы запишете значениев ячейку памяти, никогда не будете его читать, а потом запишете туда же что-то еще, топервую запись можно не выполнять, потому что записанное ею значение никогда не используется.
С учетом этого в кодех = 1 0 ; / / Запись хх = 2 0 ; / / Запись х еще раэкомпиляторы могут убрать первую инструкцию. Это означает, что если у нас имеется кодauto у = х ; 1 1 Чтениех;1 1 Чтениех = 10;1 1 Записьх = 20;11 Записьухх еще раэхх еще раэто компиляторы могут рассматривать его, как если бы он имел следующий вид:auto у = х; / / Чтение х/ / Запись хх = 20;Чтобы вас не мучило любопытство, кто в состоянии написать такой код с избыточными чтениями и лишними записями (технически известными как избь1точнь1е загрузки(redundant loads) и бессмысленные сохранения (dead stores)), отвечу: нет, люди не пишутнепосредственно такой код, по крайней мере я очень на это надеюсь.
Однако после тогокак компиляторы получают разумно выглядящий код и выполняют инстанцированияшаблонов, встраивание кода и различные виды переупорядочивающих оптимизаций,276Гnава 7. Пара nn еn ьные вычисnенияв результате не так уже редко получаются и избыточные загрузки, и бессмысленные сохранения, от которых компиляторы могут избавиться.Такие оптимизации корректны, только если память ведет себя нормально. "Особая"память так себя не ведет. Наиболее распространенным видом особой памяти, вероятно, является память, используемая для отображенного на память ввода-вывода. Вместочтения и записи обычной памяти, местоположения в такой особой памяти в действительности сообщаются с периферийными устройствами, например внешними датчиками или мониторами, принтерами, сетевыми портами и т.п. Давайте с учетом этого сноварассмотрим код с, казалось бы, избыточными чтениями:auto у = х; // Чтение х11 Чтение х еще разу = х;Если х соответствует, скажем, значению, которое передает датчик температуры, товторое чтение х избыточным не является, поскольку температура между первым и вторым чтениями может измениться.Похожа ситуация с записями, кажущимися излишними.
Например, если в кодех=х =1 0 ; 11 Запись х20; 11 Запись х еще разпеременная х соответствует управляющему порту радиопередатчика, может оказаться, чтоэтот код выполняет некоторые команды с радиопередатчиком, и значение 10 соответствуеткоманде, отличной от имеющей код 20. Оптимизация, убирающая первое присваивание,могла бы изменить последовательность команд, отправляемых радиопередатчику.Квалификатор volat i l e представляет собой способ сообщить компиляторам, что мыимеем дело с такой особой памятью. Для компилятора это означает "не выполняй никаких оптимизаций над операциями с этой памятью': Так что если переменная х соответствует особой памяти, она должна быть объявлена как vola t i le:volatileiпt х ;Рассмотрим влияние этого квалификатора на последовательность нашего исходного кода:auto у = х ; 1 1 Чтение хх;1 1 Чтение х еще раз ( не может быть устранено )х10 ;11 Запись х ( не может быть устранена )11 Запись х еще разх = 20 ;у=Это именно то, чего мы хотим, когда х представляет собой отображение в память (илиотображается в ячейке памяти, совместно используемой разными процессами и т.п.).Вопрос на засыпку: какой тип у в последнем фрагменте кода: int или volat i l e int"?• Тип у получается с помощью вывода a u t o , так что используются правила, описанныев разделе 1 .