|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Синхронизация параллельно выполняемых задач
Во всех компьютерных системах ресурсы ограничены. Ведь любой объем памяти конечен, как и количество устройств ввода-вывода, портов, аппаратных прерываний и процессоров. Если в среде ограниченных аппаратных ресурсов приложение состоит из нескольких процессов и потоков, то эти составляющие должны конкурировать за память, периферийные устройства и процессорное время. Когда и как долго процесс или поток будет использовать системные ресурсы, определяет операционная система. При использовании приоритетного планирования операционная система может прерывать выполняющийся процесс или поток, чтобы удовлетворить все остальные процессы и потоки, соревнующиеся за системные ресурсы. Процессам и потокам приходится также соперничать за программные ресурсы и ресурсы данных. Примерами программных ресурсов служат разделяемые библиотеки (которые предоставляют в общее пользование набор процедур или функций для процессов и потоков), а также приложения, программы и утилиты. При совместном использовании программных ресурсов в памяти содержится только одна копия программного кода. Под ресурсами данных подразумеваются объекты, системные данные (например, переменные среды), файлы, глобально определенные переменные и структуры данных. Что касается ресурсов данных, то процессы и потоки могут иметь собственные копии данных. В других случаях желательно и, возможно, даже необходимо, чтобы данные были разделяемыми. Одни процессы и потоки, работая вместе, используют ограниченные системные ресурсы в определенном порядке, в то время как другие действуют независимо и асинхронно, соревнуясь за использование разделяемых pecypсов. Для управления процессами и потоками, конкурирующими за использование данных, программист может задействовать ряд специальных методов и механизмов. Синхронизация также необходима для координации порядка выполнения параллельных задач. Примером может служить модель «изготовитель-потребитель», которая рассмотрена в главе 4. «Изготовитель» обязательно начинает выполняться до «потребителя», но не обязательно завершается до него. Подобные задачи нуждаются в синхронизации Синхронизация данных (синхронизация доступа к данным) и задач (синхронизация последовательностей инструкций) — два типа синхронизации, которые необходимо обеспечить при выполнении нескольких параллельных задач. Координация порядка выполнения потоков Предположим, у нас есть три параллельно выполняющихся потока — А, В и С. Все они участвуют в обработке списка. Список необходимо отсортировать, выполнить в нем операции поиска и вывода результатов. Каждому потоку назначается отдельная задача. Так, поток А должен отобразить результаты поиска, В — отсортировать список, а С — провести поиск. Сначала список необходимо отсортировать, затем выполнить несколько параллельных операций поиска, а уж потом отобразить результаты. Если задачи, выполняемые потоками, не синхронизировать надлежащим образом, то поток А может попытаться отобразить еще не сгенерированные результаты, что нарушит постусловие, или выхо д ное условие (postcondition), процесса. Предусловием, или вхо д ным условием (precondition), з д есь является необходимость получения отсортированно г о списка до выполнения в нем поиска. Поиск в неотсортированном списке может дать неверные результаты. Поэтому для этих трех потоков необходимо обеспечить синхронизацию задач, которая приводит в исполнение постусловия и предусловия ло г ических процессов. UML-диаграмма видов деятельности для этого процесса представлена на рис. 5 .1. Сначала поток В должен отсортировать список, затем эстафета управления передается «мно г оканальному» поиску, порождаемому потоком С. И только после завершения поисковых работ по всем направлениям поток А отображает результаты поиска. Взаимоотношения между синхронизируемыми задачами Существует четыре основных типа отношений синхронизации между любыми двумя потоками в одном процессе или между любыми двумя процессами в одном приложении: старт-старт (CC), финиш-старт (ФС), старт-финиш (СФ) и финиш-финиш (ФФ). С помощью этих основных типов отношений можно охарактеризовать координацию задач между потоками и процессами. UML-диаграмма видов деятельности для каждого типа отношений синхронизации показана на рис. 5.2. Рис. 5.1. Диаграмма видов деятельности для задач сортировки списка, поиска и отображения результатов поиска Отношения типа старт-старт (CC) В отношениях синхронизации типа старт-старт одна задача не может начаться до тех пор, пока не начнется другая. Одна задача может начаться раньше другой, нo не позже. Предположим, у нас есть программа, которая реализует инкарнацию (воплощение). Инкарнация «материализуется» в виде говорящей головы, созданной, разумеется, компьютерной программой. Инкарнация обеспечивает своего рода «одушевление» программ. Программа, которая реализует «одушевление», имеет несколько потоков. Здесь нас в первую очередь интересует поток А, который «отвечает» за анимацию результата, и поток В, который управляет звуком, или голосом, говорящей головы. Мы хотим создать иллюзию синхронизации звука и движений рта. В идеале они должны происходить абсолютно одновременно. При наличии нескольких процессоров оба потока могут начинаться одновременно. Эти потоки связаны отношением типа старт - старт. В соответствии с условиями временной синхронизации допускается, чтобы поток А начинался немного раньше потока В (именно немного — иначе будет нарушена иллюзия одновременности), но поток В не может начаться раньше потока А. Голос должен ожидать анимацию, а не наоборот. Совершенно нежелательно услышать голос до того, как зашевелятся губы (если это не синхронное озвучивание). Рис. 5.2. Возможные отношения синхронизации между задачами А и В Отношения типа финиш-старт (ФС) В отношениях синхронизации типа финиш-старт задача А не может завершиться до тех пор, пока не начнется задача В. Этот тип отношений типичен для родительско-сыновних процессов. Родительский процесс не может завершить выполнение некоторых операций до тех пор, пока не будет сгенерирован сыновний процесс или пока не будет получена обратная связь от сыновнего процесса, который начал выполнение. Сыновний процесс, «просигналивший» родителю или предоставивший ему необходимую информацию, продолжает выполняться, а родительский процесс после этого может завершиться. Отношения типа старт-финиш (СФ) Отношения типа старт-финиш можно считать обратным варианто м отношений типа финиш-старт. В отношениях синхронизации типа старт-финиш одна задача не м ожет начаться до тех пор, пока не завершится другая. Задача А не может начать выпо л нение до тех пор, пока задача В не финиширует или не завершит выполнение определенной операции. Если процесс А считывает данные из канала, связанного с процессом В, то процесс В должен записать данные в канал, прежде чем процесс А начнет считывать из него данные. Процесс В должен завершить по крайней мере одну операцию, записав в канал один элемент, прежде чем начнет действовать процесс А. Потоки, действующие по принципу «производитель-потребитель», — это еще один пример взаимоотношений типа финиш-старт. Потоки, обслуживающие сортировку и поиск элементов в списке (см. рис. 5.1), также демонстрируют этот тип отношений. Прежде чем начнут действовать потоки, реализующие поиск, должен завершить свою работу поток сортировки. Во всех этих случаях один поток или процесс должен завершить свою операцию, прежде чем другой попытается выполнить свою задачу. Если работа потоков не будет скоординирована, глобальная цель потока, процесса или приложения достигнута не будет или же будут получены ошибочные результаты. Отношения типа старт-финиш обычно предполагают существование информационной зависимости между задачами. При информационной зависимости для корректной работы потоков или процессов необходимо обеспечить межпоточное или межпроцессное взаимодействие. Например, поток поиска данных в списке сгенерирует некорректные результаты, если не будет выполнена сортировка списка. И поток-«потребитель» не получит файлы для обработки, если поток-«производитель» не подготовит их для потребителя. Отношения типа финиш-финиш (ФФ) В отношениях синхронизации типа финиш-финиш одна за д ача не может завершиться до тех пор, пока не завершится другая, т.е. задача А не может финишировать до задачи В. Этот тип отношений можно применить к описанию отношений между родительскими и сыновними процессами, которые рассматривались в главе 3. Родительский процесс должен ожидать до тех пор, пока не завершатся все сыновние процессы, и только потом сможет завершиться сам. Если описанная последовательность нарушится, и родительский процесс финиширует раньше своих потомков, то эти завершенные сыновние процессы перейдут в зомбированное состояние. Родительские процессы не должны завершаться (выходить из системы в данном случае) до тех пор, пока не выполнятся до конца их сыновние процессы. Для родительских процессов это достигается за счет вызова функции wait() для каждого из своих сыновних процессов либо ожидания деблокировки (освобождения) мьютекса или условной переменной, что может быть осуществлено сыновними потоками. Еще одним примером отношений типа финиш-финиш может служить модель «управляющий-рабочий». Задача управляющего потока — делегировать работу рабочим потокам. Для управляющего крайне нежелательно завершить работу раньше рабочих. В этом случае не были бы обработаны новые запросы к системе, не имели работы существующие потоки и не создавались новые. Если управляющий поток является основным потоком процесса, и он завершается, то процесс должен завершиться вместе со всеми рабочими потоками. Если в модели равноправных потоков поток А динамически создает объект, передаваемый потоку В, и поток А затем завершается, то вместе с ним разрушается и созданный им объект. Если это произойдет до того, как поток В получит возможность использовать этот объект, возникнет ошибка сегментации или нарушится доступ к данным Чтобы предотвратить возникновение этого типа ошибок, завершение потоков синхронизируется с помощью функции pthread_join(). Обращение к этой функции заставляет вызывающий поток ожидать до тех пор, пока не финиширует заданный поток. Таким образом и создается синхронизация типа финиш-финиш. Синхронизация доступа к данным Существует разница между данными, раздел я емыми между процессами, и данными, разделяемыми между потоками. Потоки совместно используют одно и то же адресное пространство, в то время как процессы имеют отдельные адресные пространства. Если существуют два процесса А и В, то данные, объявленные в процессе А, недоступны процессу В, и наоборот. Следовательно, один из методов, используемых процессами для разделения данных, состоит в создании блока памяти, отображаемого затем на адресное пространство процессов, которые должны разделять память. Другой подход предполагает создание блока памяти, существующего вне адресного пространства обоих процессов. К типам механизмов межпроцессного взаимодействия (МПВ) относятся каналы, файлы и передача сообщений. Именно блок памяти, разделяемый между потоками внутри одного и того же адресного пространства, и блок памяти, раздел я емый между процессами вне обоих адресных пространств, требует синхронизации данных. Память, разделяемая между потоками и процессами, показана на рис. 5 .3. Синхронизация данных необходима для управления состоянием «гонок», а также для того, чтобы позволить параллельным потокам или процессам безопасно получить доступ к блоку памяти. Синхронизация данных позволяет управлять считыванием и модификацией данных в блоке памяти. В многопоточной среде параллельный доступ к общей памяти, глобальным переменным и файлам обязательно должен быть синхронизирован. Что касается программного кода задачи, то синхронизация данных необходима в тех его блоках, где делается попытка получить доступ к блоку памяти, глобальным переменным или файлам, разделяемым с другими параллельно выполняемыми процессами или потоками. Такие блоки кода называются критическими разделами. В качестве критического раздела может выступать любой блок кода, который изменяет позицию файлового указателя, записывает данные в файл, закрывает файл и считывает или устанавливает глобальные переменные либо структуры данных. Выделение таких задач, которые выполняют чтение или запись данных, является одним из этапов управления параллельным доступом к совместно используемой памяти. Модель РРАМ Модель PRAM (Parallel Random-Access Machine — машина с параллельным произвольным доступом) — это упрощенная модель с N процессорами, обозначаемыми P 1 , Р 2 , Р 5 , ... Р n , которые разделяют одну глобальную память. Все процессоры одновременно получают доступ для чтения и записи к совместно используемой глобальной памяти. Каждый из этих теоретических процессоров может получить доступ к разделяе м ой глобальной памяти в течение одного непрерываемого интервала времени. Модель PRAM включает алгоритмы параллельного, а также исключающего чтения и записи. Алгоритмы параллельного чтения позволяют нескольким процессорам одновременно использовать одну и ту же область памяти без како г о бы то ни было искажения данных. Алгоритмы параллельной записи позволяют нескольким процессорам записывать данные в разделяемую область памяти. Алгоритмы исключающего чтения используются для получения гарантии того, что никакие два процессора никогда не будут считывать информацию из одной и той же области памяти одновременно. Алгоритмы исключающей записи гарантируют, что никакие два процессора никогда не будут записывать данные в одну и ту же область памяти одновременно. Модель PRAM можно использовать для определения характера параллельного доступа к общей памяти со стороны нескольких задач. Рис. 5.3. Память, разделяемая между потоками и процессами Параллельный и исключающий доступ к памяти Алгоритмы параллельного и исключаю щ его чтения и записи можно скомбинировать и получить следующие типы объединенных алгоритмов, которые можно реализовать для организации доступа к данным: • исключаю щ ее чтение и исключаю щ ая запись (exclusive read and exclusive write-EREW); • параллельное чтение и исключающая запись (concurrent read and exclusive write-CREW); • исключаю щ ее чтение и параллельная запись (exclusive read and concurrent write-ERCW); • параллельное чтение и параллельная запись (concurrent read and concurrent write-CRCW). Эти алгоритмы можно рассматривать как стратегии доступа, реализуемые задачами, которые совместно используют данные (рис. 5.4). Алгоритм EREW подразу м евает последовательный доступ к разделяемой памяти, т.е. к общей памяти в любой момент времени может получить доступ только одна задача. Примером стратегии доступа EREW может служить вариант реализации модели потоков «производитель-потребитель», рассмотренный в главе 4. Доступ к очереди, содержащей имена файлов, может быть ограничен исключающей записью «изготовителя» и исключающим чтением «потребителя». В любой момент времени доступ к очереди может быть разрешен только для одной задачи. Стратегия CREW позволяет множественный доступ для чтения общей памяти и исключающий доступ для записи в нее данных. Это означает отсутствие ограничений на количество задач, которые могут одновременно читать разделяемую память, но записывать в нее данные может только одна задача. При этом параллельное чтение может происходить одновременно с записью данных в общую память. При использовании этой стратегии доступа все читающие задачи могут прочитать различные значения, поскольку во время чтения значения из общей памяти записывающая задача может его модифицировать. Стратегия доступа ERCW — это прямая противоположность стратегии CREW. При использовании стратегии ERCW разрешены параллельные записи в общую память, но лишь одна задача может читать ее в любой момент времени. Стратегия доступа CRCW позволяет множеству задач выполнять параллельное чтение и запись. Для этих четырех типов алгоритмов требуются различные уровни и типы синхронизации. Их диапазон довольно широк: от стратегии доступа, реализация которой требует минимальной синхронизации, до стратегии доступа, реализация которой требует максимальной синхронизации. Наша задача— реализовать эти стратегии, поддерживая целостность данных и удовлетворительную производительность системы. EREW — самая простая для реализации стратегия, поскольку она предполагает, по сути, только последовательную обработку. На первый взгляд самой простой может показаться стратегия CRCW, но она таит в себе массу трудностей. А ведь это только кажется, что если к памяти можно получить доступ без ограничений, то в ней и речь не идет о какой бы то ни было стратегии. Все как раз наоборот: CRCW — самая трудная для реализации стратегия, которая требует максимальной синхронизации.
Что такое семафоры Семафор — это механизм синхронизации, который можно использовать для управления отношениями между параллельно выполняющимися программными компонентами и реализации стратегий доступа к данным. Семафор — это переменная специального вида, которая может быть доступна только для выполнения узкого диапазона операций. Семафор используется для синхронизации доступа процессов и потоков к разделяемой модифицируемой памяти или для управления доступом к устройствам или другим ресурсам. Семафор можно рассматривать как ключ к ресурсам. Этим ключом может владеть в любой момент времени только один процесс или поток. Какал бы задача ни владела этим ключом, он надежно запирает (блокирует) нужные ей ресурсы для ее монопольного использования. Блокирование ресурсов заставляет другие задачи, которые желают воспользоваться этими ресурсами, ожидать до тех пор, пока они не будут разблокированы и снова станут доступными. После разблокирования ресурсов следующая задача, ожидающая семафор, получает его и доступ к ресурсам. Какал задача будет следующей, определяется стратегией планирования, действующей для данного потока или процесса. Операции по управлению семафором Как упоминалось выше, к семафору можно получить доступ только с помощью специальных операций, подобных тем, которые выполняются с объектами. Это операции декремента, P(), и инкремента, V(). Если объект Mutex представляет собой семафор, то логика реализации операций P (Mutex) и V (Mutex) м ожет выглядеть таки м образо м: P(Mutex) if (Mutex > 0) { Mutex--; } else { Блокирование по объекту Mutex; } V(Mutex) if(Oчepeдь доступа к объекту Mutex не пуста){ Передача объекта Мьютекс следующей задаче; } else { Mutex++; } Реализация зависит от конкретной систе м ы. Эти операции неделимы, т.е. их невозможно прервать. Если операцию P () попытаются выполнить сразу несколько задач, то лишь одна из них получит разрешение продолжить работу. Если объект Mutex был уже декре м ентирован, то задача будет заблокирована и зай м ет м есто в очереди. Операци я V () вызываетс я задачей, которая и м еет доступ к объекту Mutex. Если получения доступа к объекту Мьютекс ожидают дру г ие задачи, он «передается » следующей задаче из очереди. Если очередь задач пуста, объект Mutex инкрементируетс я. Операции с семафором могут иметь другие имена: Операци я P(): lock() Операци я V(): unlock() Значение семафора зависит от его типа. Двоичный семафор будет иметь значение 0 или 1. Вычислительный семафор (определяю щ ий лимиты ресурсов для процессов, получающих доступ к ним) может иметь некоторое неотрицательное целочисленное значение. Стандарт POSIX определяет несколько типов семафоров. Эти семафоры испо л ьзуются процессами или потоками. Типы семафоров (а также их некоторые основные операции) перечислены в табл. 5.1. Таблица 5 .1. Типы семафоров, определенные стандартом POSIX
Операционные системы, которые не противоречат спецификации Single UNIX Specification или стандарту POSIX Standard, поддерживают реализацию семафоров, которые являются частью библиотеки libpthread (соответствующие функции объявлены в заголовке pthread. h). Мьютексные семафоры Стандарт POSIX определяет мьютексный семафор, используемый потоками и процессами, как объект типа pthread_mutex_t. Этот мьютекс обеспечивает базовые операции, необходимые для функционирования практического механизма синхронизации: • инициализация; • запрос на монопольное использование; • отказ от монопольного использования; • тестирование монопольного использования; • разрушение. Функции класса pthread_mutex_t, которые используются для выполнения этих базовых операций, перечислены в табл. 5.2. Во время инициализации выделяется память, необходимая для функционирования мьютексного семафора, и объекту семафора присваивается некоторое начальное значение. Для двоичного семафора начальным может быть значение 0 или 1. Начальным значением вычислительного семафора может быть неотрицательное число, которое представляет количество доступных ресурсных единиц. Семафорное значение можно использовать для представления предельного количества запросов, которое способна обработать программа в одном сеансе. В отличие от обычных переменных, в инициализации которых сомневаться не приходится, факт инициализации мьютекса с помощью вызова соответствующей функции гарантировать невозможно. Чтобы убедиться в том, что мьютекс проинициализирован, необходимо после вызова операции инициализации принять некоторые меры предосторожности (например, проверить значение, возвращаемое соответствующей мьютекс-ной функцией, или значение переменной errno). Системе не удастся создать мьютекс, если окажется, что занята память, предусмотренная ранее для мьютексов, или превышено допустимое количество семафоров, или семафор с данным именем уже существует, или же имеет место какая-то другая проблема, связанная с выделением памяти. Таблица 5.2. Фу н кции класса pthread_mutex_t Инициализация int pthread_mutex_init( pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); pthread_mutex_t mutex =PTHREAD_MUTEX_INITIALIZER; Запрос на монопольное использование #include <pthread.h> <time.h> int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, const struct timespec *restrict abs_timeout); Отказ от монопольного использо вания int pthread_mutex_unlock(pthread_mutex_t *mutex); Тестирование монопольно г о использования int pthread_mutex_trylock(pthread_mutex_t *mutex); Разрушение int pthread_mutex_destroy( pthread_mutex_t *mutex); Подобно потокам, мьютекс библиотеки Pthread имеет атрибутный объект (он рассматривается ниже), который инкапсулирует все атрибуты мьютекса. Этот атрибутный объект можно передать функции инициализации, в результате чего будет создан мьютекс с атрибутами, заданными с помощью этого объекта. Если при инициализации атрибутный объект не используется, мьютекс будет инициализирован значениями, действую щ ими по умолчанию. Объект типа pthread_mutex_t инициализируется как деблокированный и закрытый. Закрытый мьютекс разделяется между потоками одно г о процесса. Разделяемый мьютекс совместно используется потоками нескольких процессов. При использовании атрибутов, действую щ их по умолчанию, мьютекс может быть инициализирован статически для статических мьютексных объектов с помощью следующего макроса: pthread_mutex_t Mutex = PTHREAD_MUTEX_INITIALIZER; [11] Этот метод менее затратный, но в нем не предусмотрено проверки ошибок. Мьютекс может иметь или не иметь владельца. Операция запроса на монопольное использование предоставляет право владения мьютексом вызывающему потоку или процессу. После того как мьютекс обрел владельца, поток (или процесс) получает монопольный доступ к запрашиваемому ресурсу. При попытке завладеть «уже занятым» мьютексом (путем вызова этой операции), совершенной любыми другими потоками или процессами, они будут заблокированы до тех пор, пока мьютекс не станет доступным. При освобождении мьютекса следующий (по очереди) поток или процесс (который был заблокирован) деблокируется и получает право собственности на этот мьютекс. И освободить его может только поток, получивший данный мьютекс во владение с помощью функции pthread_mutex_lock(). Можно также использовать синхронизированную версию этой функции. В этом случае, если мьютекс несвободен, то процесс или поток будет ожидать в течение заданного промежутка времени. Если мьютекс за это время не освободится, то процесс или поток продолжит выполнение. Операция тестирования монопольного использования предназначена для проверки доступности мьютекса. Если он занят, функция возвра щ ает соответствующее значение. Достоинство этой операции состоит в том, что поток или процесс (в случае недоступности мьютекса) не блокируется и может продолжать выполнение. Если же мьютекс свободен, вызывающему потоку или процессу предоставляется право владения запрашиваемым мьютексом. При выполнении операции разрушения освобождается память, связанная с мьютексом. Память не освобождается, если у мьютекса есть владелец или если поток (или процесс) ожидают права на владение им. Использование мьютексного атрибутного объекта Мьютексный объект типа pthread_mutex_t можно использовать вместе с атрибутным объектом подобно атрибутно м у объекту потока. Мьютексный атрибутный объект инкапсулирует все атрибуты объекта м ьютекса. После инициализации его м огут использовать несколько м ьютексных объектов, передавал в качестве пара м етра функции pthread_mutex_init (). Мьютексный атрибутный объект определяет ряд функций, используемых для установки таких атрибутов, как предельный приоритет, протокол и тип. Эти и другие функции м ьютексного атрибутного объекта перечислены в табл. 5.3. Таблица 5.3. Функции доступа к мьютексному атрибу т ному объекту
Самый большой интерес представляет установка атрибута, связанного с тем, каким должен быть мьютекс: закрытым или разделяемым. Закрытые мьютексы разделяются между потоками одного процесса. Можно либо объявить мьютекс глобальным, либо организовать передачу дескриптора между потоками. Разделяемые мьютексы используются потоками, имеющими доступ к памяти, в которой разме щ ен данный мьютекс. Такой мьютекс могут разделять потоки различных процессов. Принцип действия закрытого и разделяемого мьютексов показан на рис. 5.5. Если разделять мьютекс приходится потокам различных процессов, его необходимо разместить в памяти, которая является общей для этих процессов. В библиотеке POSIX определено несколько функций, предназначенных для распределения памяти между объектами с помощью отображаемых в памяти файлов и объектов разделяемой памяти. В процессах мьютексы можно использовать для защиты критических разделов, которые получают доступ к файлам, каналам, общей памяти и внешним устройствам. Использование мьютексных семафоров для управления критическими разделами Мьютексы используются для управления критическими разделами процессов и потоков, чтобы предотвратить возникновение условий «гонок». Мьютексы позволяют избежать условий «гонок», реализуя последовательный доступ к критическому разделу. Рассмотрим код листинга5.1. В нем демонстрируется выполнение двух потоков. Для защиты их критических разделов и используются мьютексы. // Листинг 5.1. Использование мьютексов для защиты // критических разделов потоков // . . . pthread_t ThreadA, ThreadB; pthread_mutex_t Mutex,-pthread_mutexattr_t MutexAttr; void *task1(void *X) { pthread_mutex_lock(&Mutex); // Критический раздел кода. pthread_mutex_unlock(&Mutex); return(0) ; } void *task2 (void *X) { pthread_mutex_lock(&Mutex) ; // Критический раздел кода. pthread_mutex_unlосk (&Mu t ex) ; return(0) ; } int main(void) { //... pthread_mutexattr_init (&MutexAttr) ; pthread_mutex_init (&Mutex, &MutexAttr) ; //Устанавливаем атрибуты мьютекса. pthread_create(&ThreadA, NULL, taskl, NULL) ; pthread_create(&ThreadB,NULL, task2,NULL) ; //... return(0) ; } В листинге 5.1 потоки ThreadA и ThreadB содержат критические разделы, защищаемые с помощью объекта Mutex. В листинге 5.2 демонстрируется, как можно использовать мьютексы для защиты критических разделов процессов. // Листинг 5.2. Использование мьютексов для зашиты // критических разделов процессов //... int Rt; pthread_mutex_t Mutexl ; pthread_mutexattr_t MutexAttr; int main(void) { //... pthread_mutexattr_init (&MutexAttr); pthread_mutexattr_setpshared( &MutexAttr, PTHREAD_PROCESS_SHARED ) ; pthread_mutex_init (&Mutexl, &MutexAttr) ; if((Rt = fork()) == 0){ // Сыновний процесс. pthread_mutex_lock(&Mutexl); // Критический раздел. pthread_mutex_unlock(&Mutexl) ; } else{ // Родительский процесс, pthread_mutex_lock(&Mutexl); // Критический раздел. pthread_mutex_unlock(&Mutexl) ; } //.. . return(0); } Рис. 5.5. Закрытые и разделяемые мьютексы Важно отметить, что в листинге 5.2 при вызове следующей функции мьютекс инициализируется как разделяемый: pthread_mutexattr_setpshared(&MutexAttr,PTHREAD_PROCESS_SHARED); Установка этого атрибута равным значению PTHREAD_PROCESS_SHARED позволяет объекту Mutex стать разделяемым между потоками различных процессов. После вызова функции fork () сыновний и родительский процессы могут защищать свои критические разделы с помощью объекта Mutex. Критические разделы этих процессов могут содержать некоторые ресурсы, разделяемые обоими процессами. Блокировки для чтения и записи Мьютексные семафоры позволяют управлять критическими разделами, обеспечивая последовательный вход в эти разделы. В любой момент времени вход в критический раздел разрешается только одному потоку или процессу. Реализуя блокировки для чтения и записи, можно разрешить вход в критический раздел сразу нескольким потокам, если они намерены лишь считывать данные из разделяемой памяти. Следовательно, блокировкой для чтения может владеть любое количество потоков. Но если сразу несколько потоков должны записывать или модифицировать данные общей памяти, то доступ для этого будет предоставлен только одному потоку. Другими словами, никаким другим потокам не будет разрешено входить в критический раздел, если одному потоку предоставлен монопольный доступ для записи в разделяемую память. Такой подход может оказаться полезным, если приложения чаще считывают данные, чем записывают их. Если в приложении создается множество потоков, организация взаимно исключающего доступа может оказаться излишней предосторож н остью. Производительность такого приложения может значительно увеличиться, если в не м разрешить одновре м енное считывание данных нескольки м и потока м и. Стандарт POSIX определяет м еханиз м блокировки для чтения и записи посредство м типа pthread_rwlock_t. Блокировки для чтения и записи и м еют такие же операции, как и м ьютексные семафоры. Они перечислены в табл. 5.4. Различие между обычными мьютексами и мьютексами, обеспечивающими чтение и запись, заключается в операциях запроса на блокирование. Вместо одной операции блокирования здесь предусмотрено две: pthread_rwlock_rdlock() pthread_rwlock_wrlock () Функция pthread_rwlock_rdlock() предоставляет вызываю щ ему потоку блокировку чтения, а функция pthread_rwlock_wrlock() — блокировку записи. Запросив блокировку чтения, поток получит ее в том случае, если нет потоков, удерживающих блокировку записи. Если же таковые имеются, вызывающий поток блокируется. Если поток запросит блокировку записи, он ее получит в том случае, если нет потоков, удерживающих блокировку чтения или блокировку записи. Если же таковые имеются, вызывающий поток блокируется. Блокировка чтения-записи реализуется с помощью объектов типа pthread_rwlock_t. Этот же тип имеет атрибутный объект, который инкапсулирует атрибуты объекта блокировки. Функции установки и чтения атрибутов перечислены в табл. 5.5. Объект типа pthread_rwlock_t может быть закрытым (для разделения между потоками одного процесса) или разделяемым (для разде л ения между потоками различных процессов). Таблица 5.4. Операции, используемые для блокировки ч т ения-записи Операции Инициализация • int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr); Запрос на блокировку • int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); • int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); • int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock, const struct timespec *restrict abs_timeout); • int pthread_rwlock_timedwrlock( pthread_rwlock_t | *restrict rwlock, const struct timespec *restrict abs_timeout); Освобождение блокировки • int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); Тестирование блокировки • int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock); • int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock); Разрушение • int pthread_rwlock_destroy(pthread_rwlock_t *rwlock); Таблица 5.5. Функции доступа к атрибутному объекту типа pthread_rwlock_t • int pthread_rwlockattr_init (pthread_rwlockattr_t * attr); Инициализирует атрибутный объект блокировки чтения-записи,заданный параметром attr , значениями, действующими по умолчанию для всех атрибутов, определенных реализацией • int pthread_rwlockattr_destroy (pthread_rwlockattr_t * attr) Разрушает атрибутный объект б л окировки чтения-записи,заданный параметром attr Его можно инициализировать повторно, вызвав функцию pthread_rwlockattr_init () • int pthread_rwlockattr_setpshared (pthread_rwlockattr_t * attr, int pshared); int pthread_rwlockattr_getpshared (const pthread_rwlockattr_t * restrict attr, int *restrict pshared); Устанавливает или возвращает атрибут process-shared атрибутного объекта блокировки чтения-записи, заданного параметром attr . Параметр pshared может содержать следующие значения: • PTHREAD_PROCESS_SHARED (разрешает б л окировку чтения-записи, разделяемую любыми потоками, которые имеют доступ к памяти, выделенной для этого объекта блокировки, даже если потоки принадлежат различным процессам); • PTHREAD_PROCESS_PRIVATE (блокировка чтения-записи разделяется между потоками одного процесса) Использование блокировок чтения-записи для реализации стратегии доступа Блокировки чтения-записи можно использовать для реализации стратегии доступа CREW (параллельное чтение и исключающая запись). Согласно этой стратегии возможность параллельно считывать данные может быть предоставлена сразу нескольким задачам, но только одна задача получит право доступа для записи. При выполнении монопольной записи в этом случае не будет дано разрешение на параллельное чтение данных. Использование блокировок чтения-записи для защиты критических разделов продемонстрировано в листинге 5.3. // Листинг 5.3. Пример использования потоками блокировок // чтения-записи //... pthread_t ThreadA, ThreadB, ThreadC, ThreadD ; pthread_rwlock_t RWLock; void *producerl(void *X) { pthread_rwlock_wrlock(&RWLock) ; // Критический раэдел. pthread_rwlock_unlock(&RWLock) ; return(0); } void *producer2 (void *X) { pthread_rwlock_wrlock(&RWLock) ; // Критический раздел. pthread_rwlock_unlock(&RWLock) ; } void *consumerl(void *X) { pthread_rwlock_rdlock(&RWLock); // Критический раздел. pthread_rwlock_unlock(&RWLock); return(0); } void *consumer2(void *X) { pthread_rwlock_rdlock(&RWLock); // Критический раздел. pthread_rwlock__unlock(&RWLock); return(0); } int main(void) { pthread_rwlock_init(&RWLock,NULL); // Устанавливаем атрибуты мьютекса. pthread_create(&ThreadA, NULL, producerl, NULL) pthread_create(&ThreadB, NULL, consumerl, NULL) pthread_create(&ThreadC,NULL,producer2,NULL) pthread_create(&ThreadD,NULL, consumer2,NULL) //.. . return(0); } В листинге 5.3 создаются четыре потока. Два потока, ThreadA и ThreadC, выполняют роль изготовителей, а остальные два (ThreadB и ThreadD) — потребителей. Все потоки имеют критический раздел, который защищается объектом блокировки чтения-записи RWLock. Потоки ThreadB и ThreadD могут входить в свои критические разделы параллельно или последовательно, но это исключено, если поток ThreadA или ThreadC пребывает в своем критическом разделе. Потоки ThreadA и ThreadC не могут входить в свои критические разделы параллельно. Частичная таблица решении для листинга 5.3 показана в табл. 5.6. Таблица 5.6. Час т ич н ая таблица решений для листинга 5.3
Условные переменные Условная переменная представляет собой семафор, используемый для сигнализации о событии, которое произошло. Сигнала о том, что произошло некоторое событие, может ожидать один или несколько процессов (или потоков) от других процессов или потоков. Следует понимать различие между условными переменными и рассмотренными выше мьютексными семафорами. Назначение мьютексного семафора и блокировок чтения-записи — синхронизировать доступ к данным, в то время как условные переменные обычно используются для синхронизации последовательности операций. По этому поводу в своей книге UNIX Network Programming прекрасно высказался Ричард Стивенс (W. Richard Stevens): « Мьютексы нужно использовать для блокирования, а не для ожидания ». В листинге 4.6 поток-«потребитель» содержал цикл: 15 while(TextFiles.empty()) 16 {} Поток-«потребитель» выполнял итерации цикла до тех пор, пока в очереди TextFiles были элементы. Этот цикл можно заменить условной пере м енной. Поток-«изготовитель» сигналом уведомляет потребителя о том, что в очередь помещены элементы. Поток-«потребитель» может ожидать до тех пор, пока не получит сигнал, а затем перейдет к обработке очереди. Условная переменная имеет тип pthread_cond_t. Ниже перечислены типы операций, которые может она выполнять: • инициализация; • разрушение; • ожидание; • ожидание с ограничением по времени; • адресная сигнализация; • всеобщая сигнализация; Операции инициализации и разрушения выполняются условными переменными подобно аналогичным операциям других мьютексов. Функции класса pthread_cond_t, которые реализуют эти операции, перечислены в табл. 5.7.
Таблица 5.7. Функции класса pthread_cond_t, которые реализуют операции условных переменных Условные переменные используются совместно с мьютексами. При попытке заблокировать мьютекс поток или процесс будет заблокирован до тех пор, пока мьютекс не освободится. После разблокирования поток или процесс получит мьютекс и продолжит свою работу. При использовании условной переменной ее необходимо связать с мьютексом. //. . . pthread_mutex_lock(&Mutex) ; pthread_cond_wait(&EventMutex, &Mutex); //. . . pthread_mutex_unlock(&Mutex) ; Итак, некоторая задача делает попытку заблокировать мьютекс. Если мьютекс уже заблокирован, то эта задача блокируется. После разблокирования задача освободит мьютекс Mutex и при этом будет ожидать сигнала для условной переменной EventMutex . Если мьютекс не заблокирован, задача будет ожидать сигнала неограниченно долго. При ожидании с ограничением по времени задача будет ожидать сигнала в течение заданного интервала времени. Если это время истечет до получения задачей сигнала, функция возвратит код ошибки. Затем задача вновь затребует мьютекс. Выполняя адресную сигнализацию, задача уведомляет другой поток или процесс о том, что произошло некоторое событие. Если задача ожидает сигнала для заданной условной переменной, эта задача будет разблокирована и получит мьютекс. Если сразу несколько задач ожидают сигнала для заданной условной переменной, то разблокирована будет только одна из них. Остальные задачи будут ожидать в очереди, и их разблокирование будет происходить в соответствии с используемой стратегией планирования. При выполнении операции всеобщей сигнализации уведомление получат все задачи, ожидающие сигнала для заданной условной переменной. При разблокировании нескольких задач они будут состязаться за право владения мьютексом в соответствии с используемой стратегией планирования. В отличие от операции ожидания, задача, выполняющая операцию сигнализации, не предъявляет прав на владение мьютексом, хотя это и следовало бы сделать. Условная переменная также имеет атрибутный объект, функции которого перечислены в табл. 5.8. Таблица 5.8. Функции доступа к атрибутному объекту для условной переменной типа pthread_cond_t • int pthread_condattr_init ( pthread_condattr_t * attr) Инициализирует атрибутный объект условной переменной, заданный параметром attr, значениями, действующими по умолчанию для всех атрибутов, определенных реализацией; • int pthread_condattr_destroy ( pthread_condattr_t * attr) ; Разрушает атрибутный объект условной переменной, заданный параметром attr. Этот объект можно инициализировать повторно, вы-звав функцию pthread_condattr_init () • int pthread_condattr_setpshared ( pthread_condattr_t * attr,int pshared); • int pthread_condattr_getpshared ( const pthread_condattr_t * restrict attr, int *restrict pshared); Устанавливает или возвращает атрибут process-shared атрибутного объекта условной переменной, заданного параметром attr. Параметр pshared может содержать следующие значения: • PTHREAD_PROCESS_SHARED (разрешает блокировку чтения-записи, разделяемую любыми потоками, которые имеют доступ к памяти, выделенной для этой условной переменной, даже если потоки принадлежат различным процессам); • PTHREAD_PROCESS_PRIVATE (Условная Переменная разделяется между потоками одного процесса) • int pthread_condattr_setclock ( pthread_condattr_t * attr, clockid_t clock_id); • int pthread_condattr_getclock ( const pthread_condattr_t * restrict attr, clockid_t * restrict clock_id); Устанавливает или возвращает атрибут clock атрибутного объекта условной переменной, заданного параметром attr . Атрибут clock представляет собой идентификатор часов, используемых для измерения лимита времени в функции pthread_cond_timedwait (). По умолчанию для атрибута clock используется идентификатор системных часов Использование условных переменных для управления отношениями синхронизации Условную переменную можно использовать для реализации отношений синхронизации, о которых упоминалось выше: старт-старт (CC), финиш-старт (ФС), старт-финиш (СФ) и финиш-финиш (ФФ). Эти отношения могут существовать между потоками одного или различных процессов. В листингах 5.4 и 5.5 представлены примеры реализации ФС- и ФФ-отношений синхронизации. В каждом примере определено два мьютекса. Один мьютекс используется для синхронизации доступа к общим данным, а другой — для синхронизации выполнения кода. // Листинг 5.4. ФС-отношения синхронизации между // двумя потоками //. . . float Number; pthread_t ThreadA,ThreadB; pthread_mutex_t Mutex, EventMutex; pthread_cond_t Event; void * worker1 (void *X) { for(int Count = l;Count < 100;Count++){ pthread_mutex_lock(&Mutex); Number++; pthread_mutex_unlock(&Mutex); cout << «worker1: число равно» << Number << endl; if(Number == 50){ pthread_cond_signal(&Event); } } cout << «Выполнение функиии worker1 завершено.» << endl; return(0); } void * worker2 (void *X) { pthread_mutex_lock(&EventMutex); pthread_cond_wait(&Event,&EventMutex); pthread_mutex_unlock(&EventMutex); for(int Count = 1;Count < 50;Count++){ pthread_mutex_lock(&Mutex); Number = Number + 20; pthread_mutex_unlock(&Mutex); cout << «worker2: число равно» << Number << endl; } cout « «Выполнение функции worker2 завершено.» « endl; return(0); }; int main (int argc, char *argv[]) { pthread_mutex_init(&Mutex,NULL); pthread_mutex_init(&EventMutex,NULL); pthread_cond_init(&Event, NULL); pthread_create(&ThreadA, NULL, workerl, NULL); pthread_create(&ThreadB, NULL, worker2 , NULL); //. . . return (0); } В листинге 5.4 показан пример реализации ФС-отношений синхронизации. Поток ThreadA не может завершиться до тех пор, пока не стартует поток ThreadB. Если значение переменной Number станет равным 50, поток ThreadA сигнализирует о этом потоку ThreadB. Теперь он может продолжать выполнение до самого конца Поток ThreadB не может начать выполнение до тех пор, пока не получит сигнал от потока ThreadA. Поток ThreadB использует объект EventMutex вместе с условной переменной Event. Объект Mutex используется для синхронизации доступа для записи значения разделяемой переменной Number. Для синхронизации различных событий и доступа к критическим разделам задача может использовать несколько мьютексов. Пример реализации ФФ-отношений синхронизации показан в листинге 5.5. // Листинг 5.5. ФФ-отношения синхронизации между // двумя потоками //... float Number; pthread_t ThreadA, ThreadB ; pthread_mutex_t Mutex, EventMutex; pthread_cond_t Event; void *workerl(void *X) { for(int Count = l;Count < 10;Count++){ pthread_mu tex_l ock (&Mutex); Number++; pthread_mutex_unlосk(&Mutex); cout « «workerl: число равно " << Number « endl; } pthread_mutex_lock(&EventMutex) ,- cout « «Функция workerl в состоянии ожидания. " « endl; pthread_cond_wait (&Event, &EventMutex) ; pthread_mutex_unlock(&EventMutex); return(0); } void *worker2 (void *X) { for(int Count = l;Count < 100;Count++){ pthread_mutex_lock(&Mutex) ; Number = Number * 2 ; pthread_mutex_unlock(&Mutex) ; cout « «worker2: число равно " « Number « endl; } pthread_cond_signal (&Event) ; cout « «Функция worker2 послала сигнал " « endl; return(0); } int main(int argc, char *argv[]) { pthread_mutex_init (&Mutex,NULL) ; pthread_mutex_init (&EventMutex,NULL) ; pthread_cond_init (&Event, NULL) ; pthread_create(&ThreadA, NULL,workerl, NULL); pthread_create (&ThreadB, NULL, worker2, NULL) ; //.. . return (0); } В листинге 5.5 поток ThreadA не может завершиться до тех пор, пока не завершится поток ThreadB. Поток ThreadA должен выполнить цикл 10 раз, а ThreadB — 100. Поток ThreadA завершит выполнение своих итераций раньше ThreadB, но будет ожидать до тех пор, пока поток ThreadB не просигналит о своем завершении. CC- и СФ-отношения синхронизации невозможно реализовать подобным образом. Эти методы используются для синхронизации пор я дка выполнени я процессов. Объектно-ориентированный подход к синхронизации Одно из преимуществ объектно-ориентированного программирования состоит в защите, которую обеспечивает инкапсуляция компонентов данных объекта. Инкапсуляция может обеспечить для пользователя объектов «стратегии доступа к объектам и принципы их применения» [ 24 ]. В примерах, представленных в этой главе, за применяемые стратегии доступа вся ответственность возлагалась на пользователя данных. С помощью объектов и инкапсуляции ответственность можно переложить с пользователя данных на сами данные. При таком подходе создаются данные, которые, в отличие от функций, являются безопасными для потоков. Для реализации такого подхода данные многопоточного приложения (по возможности) необходимо инкапсулировать с помощью С++-конструкций class или struct. Затем инкапсулируйте такие механизмы синхронизации, как семафоры, блокировки для обеспечения чтения-записи и мьютексы событий. Если данные или механизмы синхронизации представляют собой объекты, создайте для них интерфейсный класс. Наконец, объедините объект данных с объектами синхронизации посредством наследования или композиции, чтобы создать объекты данных, которые будут безопасны для потоков. Этот подход подробно рассматривается в главе 11. Резюме Для координации порядка выполнения процессов и потоков (синхронизация задач), а также доступа к разделяемым данным (синхронизация данных) можно использовать различные механизмы синхронизации. Су щ ествует четыре основных вида отношений синхронизации задач. Отношение вида «старт-старт» (CC) означает, что задача А не может начаться до тех пор, пока не начнется задача В. Отношение вида «финиш-старт» (ФС) означает, что задача А не может завершиться до тех пор, пока не начнется задача В. Отношение вида «старт-финиш» (СФ) означает, что задача А не может начаться до тех пор, пока не завершится задача В. Отношение вида «финиш-финиш» (ФФ) означает, что задачаА не может завершиться до тех пор, пока не завершится задача В. Для реализации этих отношений синхронизации задач можно использовать условную переменную pthread_cond_t, которая определена стандартом POSIX. Для описания синхронизации данных используются некоторые типы алгоритмов модели PRAM. Стратегию доступа EREW (исключающее чтение и исключающая запись) можно реализовать с помощью мьютексного семафора. Мьютексный семафор защищает критический раздел, обеспечивал последовательный вход в него. Эта стратегия разрешает либо доступ для чтения, либо доступ для записи. Стандарт POSIX определяет мьютексный семафор типа pthread_mutex_t , который можно использовать для реализации стратегии доступа EREW. Чтобы реализовать стратегию доступа CREW (параллельное чтение и исключающая запись), можно использовать блокировки чтения-записи. Стратегия доступа CREW описывает возможность удовлетворения множества запросов на чтение, но при монопольной записи данных. Стандарт POSIX определяет объект блокировки для обеспечения чтения-записи типа pthread_rwlock_t , а объектно-ориентированный подход к синхронизации данных позволяет встроить механизм синхронизации в объект данных. Примечания:1 POSIX— Portable Operating System Interface for computer environments— интерфейс переносимой операционной системы (набор стандартов IEEE, описывающих интерфейсы ОС для UNIX). 11 Не макроса, а инициализации предопределенной константой, вообще-то |
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||