С. Мейерс - Эффективный и современный C++ (1114942), страница 66
Текст из файла (страница 66)
Намнужно указать для шаблонов s t d : : promise и фьючерса тип, показывающий, что по каналу связи не будут передаваться никакие данные. Таким типом является voi d. Задача обнаружения, таким образом, будет использовать std : : promise<void>, а задача реакции std : : future<void> или std : : shared_ future<void>. Задача обнаружения устанавливаетсвой объект std : : promise<void>, когда происходит интересующее нас событие, а задачареакции ожидает с помощью вызова wait своего фьючерса.
Даже несмотря на то, что задача реакции не получает никаких данных от задачи обнаружения, канал связи позволитзадаче реакции узнать, что задача обнаружения "записала" vоid-данные с помощью вызова set_value своего объекта std : : promise.Так что для данногоstd : : promise<void> р;/ / Коммуникационный каналкод задачи обнаружения тривиален:/ / Обнаружение события1 1 Сообщение задаче реакциир. set_value ( ) ;Код задачи реакции не менее прост:p get future ( )._.wait ( ) ;/ / Подготовка к реакции// Ожидание фьючерса,11 соответствующего р11 Реакция на событиеЭтот подход, как и использование флага, не требует мьютексов, работает независимоот того, устанавливает ли задача обнаружения свой объект std : : promi se до того, какзадача реакции вызывает wa it, и невосприимчива к ложным пробуждениям.
(Этой проблеме подвержены только переменные условия.) Подобно подходу на основе переменныхусловия задача реакции оказывается истинно заблокированной после вызова wai t, такчто во время ожидания не потребляет системные ресурсы. Идеально, нет�Не совсем. Конечно, подход на основе фьючерсов обходит описанные неприятности, но ведь есть и другие. Например, в разделе 7.4 поясняется, что между std : : promiseи фьючерсом находится общее состоян ие, а общие состояния обычно выделяются динамически. Поэтому следует предполагать, что данное решение приводит к расходам на динамическое выделение и освобождение памяти.7.5.
Применяйте фьючерсы void дл я одноразовых сообщений о событиях269Вероятно, еще более важно то, что std : : promise может быть установлен только одинраз. Канал связи между std : : promise и фьючерсом является одноразовым механизмом:он не может быть использован многократно. Это существенное отличие от применения переменных условия и флагов, которые могут использоваться для связи много раз.(Переменная условия может быть уведомлена неоднократно, а флаг может сбрасыватьсяи устанавливаться вновь.)Ограничение однократности не столь тяжкое, как можно подумать.
Предположим,что вы хотите создать системный поток в приостановленном состоянии. То есть вы хотели бы заплатить все накладные расходы, связанные с созданием потока, заранее, с тем,чтобы как только вы будете готовы выполнить что-то в этом потоке, вы сможете делатьэто сразу, без задержки. Или, может быть, вы захотите создать поток в приостановленном состоянии с тем, чтобы можно было настроить его перед выполнением.
Такая настройка может включать, например, установку приоритета. API параллельных вычислений С++ не предоставляет способ выполнить такие действия, но объекты s t d : : threadпредлагают функцию-член nat ive_handl e, результат которой призван предоставитьдоступ к API многопоточности используемой платформы (обычно потокам POSIX илиWindows).
Низкоуровневые API часто позволяют настраивать такие характеристики потоков, как приоритет или сродство.В предположении, что требуется приостановить поток только один раз (после создания, но до запуска функции потока), можно воспользоваться vоi d-фьючерсом. Вот каквыглядит эта методика.std: : promise<void> р ;void react ( ) ;void detect ( ){std: : thread t ( [ ]{11 Функция потока реакции1 1 Функция потока обнаружения1 1 Создание потокаp .
get_future ( ) . wai.t ( ) ; / / Приостановлен до/ / установки фьючерсаreact ( ) ;))р . set value ()_;;t . j oin ( ) ;111111111111Здесь t приостановлендо вызова reactЗапуск t (и тем самым вызов react )Выполнение дополнительной работыДелаем t неподключаемым( см. раздел 7 . 3 )Поскольку важно, чтобы поток t стал неподключаемым на всех путях выполнения,ведущих из detect, применение RАП-класса наподобие приведенного в раздела 7.3 класса ThreadRAI I выглядит целесообразным.
На ум приходит следующий код:void detect ( ){ThreadRA I I tr (std: : thread ( [ ]270Глава 7. Параллельные вычисления11 RАI I -объектp . get_future ( ) . wait ( ) ;react ( ) ;})'ThreadRAI I : : DtorAction : : join / / Рискованно ! (См. ниже ));11 Поток в tr приостановлен11 Поток в tr разблокированp . set_value ( ) ;Выглядит безопаснее, чем на самом деле.
Проблема в том, что, если в первой области" ..." (с комментарием "Поток в tr приостановлен") будет сгенерировано исключение, для рникогда не будет вызвана функция set value. Это означает, что вызов wai t в лямбдавыражении никогда не завершится. А это, в свою очередь, означает, что поток, выполняющий лямбда-выражение, никогда не завершается, а это представляет собой проблему, поскольку RАII-объект t r сконфигурирован для выполнения j o i n для этого потокав деструкторе. Другими словами, если в первой области кода ""." будет сгенерированоисключение, эта функция "зависнет': поскольку деструктор tr никогда не завершится.Имеются способы решения и этой проблемы, но я оставлю их в священном видеупражнения для читателя".
Здесь я хотел бы показать, как исходный код (т.е. без применения ThreadRA I I ) может быть расширен для приостановки и последующего продолжения не одной задачи реакции, а нескольких. Это простое обобщение, поскольку ключомявляется применение std : : shared _ future вместо std : : future в коде react. Как вы ужезнаете, функция-член share объекта s t d : : future передает владение его общим состоянием объекту std : : shared_ future, созданному share, а после этого код пишется почтисам по себе. Единственной тонкостью является то, что каждый поток реакции требуетсобственную копию std : : shared_ future, которая ссылается на общее состояние, так чтообъект std : : shared future, полученный от share, захватывается по значению лямбдавыражением, запускаемым в потоке реакции:__std : : promise<void> р ;void detect ( ){1 1 Как и ранее11 Теперь ДJJЯ нескольких11 задач реакцииauto sf=p .
get future ( ) . share ( ) ; / / Тип sf // std : : shared future<void>/ / Контейнер ДJJЯ потоковstd : : vector<std : : thread> vt ;11 реакцииfor ( int i = О ; i < threadsToRun ; ++i) {11 Ожидание локальной копии s f ;11 см. emplace_back в разделе 8 . 2 :vt . emplace_back ( [ sf] { s f . wait ( ) ;react ( ) ; } ) ;" Разумной отправной точкой дпя начапа изучения этого вопроса является запись в моем блоrе от24 декабря 201 3 года в The View From Aristeia, "ThreadRAII + Thread Suspension TrouЫe?"=7.5. Применяйте фьючерсы void для одноразовых сообщений о событиях27 111 detect " зависает " , если11 эдесь генерируется11 исключение !11 Продолжение всех потоковp . set_value ( ) ;for ( auto& t : vt ) {t .
joiл ( ) ;111111Все потоки делаютсянеподключаемыми;см ."auto&"в разделе1.2Примечателен тот факт, что дизайн с помощью фьючерсов позволяет добиться описанного эффекта, так что поэтому следует рассматривать возможность его применениятам, где требуется одноразовое сообщение о событии.Сnедует запомнить•Для простого сообщения о событии дизайн с применением переменных условия требует избыточных мьютексов, накладывает ограничения на относительное выполнение задач обнаружения и реакции и требует от задачи реакции проверки того, чтособытие в действительности имело место.•Дизайн с использованием флага устраняет эти проблемы, но использует опрос, а неблокировку.•Переменные условия и флаги могут быть использованы совместно, но получающийся механизм сообщений оказывается несколько неестественным.•Применение объектов std : : promi se и фьючерсов решает указанные проблемы, ноэтот подход использует динамическую память для общих состояний и ограниченодноразовым сообщением.7 6 Испоnьзуйте s td : : а tomic дnя параnn еnьности ,vola tileдnя особой памя ти..-Бедный квалификатор volat i le! Такой неверно понимаемый .
. . Его даже не должнобыть в этой главе, потому что он не имеет ничего общего с параллельным программированием. Но в других языках (например, в Java и С#) он полезен для такого программирования, и даже в С++ некоторые компиляторы перенасыщены volat i l e с семантикой,делающей его применимым для параллельного программирования (но только при компиляции этими конкретными компиляторами). Таким образом, имеет смысл обсудитьvolat i l e в главе, посвященной параллельным вычислениям, хотя бы для того, чтобыразвеять окружающую его путаницу.Возможность С++, которую программисты периодически путают с vo 1 а t i 1 еи которая, безусловно, относится к данной главе, - это шаблон s t d : : a t omi c . Инстанцирования этого шаблона (например, s t d : : a t omi c< i л t > , s t d : : a tomi c<boo l > ,272Гnава 7 .
Параnnеnьные вычисnенияstd : : atomic<Widget * > и т.п.) предоставляют операции, которые другими потоками будутгарантированно восприниматься как атомарные. После создания объекта std : : atomicоперации над ним ведут себя так, как будто они выполняются внутри критического раздела, защищенного мьютексом, но эти операции обычно реализуются с помощью специальных машинных команд, которые значительно эффективнее применения мьютексов.Рассмотрим код с применением s t d : : atomi c:std: : atomic<int>ai 1 0 ;std : : cout << ai;++a i ;--ai;=ai ( 0 ) ; 1 111111111Инициализация ai значением ОАтомарное присваивание ai значения 1 0Атомарное чтение значения aiАтомарный инкремент ai ДО 1 1Атомарный декремент ai до 1 0В процессе выполнения данных инструкций другие потоки, читающие a i , могут увидеть только значения О, 10 и 1 1 . Никакие друтие значения невозможны (конечно, в предположении, что это единственный поток, модифицирующий ai).Следует отметить два аспекта этого примера.
Во-первых, в и нструкции"std : : cout « a i ; " тот факт, что ai представляет собой s t d : : at omi c, гарантируеттолько то, что атомарным является чтение a i . Нет никакой гарантии атомарности всейинструкции. Между моментом чтения значения ai и вызовом оператора operator<<для записи в поток стандартного вывода другой поток может изменить значение a i . Этоне влияет на поведение инструкции, поскольку operator<< для int использует передачу выводимого параметра типа int по значению (таким образом, выведенное значениебудет тем, которое прочитано из ai), но важно понимать, что во всей этой инструкцииатомарным является только чтение значения ai.Второй важный аспект этого примера заключается в поведении двух последних инструкций - инкремента и декремента ai.
Каждая из этих операций является операциейчтения-изменения-записи (read-modify-write - RMW), выполняемой атомарно. Это однаиз приятнейших характеристик типов std : : atomic: если объект std : : atomic создан, всеего функции-члены, включая RМW-операции, будут гарантированно рассматриватьсядрутими потоками как атомарные.В противоположность этому такой же код, но использующий квалификатор volatile,в мноrопоточном контексте не гарантирует почти ничего:volatilevi 1 0 ;std : : cout++vi ;--vi;vi ( O ) ;=«vi ;1111111111Инициализация vi значением ОПрисваивание vi значения 1 0Чтение значения viИнкремент vi ДО 1 1Декремент vi до 1 0Во время выполнения этого кода, если друтой поток читает значение v i, он может прочесть что утодно, например - 1 2, 68, 4090727, любое значение! Такой код обладает неопределенным поведением, потому что эти инструкции изменяют vi, и если друтие потоки читают vi в тот же момент времени, то эти одновременные чтения и записи памяти не защищеныни std : : atomi с, ни с помощью мьютексов, а это и есть определение гонки данных.-7.6.