Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Хьюз Камерон. Параллельное и распределенное программирование на С++ - royallib.ru.doc
Скачиваний:
117
Добавлен:
11.03.2016
Размер:
1.97 Mб
Скачать

Подробнее об объектно-ориентированном взаимном исключении и интерфейсных классах

Чтобы справиться со сложностью написания и поддержки программ с параллелизмом, попробуем упростить API-интерфейс с соответствующими библиотеками. В некоторых системах, возможно, имеет смысл создать библиотеки Pthreads, MPI, атакже стандартные функции использования семафоров и разделяемой памяти как часть единого решения. Все эти библиотеки и функции имеют собственные протоколы и синтаксис. Но у них есть много общего. Поэтому мы можем использовать интерфейсные классы, наследование и полиморфизм для создания упрощенного и непротиворечивого интерфейса, с которым непосредственно будет работать программист. Мы можем также скрыть от наших приложений детали реализации конкретной библиотеки. Если приложение опирается только на методы, используемые в наших интерфейсных классах, то оно будет защищено от изменений, вносимых в реализацию функций, обновлений библиотек и прочих «подводных» реструктуризации. В конце концов, работа над интерфейсом (интерфейсными классами) с компонентами параллелизма и библиотеками функций позволит существенно понизить уровень сложности параллельного программирования. Итак, рассмотрим подробнее, какие методы разработки интерфейсных классов можно реализовать для поддержки параллелизма.

«Полуширокие» интерфейсы

Базовый POSIX-семафор используется для синхронизации доступа к критическому разделу нескольких процессов, а базовый POSIX -поток— для синхронизации доступа к критическому разделу нескольких потоков. В обоих случалх используются переменные синхронизации и ряд функций, работающих с этими переменными. Библиотеки MPI и PVM содержат примитивы передачи сообщений и обладают средствами порождения задач. Но интерфейсы этих библиотек различны. Нетрудно предположить, что работа прикладного программиста была бы эффективней, если бы он сосредоточил свое внимание на логике и структуре программы. Однако там, где семантика программы теряет свою ясность из-за необходимости использовать библиотеки, в которых попадаются аналогичные функции, а сами библиотеки отличаются синтаксисом и протоколами, у программиста возникают немалые трудности. Отсюда вытекает потребность универсализации интерфейса, который бы подходил для работы с разными библиотеками.

Существует по крайней мере два подхода к разработке общего интерфейса для семейства, или коллекции классов. Объектно-ориентированный подход начинается с общего и переходит к частностям посредством наследования. Другими словами, возьмем минимальный набор характеристик и атрибутов, которыми должен обладать каждый член рассматриваемого сехмейства классов, а затем посредством наследования будем конкретизировать характеристики для каждого класса. При таком подходе по мере «спуска» по иерархии классов интерфейс становится все более «узким». Второй подход часто используется в коллекциях шаблонов. Шаблонные методы начинаются c конкретного и переходят к более общему посредством «широких» интерфейсов. «Широкий» интерфейс включает обобщение всех характеристик и атрибутов (см. книгу Страуструпа « Язык программирования С++» , 1997). Если бы нам пришлось применить к библиотекам средств параллелизма «узкий» и «широкий» интерфейсы, то согласно метолу «узкого интерфейса» мы бы взяли от каждой библиотеки общие, или пересекающиеся, части (т.е. пересечение), обобщили их и поместили в базовый класс. И, наоборот, реализуя метод «широкого интерфейса», нужно было бы поместить в базовый класс все функциональные части каждой библиотеки (т.е. объединение), предварительно обобщив их. В результате пересечения мы получили бы меньший по объему да и менее полезный класс. А результат объединения, скорей всего, поразил бы каждого своей громоздкостью. Решение, которое интересует нас в данном случае, находится где-то посередине, т.е. нам нужны «полуширокие» интерфейсы. Начнем же мы с метода «узкого» интерфейса и обобщим его настолько, насколько это можно сделать в пределах иерархии одного класса. Затем используем этот «узкий» интерфейс в качестве основы для коллекции классов, которые связаны не наследованием, а функциями. «Узкий» интерфейс должен действовать в качестве стратегии сдерживания «ширины», до которой может разбухнуть «полуширокий» интерфейс. Другими словами, нам не нужно объединять буквально все характеристики и атрибуты; мы хотим получить объединение только тех частей, которые логически связаны с нашим «узким» интерфейсом. Проиллюстрируем эту мысль иа примере простого проекта интерфейсных классов для POSIX-семафора, Pthread-мьютекса и Pthread-переменной блокировки.

Безотносительно к реализации деталей, операции блокировки, разблокировки и «пробной» блокировки являются характеристиками переменных синхронизации. Поэтому мы создадим базовый класс, который будет служить «трафаретом» для целого семейства классов. Объявление класса synchronization_variable представленовлистинге 11.7.

// Листинг 11.7. Объявление класса synchronization_variable

class synchronization_variable{

protected:

runtime_error Exception;

//.. .

public:

int virtual lock(void) = 0;

int virtual unlock(void) = 0;

int virtual trylock(void) = 0;

//.. .

};

Обратите внимание на то, что методы синхронизации класса synchronization_variable объявлены виртуальными и инициализированы значением 0. Это означает, что они являются чисто виртуальными методами, что делает класс synchronization_variable абстрактным. Из класса, который содержит одну или несколько чисто виртуальных функций, объект прямым путем создать нельзя. Чтобы использовать этот класс, необходимо вывести из него новый класс и определить в нем все чисто виртуальные функции. Абстрактный класс — это своего рода трафарет с указанием того, какие функции должны быть определены в производном классе. Он предлагает интерфейсный проект для производных классов. Он отнюдь не диктует, как нужно реализовать методы, он лишь отмечает, какие методы должны быть представлены в выведенном классе, причем они не могут оставаться в нем чисто виргуальными. С помощью имен этих методов мы можем подсказать предполагаемое их поведение. Таким образом, проектный интерфейсный класс предлагает проект без реализации. Класс этого типа используется в качестве фундамента для будущих классов. Проектный класс гарантирует, что интерфейс будет иметь определенный вид [9]. Класс synchronization_variable обеспечивает интерфейсный трафарет для ceмейства переменных синхронизации. Для обеспечения различных вариантов реализации интерфейса мы используем наследование. Pthread-мьютекс — прекрасный кандидат для интерфейсного класса, поэтому мы определяем класс mutex как производный от класса synchronization_variable.

// Листинг 11.8. Объявление класса мьютекс, который

// наследует класс synchronization_variable

class mutex : public synchronization_variable {

protected:

pthread_mutex_t *Mutex;

pthread_mutexattr_t *MutexAttr;

//.. .

public:

int lock(void) ;

int unlock(void);

int trylock(void);

//. . .

};

Класс mutex должен обеспечить реализации для всех чисто виртуальных функций. Если эти функции определены, значит, политика, предложеннал абстрактным классом, выдержана. Класс mutex теперь не является абстрактным, поэтому из него и из его потомков можно создавать объекты. Каждый из методов класса mutex заключает в оболочку соответствующую Pthread-функцию. Например, код

int mutex::trylock(void) {

//.. .

return(pthread_mutex_trylock(Mutex); //. . .

}

обеспечивает интерфейс для функции pthread_mutex_trylock(). Интерфейсные варианты функций lock(), unlock() и trylock() упрощают вызовы функций библиотеки Pthread. Наша цель — использовать инкапсуляцию и наследование для определения всего семейства мьютексных классов. Процесс наследования — это процесс специализации. Производный класс включает дополнительные атрибуты или характеристики, которые отличают его от предков. Каждый атрибут или характеристика, добавленная в производный класс, конкретизирует его. Теперь мы, используя наследование, можем спроектировать специализацию класса mutex путем введения понятия мьютексного класса, способного обеспечить чтение и запись. Наш обобщенный класс mutex предназначен для защиты доступа к критическому разделу. Если один поток заблокировал мьютекс, он получает доступ к критическому разделу, защищаемому этим мьютексом. Иногда такая мера предосторожности оказывается излишне суровой. Возможны ситуации, когда вполне можно разрешить доступ нескольких потоков к одним и тем же данным, если ни один из этих потоков не модифицирует данные. Другими словами, в некоторых случаях мы можем ослабить блокировку критического раздела и «намертво» запирать его только для действий, которые стремятся модифицировать данные, разрешал при этом доступ для действий, которые предполагают лишь считывание или копирование данных. Такой вид блокировки называется блокировкой считывания (read lock). Блокировка считывания позволяет параллельный доступ к критическому разделу для чтения данных. Критический раздел может быть уже заблокированным одним потоком, но другой поток также может получить блокировку, если у него нет намерения изменять данные. Критический раздел может быть заблокирован для записи одним потоком, а другой поток может запросить блокировку для чтения этого критического раздела.

Архитектура «классной доски» служит прекрасным примером структуры, которая может использовать преимущества «мьютексов считывания» и мьютексов более общего назначения. Под «классной доской» понимается область памяти, разделяемал параллельно выполняемыми задачами. «Классная доска» используется для хранения решений некоторой проблемы, которую совместными усилиями решает целая группа задач. По мере приближения задач к решению проблемы каждая из них записывает результаты на «классную доску» и просматривает ее содержимое с целью поиска результатов, сгенерированных другими задачами, которые могут оказаться полезными для нее. Структура «классной доски» является критическим разделом. В действительности мы хотим, чтобы одновременно только одна задача могла обновлять содержимое «классной доски». Однако ее одновременное считывание мы можем позволить любому количеству задач. Кроме того, если несколько задач уже считывает содержимое «классной доски», нам нужно, чтобы оно не начало обновляться до тех пор, пока все эти задачи не завершат чтение. «Мьютекс считывания» как раз подходит для такой ситуации, поскольку он может управлять доступом к «классной доске», разрешал его только считывающим задачам и запрещал его для записывающих задач. Но если решение проблемы будет найдено, содержимое «классной доски» необходимо обновить. В процессе обновления нам нужно, чтобы ни одна считывающал задача не получила доступ к критическому разделу. Мы хотим заблокировать доступ для чтения до тех пор, пока не завершит обновление записывающал задача. Следовательно, нам нужно создать «мьютекс записи». В любой момент времени удерживать этот «мьютекс записи» может только одна задача. Поэтому мы делаем различие между мьютексом, который блокируется для считывания, но не для записи, и мьютексом, который блокируется для записи, но не для считывания. С использованием мьютекса считывания у нас может быть несколько параллельных считывающих задач, а с использованием мьютекса записи — только одна записывающал задача. Описаннал схема является частью модели CREW (Concurrent Read Exclusive Write — параллельное чтение, монопольнал запись) параллельного программирования.

Для разработки спецификации нашего мьютексного класса нам нужно наделить его способностью выполнять блокировки считывания и блокировки записи. В библиотеке Pthreads предусмотрены мьютексные переменные блокировки чтения-записи и атрибутов:

pthread_rwlock_t и pthread_rwlockattr_t

Эти переменные используются совместно с 11ю pthread_rwlock()-функциями. Мы используем наш интерфейсный класс rw_mutex для инкапсуляции переменных pthread_rwlock_t и pthread_rwlockattr_t, а также для заключения в оболочку Pthread-функций мьютексной организации чтения-записи.

Синопсис

#include <ptrhead.h>

int pthread_rwlock_init(pthread_rwlock_t *,const pthread_rwlockattr_t *);

int pthread_rwlock_destroy(pthread_rwlock_t *) ;

int pthread_rwlock_rdlock(pthread_rwlock_t *);

int pthread_rwlock_tryrdlock(pthread_rwlock_t *);

int pthread_rwlock_wrlock(pthread_rwlock_t *);

int pthread_rwlock_trywrlock(pthread_rwlock_t *);

int pthread_rwlock_unlock(pthread_rwlock_t *);

int pthread_rwlockattr_init(pthread_rwlockattr_t *);

int pthread_rwlockattr_destroy(pthread_rwlockattr_t *);

int pthread_rwlockattr_getpshared(const pthread_rwlockattr_t *,int *);

int pthread_rwlockattr_setpshared(pthread_rwlockattr_t *, int) ;

// Листинг 11.9. Объявление класса rw_mutex, который

// содержит объекты типа pthread_rwlock_ t

// и pthread_rwlockattr_t

class rw_mutex : public mutex{

protected:

struct pthread_rwlock_t *RwLock;

struct pthread_rwlockattr_t *RwLockAttr;

public:

//.. .

int read_lock(void);

int write_lock(void);

int try_readlock(void);

int try_writelock(void);

//.. .

};

Класс rw_mutex наслелует класс mutex. На рис. 11.3 показаны отношения между классами rw_mutex, mutex, synchronization_variable и runtime_error.

Пока мы создаем «узкий» интерфейс. На данном этапе мы заинтересованы в обеспечении минимального набора атрибутов и характеристик, необходимых для обобщения нашего класса mutex с использованием мьютексных типов и функций из библиотеки Pthread. Но после создания «узкого» интерфейса для класса mutex мы воспользуемся им как основой для создания «полуширокого» интерфейса. «Узкий» интерфейс обычно применяется в отношении классов, которые связаны наследованием. «Широкие» интерфейсы, как правило, применяют к классам, которые связаны функциями, а не наследованием. Нам нужен интерфейсный класс для упрощения работы с классами или функциями, которые принадлежат различным библиотекам, но выполняют подобные действия. Интерфейсный класс должен обеспечить программиста удобными рабочими инструментами. Для этого мы берем все библиотеки или классы с подобными функциями, отбираем все общие функции и переменные и после некоторого обобщения помещаем их в большой класс, который содержит все требуемые функции и атрибуты. Так определяется класс с «широким» интерфейсом. Но если включить в него (например, в класс rw_mutex) только интересующие нас функции и данные, мы получим «полуширокий» интерфейс. Его преимущества перед «широким» интерфейсом заключаются в том, что он позволяет нам получать доступ к объектам, которые связаны лишь функционально, и ограничивает множество методов, которыми может пользоваться программист, теми, которые содержатся в интерфейсном классе с узким «силуэтом». Это может быть очень важно при интеграции таких больших библиотек функций, как MPI и PVM с POSIX-возможностями параллелизма. Объединение MPI-, PVM- и POSIX-средств дает сотни функций с аналогичными целями. Затратив время на упрощение этой функциональности в интерфейсных классах, вы позволите программисту понизить уровень сложности, связанный с параллельным и распределенным программированием. Кроме того, эти интерфейсные классы становятся компонентами, которые можно многократно использовать в различных приложениях.

Чтобы понять, как подойти к созданию «полуширокого» интерфейса, построим интерфейсный класс для POSIX-семафора. И хотя семафор не является частью библиотеки Pthread, он находит аналогичные применения в многопоточной среде. Его можно использовать в среде, которая включает параллельно выполняемые процессы и потоки. Поэтому в некоторых случалх требуется объект синхронизации более общего характера, чем наш класс mutex.

Определение класса semaphore показано в листинге 11.10.

// Листинг 11.10. Объявление класса semaphore

class semaphore : public synchronization_variable( protected:

sem_t * Semaphore; public://.. .

int lock(void);

int unlock(void);

int trylock(void);

//. . .

};

Синопсис

<semaphore.h>

int sem_init(sem_t *, int, unsigned int) ;

int sem_destroy(sem_t *);

sem_t *sem_open(const char *, int, ...);

int sem_close(sem_t *);

int sem_unlink(const char *);

int sem_wait(sem_t *);

int sem_trywait(sem_t *);

int sem_post(sem_t *);

int sem_getvalue(sem__t *, int *);

Обратите внимание на то, что класс semaphore имеет такой же интерфейс, как и наш класс mutex. Чем же они различаются? Хотя интерфейсы классов mutex и semaphore одинаковы, реализация функций lock (), unlock (), trylock () и тому подобных представляет собой вызовы семафорных функций библиотеки POSIX .

// Листинг 11.11. Определение методов lock(), unlock() и

// trylock() для класса semaphore

int semaphore::lock(void) (

//.. .

return(sem_wait(Semaphore));

}

int semaphore::unlock(void) {

//. . .

return(sem_post(Semaphore));

}

Итак, теперь функции lock (), unlock (), trylock () и тому подобные заключают в оболочку семафорные функции библиотеки POSIX, а не функции библиотеки Pthread. Важно отметить, что семафор и мьютекс — не одно и то же. Но их можно использовать в аналогичных ситуациях. Зачастую с точки зрения инструкций, которые реализуют параллелизм, механизмы функций lock() и unlock() имеют одно и то же назначение. Некоторые основные различия между мьютексом и семафором указаны в табл. 11.2.

Таблица 11.2. Ос н овные различия между мью т ексами и семафорами

Характеристики мьютексов  

• Мьютексы и переменные условий разделяются между потоками

• Мьютекс деблокируется теми же потоками, которые его заблокировали

• Мьютекс либо блокируется, либо деблокируется

Характеристики семафоров

•  Семафоры обычно разделяются между процессами, но их разделение возможно и между потоками

• - Освобождать семафор должен необязательно тот процесс или поток, который его удерживал

•  Семафоры управляются количеством ссылок. Стандарт POSIX включает именованные семафоры

Несмотря на важность различий в семантике (см. табл. 11.2), часто их оказывается недостаточно для оправдания применения к семафорам и мьютексам совершенно различных интерфейсов. Поэтому мы оставляем «полуширокий» интерфейс для функций lock(), unlock() и trylock() с одним предостережением: программист должен знать различия между мьютексом и семафором. Это можно сравнить с ситуацией, которая возникает с такими «широкими» интерфейса м и таких контейнерных классов, как deque, queue, set, multiset и пр. Эти контейнерные классы связаны общим интерфейсом, но их семантика в определенных областях различна. Используя понятие интерфейсного класса, можно разработать соответствующие компоненты синхронизации для мьютексов, переменных условий, мьютексов чтения-записи и семафоров. Имея такие компоненты, мы можем спроектировать безопасные (с точки зрения параллелизма) контейнерные, доменные и каркасные классы. Мы можем также применять интерфейсные классы для обеспечения единого интерфейса с различными версиями одной и той же библиотеки функций при необходимости использования обеих версий (по разным причинам) в одном и том же приложении. Иногда интерфейсный класс может быть успешно применен для безболезненного перехода от устарелых функций к новым. Если мы хотим оградить программиста от различий, существующих между операционными системами, то наша цель — обеспечить его соответствующим АРI-интерфейсом 18, независимо от того, какая библиотека семафорных функций используется в среде: System V или POSIX.