|
|
|||||||||||||||||||||||||||||
|
Новые средства параллельной обработки в Visual C++ 11Источник: msdnmicrosoft Диего Дагум
В этой статье рассматривается Visual C++ 11, это предварительная версия технологий. Любая изложенная здесь информация может быть изменена. Продукты и технологии: Visual C++ 11 В статье рассматриваются:
Исходный код можно скачать по ссылке code.msdn.microsoft.com/mag201203CPP. Новейшая итерация C++, известная под названием C++11 и одобренная Международной организацией по стандартизации (International Organization for Standardization, ISO) в прошлом году, формализует новый набор библиотек и несколько зарезервированных слов, используемых при программировании параллельной обработки. Многие и раньше использовали параллельную обработку в C++, но всегда с помощью одной из сторонних библиотек, которые нередко напрямую предоставляют API операционной системы. Герб Саттер (Herb Sutter) объявил в декабре 2004 г., что "бесплатные подарки" в производительности закончились, имея в виду, что производители процессоров больше не могут создавать более быстрые процессоры простым наращиванием их тактовой частоты из-за чрезмерно растущего потребления энергии и выделяемого тепла. В итоге это привело нас в современный мир многоядерных процессоров - в новую реальность, важный шаг в которую только что сделал стандартный C++. Я решил разбить эту статью на две основные темы. Первая начинается с раздела "Параллельное выполнение", который охватывает технологии, позволяющие приложениям параллельно выполнять независимые или частично независимые операции. Вторая основная тема начинается с раздела "Синхронизация параллельной обработки" - в ней исследуются механизмы синхронизации упомянутых выше операций, обрабатывающих данные, с целью избежать условий для возникновения гонок. Эта статья написана с учетом средств, включенных в предстоящую версию Visual C++ (теперь она называется Visual C++ 11). Некоторые из них уже доступны в текущей версии, Visual C++ 2010. Статья не является ни руководством по моделированию параллельных алгоритмов, ни исчерпывающим описанием всех новых возможностей - это обстоятельное введение в новые средства параллельной обработки C++11. Параллельное выполнениеКогда вы моделируете процессы и проектируете алгоритмы для обработки данных, возникает естественный соблазн выразить их последовательностью этапов. Пока производительность находится в приемлемых рамках, чаще всего рекомендуют придерживаться именно такой схемы, потому что понять ее гораздо легче, а это очень важно для сопровождения кодовых баз. Когда производительность становится фактором, вызывающим тревогу, традиционные попытки преодолеть эту проблему заключаются в такой оптимизации последовательного алгоритма, чтобы он расходовал меньше процессорных тактов. Однако это возможно лишь до определенной точки, после которой дальнейшая оптимизация либо недоступна, либо чрезмерно сложна в реализации. Тогда приходит пора разбивать последовательные наборы этапов на параллельные операции. В первом разделе вы узнаете следующее.
Асинхронные задачиВ коде, сопровождающем эту статью, вы найдете проект Sequential Case (рис. 1). Рис. 1. Код Sequential Case
Функция main запрашивает у пользователя некие данные, а затем передает их трем функциям: calculateA, calculateB и calculateC. Впоследствии результаты комбинируются, и создается выходная информация для пользователя. В каждую функцию вычислений в сопутствующих материалах введена случайная задержка от одной до трех секунд. Учитывая, что они выполняются последовательно, это приводит к тому, что в худшем случае общее время выполнения (после ввода данных) длится девять секунд. Вы можете сами опробовать этот код, нажав клавишу F5 и запустив пример. Поэтому мне нужно пересмотреть последовательность выполнения и найти стадии, которые можно выполнять параллельно. Так как эти функции независимы, их можно выполнять параллельно, используя функцию async:
Я ввел здесь две концепции: async и future - обе они определены в заголовке <future> и пространстве имен std. Первая принимает функцию, лямбду или функцию-объект (функтор) и возвращает future. Вы можете рассматривать концепцию future как шаблон подстановки конечного результата. Какого результата? Того, который возвращается функцией, вызванной асинхронно. В какой-то момент мне понадобятся результаты этих выполняемых параллельно функций. Вызов метода get в каждом future блокирует выполнение до тех пор, пока не будет доступно значение. Вы можете проверить и сравнить переработанный код с первоначальным последовательным, запустив проект AsyncTasks. В этой модификации задержка в худшем случае составляет примерно три секунды против девяти для последовательной версии. Это облегченная модель программирования, которая освобождает разработчика от обязанности создавать потоки. Впрочем, вы можете определять политики создания потоков, но здесь я не буду говорить об этом. ПотокиМодели асинхронных задач, представленной в предыдущем разделе, в каких-то случаях может оказаться достаточно, но если вам нужны более глубокая обработка и более тонкий контроль за выполнением потоков, то C++11 предлагает класс thread, объявленный в заголовке <thread> и расположенный в пространстве имен std. Несмотря на более сложную модель программирования, потоки обеспечивают более эффективные способы синхронизации и координации, что позволяет одному потоку передавать выполнение другому и ждать определенное время или до того момента, пока другой поток не закончит свою работу. В следующем примере (он содержится в проекте Threads в прилагаемых материалах) у меня имеется лямбда-функция, которая, получив целочисленный аргумент, выводит в консоль кратные ему значения, меньшие 100 000:
Как вы увидите в последующих примерах, тот факт, что я передал лямбду потоку, не существенен; можно было бы обойтись функцией или функтором. В функции main я запускаю эту функцию в двух потоках с разными параметрами. Взгляните на мои результаты (они могут варьироваться при разных прогонах из-за различий в синхронизации):
Я мог бы реализовать пример с асинхронными задачами из предыдущего раздела на основе потоков. В этом случае я должен ввести концепцию promise. Это своего рода приемник, через который пройдет результат, когда он будет доступен. Где после этого появится результат? Каждому promise сопоставляется future. Код на рис. 2, доступный в проекте Promises, связывает три потока (вместо задач) с соответствующими promise и заставляет каждый поток вызывать функцию calculate. Сравните этот код с более простой версией AsyncTasks. Рис. 2. Сопоставление future с promise
Переменные и исключения уровня потоковВ C++ можно определять глобальные переменные, область видимости которых ограничена только уровнем приложения, включая потоки. Но теперь появился способ так определять эти глобальные переменные, чтобы в каждом потоке хранилась своя копия этих переменных. Данная концепция известна под названием "хранилище, локальное для потока (thread local storage, TLS), которое объявляется так:
Если это объявление размещается в области видимости какой-либо функции, то видимость этой переменной будет ограничена этой функцией, но каждый поток будет хранить собственную статическую копию этой переменной. То есть значения данной переменной в каждом потоке сохраняются между вызовами функции. Хотя thread_local недоступно в Visual C++ 11, его можно имитировать с помощью нестандартного расширения от Microsoft:
Что будет, если исключение генерируется внутри потока? В ряде случаев исключение может быть захвачено и обработано в стеке вызовов внутри потока. Но, если поток не обрабатывает данное исключение, вам нужен некий способ его транспортировки потоку-инициатору. Требуемые механизмы введены в C++11. На рис. 3 (полный код доступен в проекте ThreadInternals) показана функция sum_until_element_with_threshold, которая обходит вектор до тех пор, пока не обнаруживает конкретный элемент, попутно суммируя все элементы, попадающиеся ей в процессе обхода. Если сумма превышает пороговую величину, генерируется исключение. Рис. 3. TLS и исключения
При генерации исключения оно захватывается через current_exception в exception_ptr. Функция main запускает поток для sum_until_element_with_threshold, параллельно вызывая ту же функцию с другим параметром. Когда оба вызова завершаются (в основном потоке и в потоке, запущенном из него), их exception_ptr анализируются:
Если любой из этих exception_ptr инициализируется - признак того, что произошло какое-то исключение, - их исключения инициируются заново с помощью rethrow_exception:
Вот результат нашего выполнения, когда сумма во втором потоке превысила свое пороговое значение:
Синхронизация параллельной обработкиВ идеале было бы желательно, чтобы все приложения можно было разбивать на набор совершенно независимых асинхронных задач. На практике это почти никогда недостижимо, так как существуют, как минимум, зависимости от данных, параллельно обрабатываемых всеми участниками. В этом разделе вы ознакомитесь с новыми технологиями C++11, предотвращающими условия гонок (конкуренцию).
Атомарные типыЗаголовок <atomic> вводит ряд элементарных типов - atomic_char, atomic_int и др., - реализованных с применением блокирующих операций. Таким образом, эти типы эквивалентны своим омонимам без префикса atomic_, но с тем различием, что все их операции присваивания (==, ++, --, +=, *= и т. д.) защищены от условий гонок. Поэтому посреди присваивания чего-либо этим типам данных другой поток не сможет прервать эту операцию и изменить значения. В следующем примере два параллельных потока (один из них является основным) ищут разные элементы внутри одного и того же вектора:
При нахождении каждого элемента из потока выводится сообщение, уведомляющее о позиции в векторе (или об итерации), в которой был найден данный элемент:
Здесь также присутствует общая переменная, total_iterations, которая обновляется комбинированным числом итераций, выполненных обоими потоками. Таким образом, total_iterations должна быть атомарной, что два потока не могли обновить ее одновременно. В предыдущем примере, даже если бы в find_element не требовалось выводить частичное число итераций, все равно нужно суммировать число итераций в локальной, а не в общей переменной total_iterations, чтобы избежать конкуренции за атомарную переменную. Предыдущий пример вы найдете в проекте Atomics. Запустив его, я получил следующее:
Мьютекс и блокировкиВ предыдущем разделе был показан конкретный случай взаимоисключения (mutual exclusion, mutex) для получения доступа к элементарным типам на запись. В заголовке <mutex> определен ряд блокируемых классов (lockable classes) для определения критических областей (critical regions). Благодаря этому вы можете определить мьютекс, чтобы установить критическую область вокруг набора функций или методов. Тогда обращаться к любому члену этого набора сможет только один поток единовременно, успешно захвативший свой мьютекс. Поток, пытающийся захватить мьютекс, может либо оставаться блокированным, пока этот мьютекс не станет доступным, либо потерпеть неудачу в этих попытках. Между этими двумя крайностями предлагается альтернативный класс timed_mutex, ожидать захвата которого можно лишь в течение короткого интервала, после чего попытка считается неудачной. Это помогает избегать взаимоблокировок (deadlocks). Захваченный (блокированный) мьютекс по окончании работы в критической области нужно явным образом освободить (разблокировать) для других потоков. Если вы этого не сделаете, поведение приложения может стать непредсказуемым по аналогии с тем, когда вы забываете освободить динамическую память. Однако в случае с мьютексом последствия будут куда хуже, так как приложение может оказаться вообще больше не способным функционировать должным образом. К счастью, в C++11 также предусмотрены блокирующие классы (locking classes). Блокировка действует на мьютекс, но ее деструктор гарантирует освобождение мьютекса, если он заблокирован. В следующем коде (доступном в проекте Mutex) определяется критическая область вокруг мьютекса с именем mx:
Этот мьютекс гарантирует, что две функции - funcA и funcB - смогут работать параллельно, не пересекаясь в критической области. Функция funcA при необходимости будет ждать входа в критическую область. Чтобы заставить ее делать это, достаточно простейшего блокирующего механизма lock_guard:
Согласно определению, funcA должна обращаться к критической области три раза. Функция funcB будет пытаться захватить мьютекс и, если он уже блокирован к тому времени, просто подождет одну секунду до повторной попытки получить доступ к критической области. Механизм, который она использует, - unique_lock с политикой try_to_lock_t, как показано на рис. 4. Рис. 4. Блокировка с ожиданием
Функция funcB в соответствии с ее определением будет пытаться войти в критическую область до пяти раз. Результат выполнения этих функций показан на рис. 5. Из пяти попыток funcB сумела войти в критическую область лишь дважды. Рис. 5. Выполнение проекта-примера Mutex
Условные переменныеВ заголовке <condition_variable> определен последний рассматриваемый в этой статье механизм, имеющий фундаментальное значение в тех случаях, когда координация между потоками увязана с событиями. В следующем примере, доступном в проекте CondVar, функция producer ставит элементы в очередь:
Стандартная очередь не является безопасной в многопоточной среде, поэтому вы должны гарантировать, что в процессе постановки в очередь больше никто использовать эту очередь не будет (т. е. функция consumer не извлекает какой-либо элемент). Функция consumer пытается извлечь элементы из очереди, когда они становятся доступными, или просто ждет какое-то время на условной переменной до повторения попытки; если две последовательные попытки оказываются неудачными, consumer завершается (рис. 6). Рис. 6. Пробуждение потоков через условные переменные
Функция producer должна пробуждать функцию consumer через notify_all всякий раз, когда в очереди становится доступным новый элемент. Тем самым producer предотвращает засыпание consumer на весь интервал, если элементы готовы до истечения этого периода. Результат моего прогона показан на рис. 7. Рис. 7. Синхронизация с помощью условных переменных
Целостное представлениеНапомню, что в этой статье показана концептуальная панорама механизмов, введенных в C++11 для поддержки параллельного выполнения в эпоху многоядерных процессоров. Асинхронные задачи позволяют распараллеливать выполнение с использованием облегченной модели программирования. Результат каждой задачи можно получить через сопоставленный future. Потоки обеспечивают более тонкое управление, чем задачи, но влекут за собой более существенные издержки - особенно учитывая механизмы хранения раздельных копий статических переменных и транспорта исключений между потоками. Так как параллельные потоки работают с общими данными, C++11 предлагает ресурсы, предотвращающие конкуренцию. Атомарные типы гарантируют модификацию данных только одним потоком единовременно. Мьютексы помогают нам определять критические области в коде - регионы, в которых запрещен параллельный доступ потоков. Блокировки обертывают мьютексы, увязывая разблокировку последних с жизненным циклом первых. Наконец, условные переменные обеспечивают более эффективную синхронизацию потоков, поскольку некоторые потоки могут ждать события, о которых их уведомляют другие потоки. В этой статье не были рассмотрены все способы конфигурирования и использования каждого из этих механизмов, но теперь у вас должно сложиться целостное впечатление о них, и вы готовы самостоятельно продолжить более глубокое их изучение. Ссылки по теме
|
|