Санкт-Петербургский государственный университет
Ю. К. Демьянович, О.Н.Иванцова
ТЕХНОЛОГИЯ ПРОГРАММИРОВАНИЯ ДЛЯ РАСПРЕД...
8 downloads
165 Views
535KB Size
Report
This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Report copyright / DMCA form
Санкт-Петербургский государственный университет
Ю. К. Демьянович, О.Н.Иванцова
ТЕХНОЛОГИЯ ПРОГРАММИРОВАНИЯ ДЛЯ РАСПРЕДЕЛЕННЫХ ПАРАЛЛЕЛЬНЫХ СИСТЕМ Курс лекций
Санкт-Петербург 2005
ВВЕДЕНИЕ Высокопроизводительные вычисления необходимы для ряда важнейших задач, в числе которых задачи прогнозирования погоды и климата в целом, и в особенности, их катастрофических изменений (возникновения ураганов, тайфунов, резких изменений температуры и т.п.), задачи геологии и геофизики (предсказания землетрясений, вулканических извержений и т.д.), задачи астрономии и астрофизики (предсказания поведения Солнца, столкновений метеоритов и болидов с Землей, обоснование космогонических гипотез), задачи, связанные с биологией и с чрезвычайно значимой для человека областью — с медициной (последнее достижение — расшифровка генома человека — один из ярчайших примеров применения высокопроизводительных вычислительных систем). Конечно, имеется много других примеров сложных задач, решение которых без высокопроиводительных параллельных вычислительных систем невозможно. Высокопроизводительные вычисления в настоящее время не мыслятся без распараллеливания, ибо наиболее мощные вычислительные системы имеют сотни и тысячи параллельных процессоров (см., например, регулярно обновляемоый в Internet список TOP500, содержащий перечень наиболее мощных компьютеров в мире, чтобы убедиться в том, что все компьютеры в этом списке — параллельные системы). Благодаря распараллеливанию удается достичь производительности в десятки терафлопс (1012 операций в секунду с плавающей точкой), но для наиболее сложных современных задач и этого недостаточно: требуются вычислительные мощности с быстродействием в десятки пентафлопс. Достижение таких скоростей трудно представить без распараллеливания (к моменту написания этого введения уже достигнуто быстродействие 145 терафлопс). Распараллеливание алгоритмов и написание параллельных программ — весьма сложное дело. Причины возникающих трудностей различны: с одной стороны, идеология распараллеливания трудно воспринимается после приобретения навыков последовательного программирования, а с другой стороны, методы распараллеливания задач недостаточно разработаны. Заметим, что прямое численное моделирование соответствующих физических или физико-химических явлений, которые по3
существу представляют собой огромное количество физических процессов, взаимодействующих друг с другом, является наиболее естественным путем моделирования. Без связи с распараллеливанием моделирование процессов в том или ином смысле рассматривалось давно: метод частиц в ячейках, метод конечных элементов, метод сеток и другие методы, фактически, приводят к похожему результату. Конечно, распараллеливание несет в себе большие трудности, но и вселяет значительные надежды. Исследования в этой области, практическое освоение теоретических результатов, создание соответствующего программного обеспечения, отладка программ на параллельных системах и проведение эффективных вычислений представляются чрезвычайно важными. Предлагаемый курс лекций посвящен в основном параллельному программированию на вычислительных системах с распределенной памятью, хотя часть представленной информации можно отнести и к системам с общей памятью. Использование параллельных процессов существенно усложняет программирование: эффективность увеличивается с увеличением независимости процессов, но из-за этого приходится ослаблять контроль за вычислениями. Процессы находятся в постоянной конкурентной борьбе за ресурсы, но поскольку в совокупности они решают одну задачу, то необходимы моменты синхронизации и обмена полученной информацией. Для написании параллельных программ используются специальные средства, которые могут предоставляться в виде специальных библиотек или расширений известных языков (например, библиотеки MPI, Open MP, PVM, язык Linda для Fortran, C, C++); однако, основное внимание следует уделять принципам использования этих средств: именно эти принципы рассматриваются в первую очередь в данном курсе лекций. Поскольку курс расчитан на 36 лекционных часов, многие полезные аспекты здесь не рассмотриваются в надежде, что любознательный читатель сможет почерпнуть недостающие сведения из книг [1-7], содержание которых частично отражено в данном курсе лекций. Курс лекций содержит шесть глав, первая из которых посвящена программированию с использованием передачи сообщений, вторая — мониторам и условным переменным; в третьей главе вводится понятие рандеву и рассматриваются активные мониторы, а четвертая глава посвящена операторам взаимодействия. В пятой 4
главе дается представления о языках Occam, CSP, Linda. Наконец, шестая глава посвящена удаленному вызову процедур и взаимодействию процессов, вопросам неделимости операций, устранению взаимного вмешательства процессов, стратегиям планирования и критическим переменным. Во второй главе рассматривается задача о критической секции, активные блокировки, алгоритм разрыва узла, построению барьеров. В третьей главе излагаются вопросы синхронизации с помощью семафоров. Рассматриваются решения задач “об обедающих философах”, “о читателях и писателях”, “кратчайшее расстояние” и некоторых других. В лекциях широко использованы книги [1–6], а также Интернет и книга [7], из которых почерпнуты примеры и стиль изложения. Авторы курса надеются на быстрое усвоение читателями изложенных первоначальных сведений по методам параллельного программирования, что позволит им легко перейти к чтению более солидных книг, перечисленных в списке рекомендуемой литературы. Данная работа частично поддержана грантами РФФИ 04-0100692, 04-01-00026 и НШ-2268.2003.1.
5
Глава 1. ПРОГРАММИРОВАНИЕ С ИСПОЛЬЗОВАНИЕМ ПЕРЕДАЧИ СООБЩЕНИЙ § 1. О распределенном программировании В настоящее время широкое распространение получили вычислительные системы (ВС) — архитектуры с распределенной памятью. Зачастую это многомашинные комплексы и сети, а иногда — гибридные ВС, такие как сеть из рабочих станций и мультипроцессорных систем. В этом случае процессоры (вычислительные модули, кратко – ВМ) имеют собственную локальную память и общаются между собой с помощью коммуникационной сети, а не через разделяемую (т.е. общую) память; предполагается, что общая память отсутствует. Наиболее употребительны следуюшие виды коммуникационных сред: 1) масштабируемый когерентный интерфейс SCI (Scalable Coherent Interface); основными его элементами служат узлы SCI, представляющие собой стандартизованные фрагменты кэшпамяти, через которые ВМ с помощью адаптеров общаются с сетью; 2) коммуникационная среда MYRINET, в которой передачи осуществляются по схеме адаптер ВМ-источника – коммутаторы Myrinet – адаптер ВМ-приемника; 3) коммуникационная среда Raceway (с использованием кристалла-коммутатора Cypress Raceway Crossbar и адаптеров, работающая по схеме порт коммутатора – шина ВМ), 4) коннектор шин PCI: SRC 3266 DE (Serbing Ring Connection для PCI), позволяющая соединить до 256 шин PCI; 5) Memory Channel фирмы DEC для кластерных систем; 6) коммуникационные среды на базе транспьютероподобных микропроцессоров, характерными чертами которых является наличие специальной сети для инициализации и линий стробирования для передачи синхросигнала; 7) широко используются различные шинные структуры. Вычислительные модули поддерживают процессы, а взаимодействие между процессами поддерживает коммуникационнная среда с использованием каналов, связывающих отдельные процессы. Эти
6
каналы можно рассматривать как абстракцию сетей связи, обеспечивающих физическую связь между вычислительными модулями. Адаптеры, линки и коммутаторы обычно являются важными элементами коммуникационной среды. Для удобства программирования вводятся специальные операции, которые служат для передачи сообщений. Параллельные программы, использующие передачу сообщений называются распределенными программами. Заметим, что распределенные программы могут работать и на ВС с разделяемой (общей) памятью: в этом случае каналы генерируются с помощью разделяемой памяти. В распределенных программах каналы чаще всего являются важными, но не всегда единственными объектами, разделяемыми вычислительными модулями; кроме каналов могут быть общими также некоторые другие устройства (например, принтеры). Пока что будем считать, что каждый процесс выполняется на своем вычислительном модуле (иначе говоря, поток команд, обрабатываемых вычислительным модулем, рассматривается как отдельный процесс). В этом случае переменная оказывается локальной по отношению к своему процессу, который, таким образом, является ее владельцем (caretaker). Эта переменная никогда не должна становится объектом параллельного доступа, а значит она нуждается в использовании механизма взаимного исключения. Общение процессов между собой происходит разными путями; при этом должна производиться синхронизация межпроцессорного взаимодействия. Значение синхронизации здесь не меньше, а возможно и больше, чем при программировании систем с разделяемой памятью: как известно, важно постоянно поддерживать когерентность иерархической памяти системы с распределенной памятью. Взаимодействие процессов на разных стадиях может быть – асинхронным (неблокирующим), – синхронным (блокирующим). Различают: 1) асинхронную передачу сообщений, 2) синхронную передачу сообщений, 3) удаленный вызов процедур (remote procedure call — RPC), 4) рандеву.
7
Эти механизмы эквивалентны в том смысле, что программа, написанная с помощью одного из них, может быть переписана в программу, использующую другой механизм; однако, использование различных механизмов в различных обстоятельствах ведет к различной эффективности программной реализации алгоритма. Замечание. В дальнейшем применяется программная нотация, которая достаточна для описания примеров программ; она представляется достаточно ясной и не требующей формализации. § 2. Асинхронная передача сообщений Для асинхронной передачи сообщений канал является очередью FIFO (First In – First Out); при этом сообщения могут быть отправлены, но еще не получены. Объявление канала имеет вид chan ch(type1 id1 , . . . , typen idn );
(1)
Здесь ch — имя канала, typei — тип поля (обязателен), idi — имя поля (не является обязательным). Пример 1. chan input(char); chan disk_access(int cylinder, int block, int count, char*buffer); Здесь объявлены два канала. Первый канал input используется для передачи односимвольных сообщений. Второй канал disk_access содержит сообщения с четырьмя полями: номер цилиндра, номер блока, число символов, адрес буфера. Можно использовать массив каналов: chan result[n](int); здесь индексы (номера каналов) имеют значения от 0 до n-1. Отправка сообщения по каналу ch (см. (1)) имеет вид send ch(expr1 , . . . , exprn ); здесь выражение expri должно иметь тип typei . Выполнение операции send состоит в 1) в вычислении выражений expri ; 8
(2)
2) в отправке сообщения в конец очереди, связанной с каналом ch. Теоретически очередь не ограничена; поэтому посылка сообщения не вызывает задержку, и следовательно операция send является неблокирующим примитивом. Процесс получающий сообщение из канала выполняет операцию receive: receive ch(var1 , . . . , varn ); (3) Переменные должны иметь тот же тип, что и поля в объявлении канала (1). Процесс, получающий сообщение по команде (3), приостанавливается до тех пор, пока в очереди канала не появится хотя бы одно сообщение. Итак, операция receive может вызвать задержку — поэтому это блокирующий примитив; процесс, принимающий сообщение, не обязан использовать опрос канала для получения сообщения. Доступ к каждому каналу — неделимое действие; каждое переданное сообщение будет принято в том порядке, в каком отправлено (канал является очередью типа FIFO). Пример 2. Формирование строк из символов. chan input(char), output(char[MAXLINE]); process Char_to_Line{ char line[MAXLINE]; int i=0; while(true){ receive input(line[i]); while(line[i]!=CR and i<MAXLINE){ # line[0:i-1] содержит i входных символов i=i+1; receive input(line[i]); } line[i]=EOL; send output(line); i=0; } } П о я с н е н и я : сначала объявляется канал input для отдельных символов и канал output для получения строк. Далее объявляется процесс Char_to_Line, в котором, пока параметр “истина”, 9
получаем значения из канала input в переменную line[0] и до тех пор пока line[i] не совпадет с CR (символ возврата каретки) и i<MAXLINE присваиваем i=i+1 и получаем очередное line[i] из канала input. По окончании цикла присваиваем line[i] значение EOL и посылаем полученную строку в канал output. Каналы используются процессами совместно и потому объявляются глобальными относительно всех процессов (см. предыдущий пример). Любой процесс может отправлять данные в любой канал и принимать из любого канала. Если канал используется многими процессами, то он называется портом (или общим почтовым ящиком). В случае, когда у канала имеется только один получатель и много отправителей, то он называется входным портом (или индивидуальным почтовым ящиком), а если у канала много получателей и один отправитель его можно называть выходным портом. Наконец, если у канала один отправитель и один получатель, то он называется каналом связи. Замечание. При выполнении команды receive часто процесс вынужден ждать; однако, иногда предусматривается выполнение другой работы, для которой не требуется предполагаемое сообщение из затребованного канала. Процесс может определить пуста ли очередь канала с именем ch с помощью вызова empty(ch) который возвращает значение True, если канал пуст, и значение False — в противном случае. Однако, при таком запросе может получится ложная ситуация: при появлении значения True в действительности очередь может оказаться непустой, и, наоборот, при получении значения False к моменту запроса очередь уже может оказаться пустой. Таким образом, вызов empty следует применять с осторожностью. § 3. Сортировка, фильтры Фильтром называется процесс, получающий значения из одного или нескольких входных каналов, и отправляющий значения в выходные каналы (один или более).
10
Для иллюстрации рассмотрим процесс сортировки n чисел в порядке возрастания. Пусть имеется два канала: input — входной канал, output — выходной и sent[i] — i-е значение, отправляемое в output. Процесс сортировки может быть описан следующим образом: SORT: ( ∀ i: 1≤i0, а в противном случае (nw=0) число читателей возрастает на единицу; 2) RW_Contr.release_read — уведомление о конце чтения: процесс-читатель уведомляет о том, что он кончил чтение; при этом
25
число читателей уменьшаем на единицу и если оказалось их число, равным нулю, то разрешается запустить один процесс-писатель; 3) RW_Contr.request_write — запрос записи: процесс-писатель хочет писать: если nr>0 или nw>0, ему приходится ждать в очереди oktowrite, иначе число писателей увеличивается на единицу; 4) RW_Contr.release_write — уведомление о конце записи: имеется возможность просигнализировать одного писателя и о запуске всех читателей. § 9. Распределение ресурсов по приоритетам Здесь воспользуемся приоритетным оператором wait(cv,rank), который располагает приостановленные процессы в порядке возрастания ранга. Схема “самое короткое задание” предполагает выполнения заданий в порядке возрастания требуемого ресурса времени. Для этого требуется две операции: request — запросить, release — освободить. После получения и использования ресурса процесс вызывает процедуру release, в результате чего ресурс передается процессу, который будет использовать его самое короткое время; если процессов больше нет, ресурс освобождается. monitor Shortest_Job { bool free=true; cond turn; # получает сигнал, когда ресурс доступен procedure request(int time) { if (free) free=false; # - процесс получает требуемый ресурс else wait(turn,time); # - процесс ждет в очереди по приоритетам } procedure release() { if (empty(turn)) free=true; else signal(turn); 26
} } Использующий этот монитор процесс должен иметь вид Shortest_Job.request(15); [получение и использование затребованного ресурса]; Shortest_Job.release; § 10. Организация “спящих” процессов Основой такой организации является интервальный таймер, позволяющий процессу перейти в состояние “сна” на некоторое количество единиц времени. Фактически речь идет о приостановке процесса. Необходимый здесь интервальный таймер реализуется в виде монитора, представляющего логические часы. С логическими часами возможны две операции: delay(interval) — операция, приостанавливающая процесс на interval “тиков” таймера; tick — операция, увеличивающая значение логических часов и периодически запускаемая аппаратным таймером с высоким приоритетом (для сохранения точности логических часов). Замечание. Особая точность логических часов не требуется, поскольку происходит проверка числа “тиков” и запуск или приостановка процесса может происходить только после очередного “тика”. Будем считать, что значение логических часов хранится в переменной tod (time of day) так что инвариантом требуемого монитора является V CLOCK: {tod>=0} {tod монотонно увеличивается на единицу} После вызова операции delay(interval) процесс “засыпает” на interval “тиков”. В процедуре (операции) delay вводится локальная переменная wake_time, в которой хранится желаемое время запуска wake_time=tod+interval; “Засыпающий” процесс вычисляет период своего “сна” interval и вызывает процедуру delay, в которой вычисляется упомянутое время запуска. Далее запускается цикл while с условием wake_time>=tod. 27
Процедура tick лишь увеличивает значение переменной tod на единицу; она регулярно запускается высокоприоритетным процессом, связанным аппаратным таймером. Один из вариантов реализации — использование так называемого накрывающего условия. Смысл этого условия в том, что его выполнение влечет запуск всех ожидающих процессов, которые после этого должны проверить условия своего выполнения. При этом в мониторе можно использовать одну условную переменную (назовем ее check) с накрывающим условием “значение tod увеличено”. В этом случае при каждом запуске процедуры tick будут запускаться все ожидающие процессы и будут проверять условия своего запуска; в результате запустятся те процессы, условия запуска которых выполнены. Остальные останутся в состоянии ожидания. Для запуска всех процедур в процедуре tick используется оповещающая операция signal_all. monitor Timer{ int tod=0; cond check; # получает сигнал при увеличении tod procedure delay(int interval){ int wake_time; wake_time=tod+interval; while (wake_time>tod) wait(check); } procedure tick(){ tod=tod+1; signal_all(check); } } Использование “покрывающих условий” подходит, если затраты на ложные сигналы невелики (т. к. при “ложном” сигнале процесс запускается, проверяет условия своего выполнения, а затем снова возвращается в состояние ожидания). Если же периоды “сна” велики, то скорее всего такой подход приводит к излишним затратам ресурсов. Для больших периодов “сна” более эффективно использовать в мониторе приоритетный оператор wait. Это удобно в тех случа28
ях, когда имеется статическая упорядоченность (т. е. упорядоченность, не меняющаяся во времени). В нашей ситуации процессы можно упорядочить в соответствии со временем их запуска (“пробуждения”). После этого можно использовать операцию minrank для того, чтобы определить, настало ли время запуска самого первого процесса, приостановленного с помощью check; если это так, то процесс получает сигнал для запуска. Теперь не нужен цикл while в процедуре delay, но он нужен в процедуре tick при посылке сигнала, ибо этого времени запуска могут ожидать несколько процессов. monitor Timer{ int tod=0; cond check; # получает сигнал, когда minranktod) wait(check, wake_time); } procedure tick(){ tod=tod+1; while(!empty(check) && minrank(check)=cleave bavail>=bbusy>=bdone С другой стороны нужно учесть следующее: 1) посетитель не может садиться в кресло чаще, чем парикмахер освобождается от работы; 2) парикмахер не может начинать стрижку чаще, чем посетитель садится в его кресло. V C2: cinchair0 (очередь, ожидающих окончание стрижки); customer_left получает сигнал при open=0 (очередь, окончивших стрижку). Процессы ждут выполнения условия с помощью операторов wait, заключенных в циклы. В момент истинности условий выполняется операция signal. monitor Barber_Shop{ int barber=0, chair=0, open=0; cond barber_available; # barber>0, то signal cond chair_occupied; # chair>0, то signal cond door_open; # open>0, то signal cond customer_left; # open=0, то signal procedure get_haircut(){ while(barber==0) wait(barber_available); barber=barber-1; chair=chair+1; signal(chair_occupied); while(open==0) wait(door_open); open=open-1; signal(customer_left);
32
} procedure get_next_customer(){ barber=barber+1; signal(barber_available); while(chair==0) wait(chair_occupied); chair=chair-1; } procedure finished_cut(){ open=open+1; signal(door_open); while(open>0) wait(customer_left); } } Особенности монитора таковы. I. В процедуре get_haircut имеется два обращения к процедуре wait: 1) посетителю нужно ждать освобождения парикмахера (канал barber_available); 2) посетителю приходиться ждать в процессе стрижки (канал door_open); II. Посетителю еще нужно ждать 3) в кресле перед началом стрижки (канал chair_occupied); 4) после стрижки до открытия выходной двери (канал customer_left). § 2. Активные мониторы Как видно из предыдущих параграфов, монитор управляет ресурсом, предоставляя набор процедур для доступа к нему. Процедуры выполняются со взаимным исключением и используют переменные для условий синхронизации. В некотором смысле рассмотренные ранее мониторы являются пассивными наборами процедур, а не активными процессами. В условиях распределенной памяти и взаимодействия процессов на основе передачи сообщений желательно программировать мониторы в виде активных процессов. Предположим, что в мониторе имеется лишь одна процедура op. Тогда структура монитора такова 33
monitor Mon{ [объявление постоянных переменных]; [код инициализации]; procedure op ([формальные параметры]){ [тело процедуры]; } } Для моделирования монитора Mon используем серверный процесс Server. Постоянные переменные монитора становятся локальными переменными этого процесса. Сервер сначала инициализирует переменные, затем выполняет один и тот же цикл, обслуживая “вызовы” процедуры op. Имитация вызова состоит в следующем: – сначала клиентский процесс отправляет сообщение в канал запроса; – ответ он получает из канала ответа. Для того, чтобы избежать перехвата ответа другим клиентским процессом, каждому клиенту нужен собственный канал ответа (или специальные примитивы, позволяющие определить отправителя). chan request(int clientID, [типы входных данных]); # - канал запроса chan reply[n]([типы результатов]); # - массив каналов ответов process Server { int clientID; [объявление других постоянных переменных]; [код инициализации]; while (true){ receive request(clientID, [входные значения]); [код из тела операции op]; send reply[clientID]([результаты]); } }
34
process Client[i=0 to n-1]{ send request(i, [аргументы-значения]); # - вызов op receive reply[i]([аргументы для результатов]); # - ожидания ответа } Заметим, что каналы глобальны по отношению к процессам, так что к каналам можно обращаться непосредственно (статическое именование). Динамическое именование также может быть применено: каждый клиент создает собственный канал ответа, передавая его в первом поле запроса. В этом случае у других процессов не будет доступа к ответу. Заметим, что если монитор имеет несколько процедур, то в сообщении запроса должно быть указание, какую операцию (процедуру) вызывает клиент. У различных операций могут быть разные наборы аргументов: это требует программирования записей с вариантами и объединениями. type op_kind=enum(op_1, ..., op_n); # объявление перечисляемого типа type arg_type=union(arg_1, ..., arg_n); # объявление типа объединения #(резервирование одного пространства) type res_type=union(res_1, ..., res_n); # объявление типа объединения #(резервирование одного пространства) chan request(int clientID, op_kind, arg_type); # канал запросов chan reply[n](res_type); # массив каналов ответов process Server { int clientID; op_kind kind; arg_type args; res_type results; [объявление других переменных]; [код инициализации]; while (true){ ## инвариант цикла
35
receive request(clientID, kind, args); if (kind==op_1) {[тело op_1]}; . . . . . . . . . . else if (kind==op_n) {[тело op_n]}; send reply[clientID](results); } } process Client[i=0 to n-1]{ arg_type myargs; res_type myresults; [поместить аргументы-значения в myargs]; send request(i, op_j, myargs); # вызов op_j receive reply[i](myresults); # ждать ответа } Заметим, что при обслуживании клиента рассмотренному монитору не приходится задерживаться, обслуживая запрос, т. к. тело операции содержит лишь последовательные операции. Однако, более общий вариант монитора использует условную синхронизацию и поддерживает несколько операций; при этом учитывается, что запрос может быть обработан с задержкой. Главное отличие реализующего его серверного процесса состоит в том, что сервер не может ждать, если нет доступного ресурса по данному запросу. Поэтому он запоминает запрос и откладывает посылку ответа до момента освобождения ресурса. После отправки запроса клиент ждет получения ресурса (хотя могут быть и другие варианты). После освобождения ресурса клиент посылает сообщение о его освобождении, но не ждет его обработки. Сначала приведем монитор, а потом — реализующий его сервер.
МОНИТОР monitor Resource_Allocator{ int avail=MAXUNITS; set units=[начальные значения]; cond free;
36
# - получает сигнал, когда процессу нужен ресурс procedure acquire(int &id){ if(avail==0) # нет доступа, wait(free); # уход в очередь else # есть доступ avail=avail-1; remove(units, id); # - удовлетворение запроса: использован ресурс } procedure release(int id){ insert(units, id); # окончание использования ресурса: # возврат его на место if(empty(free)) # нет очереди avail=avail+1; else signal(free); # сигнал "очереднику" } } СЕРВЕР РАСПРЕДЕЛЕНИЯ РЕСУРСОВ И КЛИЕНТЫ type op_kind=enum(ACQUIRE, RELEASE); chan request(int clientID, op_kind kind, int unitID); chan reply[n](int unitID); process Allocator { int avail=MAXUNITS; set units=[начальные значения]; # units - многоэлементный ресурс, # управляемый вставкой или удалением queue pending; # в начальном состоянии очередь пуста int clientID, unitID; op_kind kind; [объявление других локальных переменных]; while (true){ receive request(clientID, kind, unitID); if (kind==ACQUIRE) { if (avail>0){ # удовлетворить запрос: avail--; remove(units, unitID);
37
send reply[clientID](unitID); } else # запомнить запрос: insert(pending, clientID); } else { # kind==RELEASE, то освобождение ресурса if empty(pending){ # очередь пуста # возвратить unitID в множество элементов: avail++; insert(units, unitID); } else { # удовлетворить unitID для ожидающего клиента: remove(pending, clientID); send reply[clientID](unitID); } } } } process Client[i=0 to n-1]{ int unitID; send request(i, ACQUIRE, 0); # "вызов" запросов receive reply[i](unitID); # использовать ресурс unitID, затем освободить его send request(i, RELEASE, unitID); . . . } Замечание. Клиенты остаются без изменений. Здесь рассмотрен частный случай имитации монитора с помощью процесса-сервера; в этом мониторе программирование проводится с помощью метода передачи условия. Для моделирования мониторов, содержащих операторы wait или/и операторы signal, приходится сохранять ожидающий запрос и проверять очередь ожидающих запросов. Между программами с мониторами и с передачей сообщений имеется некоторое соответствие.
38
Т а б л и ц а № 2. Мониторы
Программы с передачей сообщений Постоянные переменные Локальные переменные сервера Идентификаторы процедур Канал запроса и виды операций Вызов процедуры send request(); receive reply(); Вход в монитор receive request(); Возврат в процедуру send reply(); Оператор wait Сохранение ожидающего запроса Оператор signal Получение и обработка ожидающего запроса Тела процедур Ветви оператора выбора по видам операций Относительная производительность двух стилей программирования определяется аппаратурой. Программирование с помощью мониторов обычно эффективнее для систем с разделяемой памятью, и потому на таких ВС обычно реализуются мониторы. Для распределенных систем эффективность мониторов часто ниже, ибо более естественно накладывается на архитектуру использование механизма передачи сообщений; однако, иногда используется оба механизма. § 3. Планирующий сервер Здесь рассмотрим решение задачи планирования доступа к диску. Для того, чтобы осуществлять планирование процесс должен просматривать все ожидающие запросы. Процесс получает сообщения, выполняя цикл, который завершается, когда канал request пуст и есть хотя бы один сохраненный запрос. Затем драйвер выбирает наиболее подходящий запрос, обращается к диску и отправляет ответ клиенту, приславшему запрос. Имеются различные стратегии планирования диска. Рассмотрим одну из них. Процесс записывает ожидающие запросы в одну из двух упорядоченных очередей left или right в зависимости от направления,
39
в котором для обработки запроса необходимо перемещать головки диска из их текущего положения. Запросы очереди left упорядочены по уменьшению номера цилиндра, а в очереди right — по возрастанию. Переменная headpos запоминает текущую позицию головок, а nsaved — число сохраненных запросов. Пусть cy1 — затребованный адрес (т.е. позиция, куда требуется переместить головку). Инвариант для внешнего цикла процесса можно записать в следующем виде. SST: {left — Vэто упорядоченная очередь от наибольшего к наименьшему} {все значения в left не больше headpos} V {right — V это упорядоченная очередь от наименьшего V к наибольшему} {все значения в right не меньше headpos} {если (nsaved==0), то и left и right пусты} Примитив empty в условии внутреннего цикла while позволяет определить, есть ли еще сообщение в очереди канала request. Здесь приводится пример метода программирования, называемого опросом. Процесс постоянно опрашивает канал request, чтобы определить, имеются ли ожидающие запросы. Если такие запросы есть, то процесс получает еще один запрос для того, чтобы расширить возможность выбора подходящего. Если ожидающих запросов нет, то процесс приступает к обслуживанию наиболее подходящих из сохраненных. ПЛАНИРУЮЩИЙ ПРОЦЕСС chan request(int clientID, int cy1, [типы других аргументов]); chan reply[n]([типы результатов]); process Disk_Driver { queue left, right; # упорядоченные очереди сохраненных запросов int clientID, cy1, headpos=1, nsaved=0; [переменные для запоминания других аргументов запроса]; while (true){ ## инвариант цикла SST while (!empty(request) or nsaved==0){ # если очередь не пуста или число сохраненных 40
# переменных равно нулю, то ждать первого # запроса или получать еще один запрос receive request(clientID, cy1, . . .); if (cy1largest) largest=new; } # разослать результаты остальным процессам: for [i=1 to n-1] send results[i](smallest, largest); } process P[i=1 to n-1] { int v; # считается, что переменная v инициализирована int smallest, largest; send values(v); receive results[i](smallest, largest); }
Рис. 4. Структура взаимодействия при асимметричном решении 45
Второй способ (симметричное решение). Все процессы выполняют один и тот же алгоритм (в стиле SIMD – Single Instruction Multiple Data). Здесь каждый процесс отправляет свое значение всем остальным, затем все процессы вычисляют минимум и максимум из n значений (см. рис. 5).
Рис. 5. Структура взаимодействия при симметричном решении chan values[n](int); process P[i=0 to n-1] { int v; # считается, что v инициализирована int new, smallest=v, largest=v; # начальное условие # отправить мое значение всем остальным процессам: for [j=0 to n-1 st j!=i] # для каждого j от нуля до n-1, # исключая j=i send values[j](v); # собрать значения, найти и # заполнить минимум и максимум: for [j=1 to n-1] { receive values[j](v); if (new<smallest) smallest=new; if (new>largest) 46
largest=new; } } Третий способ (кольцевое решение). Здесь процессы организованы в логическое кольцо; каждый процесс получает сообщение от своего предшественника и отправляет сообщение преемнику (см. рис. 6).
Рис. 6. Структура взаимодействия при кольцевом решении Каждый процесс проходит две стадии: 1) получает два числа и, подключая свое число, находит минимальное и максимальное и отсылает преемнику; 2) получает значения глобально максимального и глобально минимального и отсылает преемнику. Это решение почти симметрично: немного отличается P[0] — это процесс инициализатор. chan values[n](int smallest, int largest); process P[0] { # процесс-инициализатор int v; # считается, что v инициализирована 47
int smallest=v, largest=v; # начальное состояние # послать v следующему процессу P[1]: send values[1](smallest, largest); # получить глобальные максимальное и # минимальное от P[n-1] и отправить # их процессу P[1]: receive values[0](smallest, largest); send values[1](smallest, largest); } process P[i=1 to n-1] { int v; # считается, что v инициализирована int smallest, largest; # получить текущие минимум и максимум # и обновить их, сравнивая с v: receive values[i](smallest, largest); if (v<smallest) smallest=v; if (v>largest) largest=v; # отправить результат следующему процессу и # ожидать получение глобальных результатов: send values[(i+1) mod n](smallest, largest); } Замечание. Симметричное решение — самое короткое, его легко программировать, но оно требует взаимодействия каждого с каждым. Хотя теоретически все сообщения могут быть посланы одновременно и пересылка может происходить параллельно с наименьшими временными затратами, но на практике пересылка большого количества сообщений требует значительных затрат времени, что может свести на нет ускорение, связанное с распараллеливанием. Заметим, что при симметричном решении устанавливаем n(n-1)/2 связей точка-точка, где n — число ВМ; это число весьма значительно, если число ВМ велико. Кольцевое решение напоминает конвейер: операнды umin и umax получают дополнительную обработку на каждом шаге конвейера. Однако, конвейер плохо загружен (особенно, если n велико); та48
ким образом, в рассматриваемой простой задаче кольцевое решение вряд ли эффективно. Оно может оказаться эффективным 1) при обработке потоков данных с числом элементов данных больше или равным n (например, при подсчете тех же минимумов и максимумов в i-й компоненте вектора v[0:n-1], i=0,1, . . . , n-1, если каждый ВМ имеет свой локальный вектор v); 2) при значительных вычислениях с упомянутыми (даже и скалярными) данными. Наиболее приемлемо централизованное решение в приведенном выше первом способе: здесь данные отправляются центральному ВМ без задержек и без задержек обрабатываются центральным ВМ.
49
Глава 4.
ОПЕРАТОРЫ ВЗАИМОДЕЙСТВИЯ И ЗАЩИТА
§ 1. Синхронная передача сообщений Команда send не приводит к блокировке программы, отправившей сообщение. В этом случае отправленное сообщение либо достигает адресата, либо (если адресат не может его принять) оно остается в канале в очереди у адресата; рано или поздно адресат примет сообщение. При этом отправитель продолжает свою работу. Такой способ обработки команды называется неблокирующим и асинхронным (он не требует синхронизации отправителя и адресата). В противоположность команде send команда synch_send является блокирующей, отправитель приостанавливается до получения подтверждения о приемке от адресата. Если адресат не готов принять послание, то начинается простой отправителя. При этом процесс-отправитель не может послать новое сообщение адресату. Физически сообщение находится в адресном пространстве отправителя до готовности адресата получить его; при такой реализации возникает очередь в канале отправителя из адресов сообщений нуждающихся в отправке. Синхронной передаче свойственны следующие недостатки. 1. Первый недостаток. При взаимодействии двух процессов по крайней мере один из них блокируется, в зависимости от того, кто первым попытается установить связь. Рассмотрим следующую программу “производитель – потребитель”: channel values(int); process Producer { int data[n]; for [i=0 to n-1] { [выполнить некоторые вычисления]; synch_send values(data[i]); } } process Consumer { int results[n]; 50
for [i=0 to n-1] { receive values(results[i]); [выполнить некоторые вычисления]; } } Если вычисления, производимые этим процессам таковы, что иной раз Producer выполняет их быстрее, а иной раз их быстрее выполняет Consumer, то простаивает то Producer, то Consumer, так что общее время простоя процессов складывается из этих слагаемых. При асинхронной работе весьма вероятно, что в канале будет скапливаться достаточное число сообщений и простоев не будет вовсе. 2. Второй недостаток блокировок состоит в том, что использующие синхронизацию процессы более предрасположены к взаимным блокировкам, так что программисту нужно наблюдать за тем, чтобы соблюдалось соответствие между операторами передачи и приема. При программировании синхронизации нужно проявлять известную осторожность; например, рассмотрим программу обмена значениями: channel in1(int), in2(int); process P1 { int value1=1, value2; synch_send in2(value1); receive in1(value2); } process P2 { int value1, value2=2; synch_send in1(value2); receive in2(value1); } Эта программа зависнет, поскольку оба процесса заблокируются в операторах synch_send. В одном из них (но не в обоих) нужно выполнить операцию receive сначала, а synch_send потом. 51
Здесь более уместна асинхронная передача сообщений: зависаний не будет, программа отработает без задержки. При асинхронной передаче легко модифицировать программу при увеличении числа процессов. Замечание. Недостаток асинхронных передач (и в отдельных случаях — весьма существенный) состоит в том, что резко ослабевает контроль за вычислительным процессом: в частности, может быть изменен порядок арифметических действий, что, как известно, может привести к большим вычислительным погрешностям. § 2. Операторы взаимодействия Предположим, что процесс А собирается передать значение процессу В, а процесс В — получить это значение. Это запишем, используя нотацию языка CSP (Communicating Sequential Process). process A {. . . process B {. . .
B!e; . . .} # оператор вывода: выдать A?x; . . .} # оператор ввода: запросить
Язык CSP характеризуется использованием так называемого защищенного взаимодействия, о котором речь пойдет ниже. В приведенном фрагменте использованы операторы: B!e — оператор вывода; лучше всего его читать так: “выдать e процессу B”. A?x — оператор ввода, который читается так: “запросить x у процесса A”. Если типы переменных e и x совпадают, то операторы ввода и вывода называются согласованными. Эти операторы приостанавливают каждый из процессов до тех пор пока другой процесс не подойдет к выполнению соответствующего согласованного оператора, после чего оба оператора выполняются одновременно. Выполнение согласованных операторов можно рассматривать как распределенное присваивание : здесь значение переменной из одного процесса присваивается переменной другого процесса. На момент такого присваивания процессы синхронизируются, а далее выполняются независимо. Общий вид операторов ввода и вывода таков:
52
Destination!port(e_1, . . ., e_n); # - выдать e_1, . . ., e_n процессу Destination # через порт с именем port Source?port(x_1, . . ., x_n); # - запросить x_1, . . ., x_n у процесса Source # через порт с именем port Здесь Destination и Source — процессы (соответственно процесс-приемник и процесс-источник), port — общее для обоих процессов имя порта (это имя можно не использовать, если имеется лишь один вид сообщений, при этом скобки можно не писать), e_1, . . ., e_n и x_1, . . ., x_n — имена переменных соответствующих типов. При использовании массива источников возможно обращение к определенному элементу этого массива в виде Source[i]?port(x_1, . . ., x_n); а также возможен запрос ко всем источникам массива в виде Source[*]?port(x_1, . . ., x_n); Простым примером является следующий процесс-фильтр: process Copy { char c; do true --> West?c; # запросить символ у процесса West East!c; # выдать символ процессу East od } Оператор ввода ждет, пока процесс West будет готов принять символ, а оператор вывода ждет, пока процесс East будет готов выдать символ; оба выполняются одновременно. Язык CSP использует понятие защищенных команд. Рассмотрим процесс отыскания наибольшего делителя двух положительных целых чисел x и y (алгоритм Евклида). 53
process Euc { int id, x, y; # ввести запрос от любого # клиента клиентского массива: do true --> Client[*]?args(id, x, y); do x>y --> x=x-y; [ ] x y=y-x; # или od # выдать результат # клиенту с идентификатором id : Client[id]!result(x); od } Клиент Client[i] взаимодействует с Euc соответствующим образом: . . . Euc!args(i, v1, v2); Euc?result(r); . . .
§ 3. Защищенное взаимодействие Защищенные операторы взаимодействия в языке CSP имеют вид: B; C --> S; Здесь B — логическое выражение, C — оператор взаимодействия, S — список операторов. B и C образуют защиту. Говорят, что защита (B,C) пропускает, если B истинно и не пропускает если B ложно. Если B истинно (защита пропускает), а оператор C пока не выполнен, то говорят, что защита пропускает, но блокирует; в противном случае говорят, что защита пропускает и не блокирует. Защищенные операторы взаимодействия используются в операторах if и do. Рассмотрим оператор if: if B_1; C_1 --> S_1; [ ] B_2; C_2 --> S_2; 54
fi; В этом операторе обе ветви являются защищенными операторами взаимодействия. Здесь сначала вычисляются логические выражения в защитах: 1) если обе защиты не пропускают, то выполнение оператора if заканчивается; 2) если одна из защит пропускает, а вторая — нет, то выбирается та, которая пропускает и происходит ожидание возможности выполнения соответствующего оператора C_i; 3) если обе защиты пропускают, но блокируют, то происходит ожидание, пока одна из них разблокируется; 4) если обе защиты пропускают и не блокируют, то выбирается одна из них (недетерминирование). Аналогично работает оператор do; отличие в том, что цикл повторяется до тех пор, пока все защиты перестанут пропускать. Рассмотрим примеры 1. В первом примере в защите нет логического выражения и потому цикл выполняется бесконечно (при готовности процессов West и East): process Copy { char c; do West?c --> East!c # запросить у процесса West и выдать процессу East od } 2. Во втором примере может буферизоваться один или два символа process Copy { char c1, c2; West?c1 # запросить из West do West?c2 --> East!c1 # - защита блокирована (может быть West не готов # выполнить запрос)
55
# запросить еще символ из West и # выдать предыдущий символ в East c1=c2; # - переприсвоить символы [ ] East!c1 --> West?c1; # - выдать символ в East и # запросить символ из West od } Процесс может заблокироваться и на East (если не готов принять) и на West (если не готов выдать запрос). Здесь обе защиты всегда пропускают и потому цикл никогда не завершается; однако, время от времени он может блокироваться. Если обе защиты не блокируют, то выполняется любая ветвь (недетерминирование). 3. Третий пример. Реализацию кольцевого буфера можно получить, реализуя программу Copy в виде process Copy { char buffer[10]; int front=0, rear=0, count=0; do count count=count+1; rear=(rear+1) mod 10; [ ] count>0; East!buffer[front] --> count=count-1; front=(front+1) mod 10; od } Здесь используется оператор do с двумя ветвями, но теперь в защитах имеются и логические выражения и операторы взаимодействия. Защита в первой ветви пропускает, когда в буфере есть свободное место, и эта защита не блокирует, если процесс West готов вывести запрашиваемый символ (в конец заполняемого буфера), а защита во второй ветви пропускает, если в буфере есть символы и процесс East готов получить символ (из начала массива заполненного буфера). Цикл здесь тоже никогда не завершается, ибо хотя бы одно из логических значений истинно. 56
4. В четвертом примере используется три порта: acquire, reply, release. Здесь рассмотрим распределитель ресурсов, использующий асинхронную передачу сообщений. process Allocator { int avail=MAXUNITS; set units=[начальные значения]; int index, unitid; do avail>0; Client[*]?acquire(index) --> avail--; remove(units, unitid); # удаление units из доступных Client[index]!reply(unitid); # ответ клиенту с указателем id [ ] Client[*]?release(index, unitid) --> avail++; insert(units, unitid); # вставить units на место od } Замечание. В этой программе не нужно сохранять запросы, поскольку получение сообщения acquire откладывается до тех пор, пока не появится доступные элементы (т. е. до тех пор, пока не станет avail>0). 5. Пятый пример. Симметричное решение в задаче об обмене значениями двух локальных переменных. process P1 { int value1=1, value2; if P2!value1 --> P2?value2; # процессу P2 выдать значение value1, # а затем запросить у него value2 [ ] P2?value2 --> P2!value1; # или наоборот fi } process P2 { int value1, value2=2; 57
if P1!value2 --> P1?value1; # выдать процессу P1 значение value2 # (если он готов принять), # у процесса P1 запросить значение value1 [ ] P1?value1 --> P1!value2; # у процесса P1 запросить значение value1 # выдать процессу P1 значение value2 fi } Замечание. Напомним, что в подобных ситуациях альтернатива выбирается недетерминированно. § 4. Программа генерации простых чисел Здесь опять воспользуемся языком CSP. Пусть нам нужно найти простые числа между 2 и n. Сначала задается список всех таких чисел 2 3 4 5 6 . . . n Вычеркнем числа, кратные двум, кроме первого, считая n нечетным числом, получим 2 3 5 7 9 . . . n Затем вычеркнем все числа, кратные трем, кроме первого. Если продолжить этот процесс далее, то останутся лишь простые числа от 2 до n. Приведенный алгоритм называется решето Эратосфена. Будем распараллеливать этот алгоритм, используя конвейер процессов-фильтров. Каждый фильтр получает поток чисел от своего предшественника, отделяет первое число и посылает своему преемнику все числа, некратные первому числу потока. Отделяемые числа являются простыми. process Sieve[1] { int p=2; for [i=3 to n by 2] Sieve[2]!i; # - передать нечетные числа Sieve[2] }
58
process Sieve[i=2 to L] { int p, next; Sieve[i-1]?p; # - запросить первое число у i-1-го, # p является простым do Sieve[i-1]?next --> # - запросить следующее число у i-1-го if (next mod p)!=0 --> # если оно не делится на p, # то передать его дальше: Sieve[i+1]!next; fi od } Замечание 1. Для нормального завершения всех процессов в конце списка можно поместить маркер, после получения которого каждый из рассмотренных процессов нормально завершается. Замечание 2. Когда программа заканчивается, то искомый набор простых чисел находится в переменных p рассматриваемых процессов.
59
Глава 5. О НЕКОТОРЫХ ЯЗЫКАХ ПРОГРАММИРОВАНИЯ § 1. О языке Occam (“Бритва Оккама”) Язык разрабатывался для работы с транспьютерами. Базовыми элементами языка являются декларации и три “процесса”: – присваивание; – ввод; – вывод. Ввод/вывод аналогичны таковым в языке CSP, но каналы глобальны по отношению к процессам и имеют имена. Каждый канал должен иметь одного отправителя и одного получателя. Базовые процессы объединяются в обычные процессы с помощью так называемых конструкторов; существуют – последовательные конструкторы; – параллельный конструктор; – защищенный оператор взаимодействия. Конструкторы PAR — параллельный, SEQ — последовательный. В синтаксисе Occam каждый базовый процесс, конструктор и декларация занимают отдельную строчку; а декларация заканчивается двоеточием, в записи используются отступы. Рекурсия не поддерживается. Пример 1. INT x,y: SEQ x:=x+1 y:=y+1
# последовательное # увеличение значений x и y
Замечание. Для параллельного исполнения вместо SEQ можно поставить PAR. В языке Occam существуют также конструкторы IF, CASE, WHILE, ALT (для защищенного взаимодействия). Кроме того, имеется механизм, называемый репликатором (похож на квантификатор).
60
Параллельные процессы создаются с помощью конструктора PAR; они взаимодействуют через каналы с помощью базовых процессов ввода ? и вывода !. Пример 2. Программа эхо с клавиатуры на экран: WHILE TRUE BYTE ch: SEQ keyboard?ch screen!ch Можно написать программу, где имеется один канал с накоплением и два процесса. Пример 3. CHAN OF BYTE comm: PAR WHILE TRUE # процесс вывода с клавиатуры BYTE ch: SEQ keyboard?ch comm!ch WHILE TRUE # процесс вывода на экран BYTE ch: SEQ comm?ch display!ch Замечание. Использование отступов делает ненужным закрывающие ключевые слова. Конструктор ALT обеспечивает защищенное взаимодействие. Защита состоит из 1) процесса ввода или; 2) логического выражения и процесса ввода или; 3) логического выражения и конструктора SKIP. Пример 4. Процедурный вариант копирования PROC Copy(CHAN OF BYTE West, Ask, East) 61
BYTE c1, c2, dummy: SEQ West?c1 WHILE TRUE ALT West?c2 SEQ East!c1 c1:=c2 Ask?dummy # процессу East нужен байт SEQ East!c1 West?c1
§ 2. О языке CSP Современные версии языка CSP позволяют его использовать в различных аспектах моделирования приложений: – протоколов взаимодействия; – протоколов безопасности; – протоколов отказоустойчивых систем. Все взаимодействия происходят в результате событий. Основными являются операторы: – присоединения (префиксации — последовательного выполнения); – рекурсии (повторения); – защищенного выбора (недетерминированного выбора). Оператор присоединения используется для задания последовательного порядка событий. Пример 1. Если red и green — события, то светофор, который один раз включает green, а потом red, задается так green --> red --> STOP Здесь STOP — простейший процесс в CSP, который не нуждается во взаимодействии. Пример 2. Для описания повторения используется рекурсия; в частности, циклическое включение green и red имеет вид LIGHT=green --> red --> LIGHT 62
Для описания взаимодействия процессов друг с другом используются каналы. Пример 3. COPY1=West?c:char --> East!c --> COPY1 # копирование по символу Пример 4. Программа вычисления НОД: GCD=Input?id, x, y --> GCD(id, x, y) GCD(id, x, y)= if (x=y) then Output!id, x --> GCD else if (x>y) then GCD(id, x-y, y) else GCD(id, x, y-x) Здесь используются два взаимно-рекурсивных процесса. Первый GCD ждет события ввода и вызывает второй процесс GCD(). Второй процесс повторяется до выполнения условия x=y, затем выводит результат и запускает первый процесс для ожидания еще одного события ввода. Пример 5. Следующая программа определяет поведение системы, буферизующей один или два символа. COPY=West?c1:char --> COPY2(c1) COPY2(c1)=West?c2:char --> East!c1 --> COPY2(c2) [ ] East!c1 --> West?c1:char --> COPY2(c2) Второй процесс использует оператор защищенного выбора [ ]: – защита в первой части ждет ввода из канала West; – защита во второй части ждет вывода из канала East. Выбор недетерминирован: возможны оба взаимодействия.
63
§ 3. О языке Linda В языке Linda обобщается идея разделяемых переменных и асинхронной передачи сообщений. Любой последовательный язык можно дополнить примитивами из Linda и получить параллельный вариант. Имеются кортежи процессов — процедуры которые выполняются асинхронно, и кортежи данных — помеченные записи. Здесь имеется шесть примитивов для доступа к пространству кортежей (разделяемой ассоциативной памяти); кортежем называется отмеченная запись данных. Пространство кортежей (ПК) похоже на единый разделяемый канал связи, но кортежи не упорядочены. OUT — операция помещения кортежа (запись — аналог send — в канал кортеж помещается); IN — операция извлечения кортежа (чтение — аналог receive — из канала кортеж извлекается); EVAL — операция создания процесса; INP — операция ввода (неблокирующая); RDP — операция чтения (неблокирующая); RD — операция просмотра (блокирующая). Пространство кортежей состоит: – из множества пассивных кортежей; – из множества активных кортежей. Каждый кортеж имеет вид ("tag", value_1, . . ., value_n) где метка "tag" является строкой (для различения кортежей). Значения (value_1, . . ., value_n) — это нуль или несколько значений данных (целых чисел, действительных чисел или массивов). Для обработки кортежей служат три базовых неделимых примитива: OUT, IN, RD. OUT("tag", expr_1, . . ., expr_n); — помещение кортежа в ПК (аналогично оператору send, но вместо канала рассматривается пространство кортежей). IN("tag", field_1, . . ., field_n); — извлечение кортежа из ПК (аналогично оператору receive).
64
Каждое поле field_i может быть выражением или формальным параметром вида ?var, где var — имя переменной выполняемого процесса. Аргументы примитива IN называются шаблоном. Процесс IN ждет, пока в ПК появится хотя бы один кортеж, соответствующий шаблону, и удаляет его из ПК. Кортеж d соответствует шаблону t, если 1) их метки идентичны; 2) число полей d и t одинаково; 3) значение каждого выражения в t (если оно указано) равно соответствующему значению в кортеже данных d. После того, как кортеж будет удален из ПК формальным параметрам шаблона присваивается соответствующие значения. Пример 1. Реализация семафора. Пусть sem — символьное имя семафора. Тогда операция V над семафором (увеличение семафора на единицу) будет иметь вид OUT("sem"), а операция P над семафором (ожидание увеличения семафора на единицу, если он был нуль) будет иметь вид IN("sem"). Замечание. Операции P и V состоят из следующих неделимых операций: P(s): 0); s=s-1;>, V(s): <s=s+1;>. Значение семафора — число кортежей sem в пространстве кортежей. Для моделирования массива семафоров используется дополнительное поле, представляющее индекс массива, например IN("sems", i); # P[sems[I]] OUT("sems", i); # V[sems[I]] Базовый примитив RD (блокирующий) используется для просмотра кортежей в пространстве кортежей (без их изъятия из этого пространства — в отличие от примитива IN). Если t — шаблон, то RD(t) приостанавливает процесс до тех пор, пока в пространстве кортежей не появится кортеж, соответствующий шаблону t. Примитивы INP и RDP выполняют те же действия, что IN и RD, но не являются блокирующими; они кроме того возвращают TRUE или FALSE в зависимости от того, есть или нет кортежа в пространстве кортежей с данным шаблоном. INP — извлекает кортеж из ПК (если он есть); 65
RDP — читает кортеж (но оставляет его в ПК). Пример 2. Реализация барьера-счетчика. OUT("barier", 0); # создание элемента barier в ПК с нулевым # значением счетчика Достигнув барьера тот или иной процесс извлекает счетчик из ПК: IN("barier", ?counter); # получение кортежа "barier" OUT("barier", counter=counter+1); # увеличение счетчика на единицу Далее процесс ждет, пока к барьеру придут все n процессов с помощью блокирующего чтения RD("barier", n); При появлении указанного кортежа в ПК процесс продолжает работу. Шестой (и последний) примитив в языке Linda — примитив, создающий новые кортежи EVAL("tag", expr_1, . . ., expr_n); Среди expr_i могут быть процедуры или функции. При создании кортежа они вычисляются, причем все поля кортежа вычисляются параллельно. Меткой кортежа становится метка "tag", а полями — значения после вычисления функций и процедур. Пример 3. Рассмотрим параллельный оператор co[i=1 to n] a[i]=f(i); Ему соответствует C-программа, обогащенная примитивами Linda for(i=1; i называется защитой. Символы S_i служат для обозначения последовательностей операторов. Выражение and B_i называется условием синхронизации, а выражение e_i называется условием планирования. Говорят, что защита в операторе ввода пропускает, если была вызвана операция и соответствующее условие синхронизации истинно (или его нет). Условие синхронизации может зависеть от значений параметров (т. к. их область видимости включает всю защищенную операцию), так что один вызов может привести к тому, что защита пропустит, а в другом случае — не пропустит. Выполнение in приостанавливает работу процесса, пока какаянибудь защита, наконец, пропустит. Если несколько защит пропускает, то (при отсутствии условий планирования) оператор обслуживает первый по времени вызов, пропуская через одну из ветвей, причем выбор срабатывающей ветви стандартом не определяется (фактически, ее выбор определяется конфигурированием системы; допуская очевидную вольность формулировок, говорят, что ветвь выбирается недетерминировано). Выражение планирования используется для изменения порядка 83
обработки вызовов: первым обслуживается самый старый вызов, который имеет минимальное значение выражения планирования. Заметим, что как и условие синхронизации выражение планирования может ссылаться на параметры операции, так что его значение, вообще говоря, зависит от аргументов вызова операции. § 8. Взаимодействие типа “клиент-сервер” Пусть имеется процесс с локальным кольцевым буфером из n элементов, который обслуживает две операции deposit и fetch: deposit — производитель помещает элемент в буфер; fetch — потребитель извлекает элемент из буфера. Рассмотрим кольцевой буфер, построенный в помощью рандеву. КОЛЬЦЕВОЙ БУФЕР НА ОСНОВЕ РАНДЕВУ module BoundedBuffer op deposit(type T), fetch(result type T); body process Buffer { type T buf[n]; int front=0; rear=0; count=0; # count - число обращений производителя к # буферу минус число изъятий while(true) # вызывая deposite() производитель # помещает товар в буфер: in deposite(item) and count buf[rear]=item; # - помещение товара в буфер rear=(rear+1) mod n; # - номер следующего пустого ящика count=count+1; [ ] # вызывая fetch(), потребитель # забирает товар из буфера: fetch(item) and count>0 --> item=buf[front]; front=(front+1) mod n; 84
# - продвижение фронта заполненных ящиков count=count-1; ni } end BoundedBuffer Далее приведем модуль, представляющий собой централизованное решение задачи “об обедающих философах”. РЕАЛИЗАЦИЯ ЗАДАЧИ “ОБ ОБЕДАЮЩИХ ФИЛОСОФАХ” module Table op getforks(int), relforks(int); body process Waiter { bool eating[5]=([5] false); # булевский массив while(true) # защита может не пропустить, если вилки заняты in getforks(i) and not(eating[left(i)]) and not(eating[right(i)]) --> eating[i]=true; [ ] relforks(i) --> eating[i]=false; ni } end Table process Philosopher[i=0 to 4] { while(true) { call getforks(i); # запрос вилки [поесть]; call relforks(i); # освобождение вилки [поразмышлять]; } } Приведем пример модуля сервера времени, аналогичый рассмотренному ранее.
85
РЕАЛИЗАЦИЯ МОДУЛЯ СЕРВЕРА ВРЕМЕНИ module TimeServer op get_time() returns int; # вызов дать времени op delay(int); # вызов задержки op tick(); # вызывается обработчиком прерывания часов body process Timer { int tod=0; # время суток while(true) # если для delay(waketime) имеем waketime time=tod; [ ] delay(waketime) and waketime [ ] tick() --> { tod=tod+1; [запуск таймера]} ni } end TimeServer Здесь операции get_time и delay экспортируются для клиентов, а tick — для обработчика прерывания часов. При вызове delay используется условие синхронизации. В следующем примере наряду с условием синхронизации используется выражение планирования. Это пример распределения ресурсов по принципу “кратчайшее задание”; в енм фигурирует условие планирования by time, что при выполнении условия синхронизации (в данном случае and free) ведет к первоочередному выполнению наиболее раннего вызова с минимальным параметром time. РАСПРЕДЕЛЕНИЯ РЕСУРСОВ ПО ПРИНЦИПУ “КРАТЧАЙШЕЕ ЗАДАНИЕ” module SJN_Allocator op request(int time), release(); body process SJN { bool free=true; # free - занятость ресурса, 86
# free=true - ресурс свободен while(true) in request(time) and free by time --> free=false; # and free - условие синхронизации # by time - выражение планирования # оно ведет к первоначальному исполнению # запроса с наименьшим time [ ] release() --> free=true; ni } end SJN_Allocator
§ 9. Обмен значениями Здесь возвращаемся к задаче, рассмотренной в параграфе 6; решение дадим с использованием рандеву. При этом процессы могут связываться друг с другом непосредственно. Однако, следует предостеречь от ошибки: процессы, сделавшие вызов одновременно, заблокируют друг друга. Поэтому один процесс должен выполнять операторы call и in в одном порядке, а другой — в противоположном. module Exchange[i=1 to 2] op deposit(int); body proc Worker { int myvalue, othervalue; if (i==1) { # один процесс вызывает call Exchange[2].deposit(myvalue); in deposit(othervalue) --> skip; ni } else { # другой процесс получает in deposit(othervalue) --> skip; ni call Exchange[1].deposit(myvalue); } } end Exchange
87
§ 10. Объединенная нотация Здесь рассматривается программная нотация, объединяющая RPC (Remote Procedure Call), рандеву и асинхронную передачу сообщений в единое целое. В этой нотации операция может быть вызвана – либо синхронным оператором call; – либо асинхронным оператором send в виде строк call Modname.opname([аргументы]); send Modname.opname([аргументы]); Оператор call завершается, как только операция обслужена и возвращены результирующие аргументы, а оператор send — как только вычислены аргументы. Если операция возвращает результат (как функция), то она может быть использована в выражении; в остальных случаях функциональный результат игнорируется. Операция может быть обслужена либо в процедуре proc, либо с помощью рандеву (оператором in). Если обслуживание происходит с помощью proc, то это производится немедленно без использования очередей. При обслуживании оператором in создается очередь процессов, вызывающих операцию (отметим, что доступ к очереди неделимый); в этом случае использование вызова call ведет к приостановке вызывающего процесса, а при использовании вызова send отправитель продолжает работу. Таким образом, имеется четыре комбинации: два способа вызова операции (call и send) и два способа обслуживания (proc и in). Приведем соответствующую таблицу.
88
Т а б л и ц а № 3. Вызов
Обслуживание
Результат
call
proc
call
in
send
proc
send
in
Вызов процедуры Рандеву Динамическое создание процесса Асинхронная передача сообщения
Приостановка вызвавшего да
Образование очереди нет
да
да
нет
нет
нет
да
Замечание. Операцию нельзя одновременно обслуживать с помощью proc и in, ибо возникает неопределенность, помещать ли в очередь. Однако, она может обслуживаться в нескольких операторах in, находящихся в нескольких процессах модуля; в этом случае образуется единая очередь с неделимым доступом. Для мониторов и асинхронной передачи сообщений ранее был определен примитив empty, определяющий есть ли объекты в канале сообщений или в очереди условной переменной. Здесь определим функцию ?opname, возвращающую число ожидающих вызовов операции opname. Пример: in op1 ( . . . ) --> S1; [ ] op2 ( . . . ) and ?op1==0 --> S2; Здесь операции op1 и op2 находятся в неравном положении: вызов op2 возможен только в том случае, если не было вызовов op1, а вызов op1 возможен всегда. § 11. Очередь и кольцевой буфер Здесь продемонстрируем различные способы вызова и обслуживания операций. ПОСЛЕДОВАТЕЛЬНАЯ ОЧЕРЕДЬ module Queue op deposit(type T), fetch(result type T); body 89
type T buf[n]; int front=1, rear=1, count=0; # front - передняя граница, rear - задняя граница, # count - количество элементов в buf proc deposit(item) { if (count0) { item=buf[front]; # при вызове fetch из buf извлекается элемент front=(front+1) mod n; count=count-1; } else [действия, соответствующие опустошению]; } end Queue Замечание 1. Если deposit вызван оператором call, то вызывающий процесс ждет, а если deposit вызвана оператором send, то вызывающий процесс продолжает работу. Замечание 2. Модуль Queue пригоден для использования одним процессом в другом модуле, но его не могут использовать одновременно несколько процессов, ибо в нем нет критических секций для защиты переменных модуля; при параллельном вызове операций может возникнуть взаимное влияние. Введем теперь оператор receive, а именно, следующий оператор ввода in op(f_1, . . ., f_n) --> v_1=f_1; . . .; v_n=f_n; ni обозначим 90
receive op(v_1, . . ., v_n) Введем еще операцию empty(), которая не имеет аргументов, и вызывается оператором send, а обслуживается оператором receive. Такая операция эквивалентна семафору, где send выступает в качестве V, а receive — в качестве P. Начальное значение семафора равно нулю. Его текущее значение, это число “пустых” сообщений, переданных операции, минус число полученных сообщений. В следующей программе буфера для реализации исключения использованы семафоры. КОЛЬЦЕВОЙ БУФЕР С СЕМАФОРАМИ module BoundedBuffer op deposit(type T), fetch(result type T); body type T buf[n]; int front=1, rear=1; # локальные операции для имитации семафоров: op empty(), full(), D(), F(); send D(); send F(); for [i=1 to n] # инициализация пустого "семафора" send empty(); proc deposit(item){ receive empty(); receive D(); buf[rear]=item; rear=(rear+1) mod n; send D(); send full(); } proc fetch(item) { receive full(); receive F(); item=buf[front]; front=(front+1) mod n; send F(); send empty(); } end BoundedBuffer
91
Список литературы [1] Воеводин В.В. Математические модели и методы в параллельных процессах. М., 1986. 296 с. [2] Корнеев В.В. Параллельные вычислительные система. М., 1999. 320 с. [3] Yukiya Aoyama, Jun Nakato. RS/6000 SP:Practical MPI Programming. IBM. Tachnical Support Organization., 2000. 221 p. www.redbook.ibm.com [4] Воеводин В.В., Воеводин Вл.В. Параллельные вычисления. СПб., 2002. 608 с. [5] Бурова И.Г., Демьянович Ю.К. Лекции по параллельным вычислениям. СПб., 203. 132 с. [6] Грегори Р. Эндрюс. Основы многопоточного параллельного и распределенного программирования. М., 2003. 512 с. [7] Математическая Энциклопедия. М., 1977. Т.1, 1152 с.
92
ОГЛАВЛЕНИЕ
ВВЕДЕНИЕ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Глава 1. ПРОГРАММИРОВАНИЕ С ИСПОЛЬЗОВАНИЕМ ПЕРЕДАЧИ СООБЩЕНИЙ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6 § 1. О распределенном программировании . . . . . . . . . . . . . . . . . . 6 § 2. Асинхронная передача сообщений . . . . . . . . . . . . . . . . . . . . . . 8 § 3. Сортировка, фильтры . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10 § 4. Клиенты и серверы. Файловые системы . . . . . . . . . . . . . . . 13 Глава 2. МОНИТОРЫ И УСЛОВНЫЕ ПЕРЕМЕННЫЕ . . 15 § 1. Мониторы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 § 2. Структура монитора . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 § 3. Взаимное исключение в мониторе . . . . . . . . . . . . . . . . . . . . . 16 § 4. Условные переменные . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 17 § 5. Способы выполнения операции сигнализации . . . . . . . . . 18 § 6. Операции с условными переменными . . . . . . . . . . . . . . . . . . 21 § 7. Монитор, реализующий кольцевой буфер . . . . . . . . . . . . . 22 § 8. Задача о “читателях” и “писателях” . . . . . . . . . . . . . . . . . . . .24 § 9. Распределение ресурсов по приоритетам . . . . . . . . . . . . . . 25 § 10. Организация “спящих” процессов . . . . . . . . . . . . . . . . . . . . . 26 Глава 3. РАНДЕВУ И АКТИВНЫЕ МОНИТОРЫ . . . . . . . . 30 § 1. Рандеву . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30 § 2. Активные мониторы . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 § 3. Планирующий сервер . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 § 4. Файловые серверы и клиенты . . . . . . . . . . . . . . . . . . . . . . . . . .42 § 5. Обмен значениями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 Глава 4. ОПЕРАТОРЫ ВЗАИМОДЕЙСТВИЯ И ЗАЩИТА 49 § 1. Синхронная передача сообщений . . . . . . . . . . . . . . . . . . . . . . 49 § 2. Операторы взаимодействия . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51 § 3. Защищенное взаимодействие . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 § 4. Программа генерации простых чисел . . . . . . . . . . . . . . . . . . 57
93
Глава 5. О НЕКОТОРЫХ ЯЗЫКАХ ПРОГРАММИРОВАНИЯ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59 § 1. О языке Occam (“Бритва Оккама”) . . . . . . . . . . . . . . . . . . . . 59 § 2. О языке CSP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 § 3. О языке Linda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 § 4. Портфель задач . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66 Глава 6. УДАЛЕННЫЙ ВЫЗОВ ПРОЦЕДУР И ВЗАИМОДЕЙСТВИЕ ПРОЦЕССОВ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71 § 1. Удаленный вызов процедур . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71 § 2. Вопросы взаимно исключающего доступа и синхронизации (внутри модуля) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .73 § 3. Модуль сервера времени . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73 § 4. Распределенная файловая система . . . . . . . . . . . . . . . . . . . . 75 § 5. Фильтр слияния в Remote Procedure Call (RPC) . . . . . . 78 § 6. Обмен значениями в RPC . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80 § 7. Операторы ввода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80 § 8. Взаимодействие типа ”клиент-сервер” . . . . . . . . . . . . . . . . . 82 § 9. Обмен значениями . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85 § 10. Объединенная нотация . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 § 11. Очередь и кольцевой буфер . . . . . . . . . . . . . . . . . . . . . . . . . . 87 СПИСОК ЛИТЕРАТУРЫ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 90
94