Глава_1 (1085730), страница 8
Текст из файла (страница 8)
В роли чего-то другого выступает передача сообщений. Этот метод межпроцессного взаимодействия использует два примитива: send и receive, которые скорее являются системными вызовами, чем структурными компонентами языка (что отличает их от мониторов и делает похожим на семафоры). Поэтому их легко можно поместить в библиотечные процедуры, например
Send (destination, &message);
receive(source, &message);
Первый запрос посылает сообщение заданному адресату, а второй получает сообщение от указанного источника (или от любого источника, если это не имеет значения). Если сообщения нет, второй запрос блокируется до поступления сообщения либо немедленно возвращает код ошибки.
Разработка систем передачи сообщений
С системами передачи сообщений связано большое количество сложных проблем и конструктивных вопросов, которых не возникает в случае семафоров и мониторов. Особенно много сложностей появляется в случае взаимодействия процессов, происходящих на различных компьютерах, соединенных сетью. Так, сообщение может потеряться в сети. Чтобы избежать потери сообщений, отправитель и получатель договариваются, что при получении сообщения получатель посылает обратно подтверждение приема сообщения. Если отправитель не получает подтверждения через некоторое время, он отсылает сообщение еще раз.
Теперь представим, что сообщение получено, но подтверждение до отправителя не дошло. Отправитель пошлет сообщение еще раз, и до получателя оно дойдет дважды. Крайне важно, чтобы получатель мог отличить копию предыдущего сообщения от нового. Обычно проблема решается с помощью помещения порядкового номера сообщения в тело самого сообщения. Если к получателю приходит письмо с номером, совпадающим с номером предыдущего письма, письмо классифицируется как копия и игнорируется. Решение проблемы успешного обмена информации в условиях ненадежной передачи сообщений составляет основу изучения компьютерных сетей. Для систем обмена сообщениями также важен вопрос названий процессов. Необходимо однозначно определять процесс, указанный в запросе send или receive. Кроме того, встает вопрос аутентификации: каким образом клиент может определить, что он взаимодействует с настоящим файловым сервером, а не с самозванцем?
Помимо этого, существуют конструктивные проблемы, существенные при расположении отправителя и получателя на одном компьютере. Одной из таких проблем является производительность. Копирование сообщений из одного процесса в другой происходит гораздо медленнее, чем операция на семафоре или вход в монитор. Было проведено множество исследований с целью увеличения эффективности передачи сообщений. В [65], например, предлагалось ограничивать размер сообщения до размеров регистра и передавать сообщения через регистры.
Решение проблемы производителя и потребителя с передачей сообщений
Теперь рассмотрим решение проблемы производителя и потребителя с передачей сообщений и без использования разделенной памяти. Решение представлено в листинге 2.9. Мы предполагаем, что все сообщения имеют одинаковый размер и сообщения, которые посланы, но еще не получены, автоматически помещаются операционной системой в буфер. В этом решении используются N сообщений, по аналогии с N сегментами в буфере. Потребитель начинает с того, что посылает производителю N пустых сообщений. Как только у производителя оказывается элемент данных, который он может предоставить потребителю, он берет пустое сообщение и отсылает назад полное. Таким образом, общее число сообщений в системе постоянно и их можно хранить в заранее заданном участке памяти.
Если производитель работает быстрее, чем потребитель, все сообщения будут ожидать потребителя в заполненном виде. При этом производитель блокируется в ожидании пустого сообщения. Если потребитель работает быстрее, ситуация инвертируется: все сообщения будут пустыми, а потребитель будет блокирован в ожидании полного сообщения.
Листинг 2.9. Решение проблемы производителя и потребителя с использованием N сообщений
#define N 100 /* количество сегментов в буфере */
void producer(void)
{
int item;
message m; /* буфер для сообщений */
while (TRUE) {
item = produce_item(); /* сформировать нечто, чтобы заполнить буфер */
receive(consumer, &m): /* ожидание прибытия пустого сообщения */
build_message(&m, item); /* сформировать сообщение для отправки */
send(consumer, &m); /* отослать элемент потребителю */
} }
void consumer(void)
{
int itern, i;
message m;
for (i = 0; i < N; i++) send(producer, &m): /* отослать N пустых сообщений */
while (TRUE) {
receive(producer, &m); /* получить сообщение с элементом */
item = extract_item(&m); /* извлечь элемент из сообщения */
send(producer, &m); /* отослать пустое сообщение */
consume_item(item); /* обработка элемента */
}}
Передача сообщений может быть реализована по-разному. Рассмотрим способ адресации сообщений. Можно присвоить каждому из процессов уникальный адрес и адресовать сообщение непосредственно процессам. Другой подход состоит в использовании новой структуры данных, называемой почтовым ящиком. Почтовый ящик — это буфер для определенного количества сообщений, тип которых задается при создании ящика. При использовании почтовых ящиков в качестве параметров адреса send и receive задаются почтовые ящики, а не процессы. Если процесс пытается послать сообщение в полный почтовый ящик, ему приходится подождать, пока хотя бы одно сообщение не будет удалено из ящика.
В задаче производителя и потребителя оба они создадут почтовые ящики, достаточно большие, чтобы хранить N сообщений. Производитель будет посылать сообщения с данными в почтовый ящик потребителя, а потребитель будет посылать пустые сообщения в почтовый ящик производителя. С использованием почтовых ящиков метод буферизации очевиден: в почтовом ящике получателя хранятся сообщения, которые были посланы процессу-получателю, но еще не получены.
Другим предельным случаем использования почтовых ящиков является принципиальное отсутствие буферизации. При таком подходе, если send выполняется раньше, чем receive, посылающий процесс блокируется до выполнения receive, когда сообщение может быть напрямую скопировано от отправителя к получателю без промежуточной буферизации. Если receive выполняется раньше, чем send, получающий процесс блокируется до выполнения send. Этот метод часто называется рандеву, он легче реализуется, чем схема буферизации сообщений, но менее гибок, поскольку отправитель и получатель должны работать в режиме жесткой синхронизации.
Передача сообщений часто используется в системах с параллельным программированием. Характерным примером системы передачи сообщений является MPI (Message-Passing Interface — интерфейс передачи сообщений).
Барьеры
Последний из рассмотренных нами механизмов синхронизации предназначался скорее для групп процессов, нежели для ситуаций с двумя процессами типа производитель—потребитель. Некоторые приложения делятся на фазы, и существует правило, что процесс не может перейти в следующую фазу, пока к этому не готовы все остальные процессы. Этого можно добиться, разместив в конце каждой фазы барьер. Когда процесс доходит до барьера, он блокируется, пока все процессы не дойдут до барьера. Действие барьера представлено на рис. 2.17.
Процессы
Барьер


Барьер
б
Барьер
с
Время
Рис. 2.17. Применение барьеров: процессы, приближающиеся к барьеру (а); все процессы, кроме одного, блокированы барьером (б); как только последний процесс достигает барьера, все процессы переходят в следующую фазу (в)
На рис. 2.17, а представлены четыре процесса, приближающиеся к барьеру. Это означает, что они заняты вычислениями и еще не дошли до конца фазы. Через некоторое время первый процесс завершает вычисления, предусмотренные в этой фазе. Он выполняет примитив barrier, чаще всего вызывая библиотечную процедуру. Затем процесс приостанавливается. Через некоторое время второй и третий процессы заканчивают первую фазу и выполняют примитив barrier (рис. 2.17, б). Наконец, когда последний процесс достигает барьера, все процессы переходят в следующую фазу, как показано на рис. 2.17, в.
Рассмотрим типичную задачу релаксации в физике или машиностроении в качестве примера ситуации, требующей наличия барьеров. Обычно задача представляется в виде матрицы с некоторыми начальными значениями. Эти значения могут соответствовать температуре в разных точках металлической пластины. Необходимо рассчитать, через какое время установится постоянное распределение температуры в случае нагрева одной стороны пластины.
К матрице применяется некоторое преобразование, например соответствующее законам термодинамики, чтобы получить матрицу значений температуры через
некоторое время. Преобразование применяется снова и снова, чтобы получить зависимость температуры в каждой точке от времени. Результатом будет серия матриц, соответствующих различным моментам времени.
Теперь представим, что матрица очень большая (скажем, миллион на миллион) и для ускорения расчетов необходимы параллельные процессы, возможно, на мультипроцессоре. Различные процессы обрабатывают различные части матрицы, рассчитывая новые элементы на основе старых по законами физики. Очевидно, что ни один процесс не должен начинать итерацию п + 1, пока все процессы не закончили свою текущую работу. Этой цели можно достичь, если каждый процесс будет выполнять операцию barrier, закончив свою часть итерации. Когда все процессы закончили работу и новая матрица, являющаяся входными данными для следующей итерации, составлена, все процессы одновременно начнут следующую итерацию.
Классические проблемы межпроцессного взаимодействия
Литература по операционным системам содержит множество интересных проблем, которые широко обсуждались и анализировались с применением различных методов синхронизации. В этом разделе мы рассмотрим три наиболее известные проблемы.
Проблема обедающих философов
В 1965 году Дейкстра сформулировал и решил проблему синхронизации, названную им проблемой обедающих философов. С тех пор каждый, кто изобретал еще один новый примитив синхронизации, считал своим долгом продемонстрировать достоинства нового примитива на примере проблемы обедающих философов. Проблему можно сформулировать следующим образом: пять философов сидят за круглым столом, и у каждого есть тарелка со спагетти. Спагетти настолько скользкие, что каждому философу нужно две вилки, чтобы с ними управиться. Между каждыми двумя тарелками лежит одна вилка (рис. 2.18).
Жизнь философа состоит из чередующихся периодов поглощения пищи и размышлений. (Разумеется, это абстракция, даже применительно к философам, но остальные процессы жизнедеятельности для нашей задачи несущественны.) Когда философ голоден, он пытается получить две вилки, левую и правую, в любом порядке. Если ему удалось получить две вилки, он некоторое время ест, затем кладет вилки обратно и продолжает размышления. Вопрос состоит в следующем: можно ли написать алгоритм, который моделирует эти действия для каждого философа и никогда не застревает? (Кое-кто считает, что необходимость двух вилок выглядит несколько искусственно. Возможно, нам следует заменить итальянскую пищу блюдами китайской кухни, спагетти — рисом, а вилки — соответствующими палочками.)