Добавлен: 29.10.2018
Просмотров: 48029
Скачиваний: 190
166
Глава 2. Процессы и потоки
Также стоит заметить, что условные переменные (в отличие от семафоров) не запоми-
наются. Если сигнал отправлен условной переменной, изменения значения которой не
ожидает ни один из потоков, сигнал теряется. Чтобы не потерять сигнал, программисты
должны обращать особое внимание.
В качестве примера использования мьютексов и условных переменных в листинге 2.8
показана очень простая задача производителя-потребителя, в которой используется один-
единственный буфер. Когда производитель заполняет буфер, то перед тем как произвести
следующую запись, он должен ждать, пока потребитель не опустошит этот буфер. Точно
так же, когда потребитель извлечет запись, он должен ждать, пока производитель не про-
изведет другую запись. При всей своей предельной простоте этот пример иллюстрирует
работу основного механизма. Оператор, приостанавливающий работу потока, прежде
чем продолжить его работу, должен убедиться в выполнении условия, поскольку поток
может быть активирован в результате поступления сигнала UNIX или по другой причине.
Листинг 2.8. Использование потоков для решения задачи производителя-
потребителя
#include <stdio.h>
#include <pthread.h>
#define MAX 1000000000 /* Количество производимого */
pthread_mutex_t the_mutex;
pthread_cond_t condc, condp; /* используется для сигнализации */
int buffer = 0; /* буфер, используемый между производителем и
потребителем */
void *producer(void *ptr) /* производство данных */
{ int i;
for (i= 1; i <= MAX; i++) {
pthread_mutex_lock(&the mutex); /* получение исключительного
доступа к буферу */
while (buffer != 0) pthread_cond_wait(&condp, &the_mutex);
buffer = i; /* помещение записи в буфер */
pthread_cond_signal(&condc); /* активизация потребителя */
pthread_mutex_unlock(&the mutex); /* освобождение доступа к буферу */
}
pthread exit(0);
}
void *consumer(void *ptr) /* потребление данных */
{ int i;
for (i = 1; i <= MAX; i++) {
pthread_mutex_lock(&the_mutex); /* получение исключительного
доступа к буферу */
while (buffer ==0 ) pthread_cond_wait(&condc, &the_mutex);
buffer = 0; /* извлечение записи из буфера */
pthread_cond_signal(&condp); /* активизация производителя */
pthread_mutex_unlock(&the_mutex); /* освобождение доступа к буферу */
}
pthread exit(0);
}
int main(int argc, char **argv)
{
2.3. Взаимодействие процессов
167
pthread_t pro, con;
pthread_mutex_init(&the mutex, 0);
pthread_cond_init(&condc, 0);
pthread_cond_init(&condp, 0);
pthread_create(&con, 0, consumer, 0);
pthread_create(&pro, 0, producer, 0);
pthread_join(pro, 0);
pthread_join(con, 0);
pthread_cond_destroy(&condc);
pthread_cond_destroy(&condp);
pthread_mutex_destroy(&the mutex);
}
2.3.7. Мониторы
Если вы думаете, что благодаря семафорам и мьютексам организация взаимодей-
ствия процессов кажется весьма простой задачей, то выкиньте это из головы. При-
смотритесь к порядку выполнения операций down перед вставкой записей в буфер
или удалением записей из буфера, показанному в листинге 2.6. Допустим, что две
процедуры down в коде производителя были переставлены местами, чтобы значение
mutex было уменьшено до уменьшения значения empty, а не после него. Если бы
буфер был заполнен под завязку, производитель был бы заблокирован, а значение
mutex было бы установлено в 0. Следовательно, при следующей попытке доступа
производителя к буферу он осуществлял бы down в отношении mutex, который теперь
имеет значение 0, и также блокировал бы его. Оба процесса находились бы в заблоки-
рованном состоянии бесконечно долго, не позволяя что-либо сделать. Такая непри-
ятная ситуация называется взаимной блокировкой, к ее детальному рассмотрению
мы вернемся в главе 6.
О существовании этой проблемы было упомянуто, чтобы показать, насколько акку-
ратно нужно относиться к использованию семафоров. Одна малозаметная ошибка —
и все будет остановлено. Это напоминает программирование на ассемблере, но здесь
ситуация еще хуже, поскольку ошибки касаются состязательных ситуаций, взаимных
блокировок и иных форм непредсказуемого и невоспроизводимого поведения.
Чтобы облегчить написание безошибочных программ, Бринч Хансен (Brinch Hansen)
в 1973 году и Хоар (Hoare) в 1974 году предложили высокоуровневый синхронизаци-
онный примитив, названный монитором. Их предложения, как мы увидим дальше,
мало отличались друг от друга. Монитор представляет собой коллекцию переменных
и структур данных, сгруппированных вместе в специальную разновидность модуля
или пакета процедур. Процессы могут вызывать любые необходимые им процедуры,
имеющиеся в мониторе, но не могут получить непосредственный доступ к внутрен-
ним структурам данных монитора из процедур, объявленных за пределами монитора.
В листинге 2.9 показан монитор, написанный на воображаемом языке Pidgin Pascal.
Язык C здесь не подойдет, поскольку мониторы являются понятиями языка, а C такими
понятиями не обладает.
Листинг 2.9. Монитор
monitor example
integer i;
condition c;
168
Глава 2. Процессы и потоки
procedure producer();
.
.
.
end;
procedure consumer();
. . .
end;
end monitor;
У мониторов имеется весьма важное свойство, позволяющее успешно справляться со
взаимными исключениями: в любой момент времени в мониторе может быть активен
только один процесс. Мониторы являются конструкцией языка программирования,
поэтому компилятор осведомлен об их особенностях и способен обрабатывать вызовы
процедур монитора не так, как вызовы всех остальных процедур. Обычно при вызове
процессом процедуры монитора первые несколько команд процедуры осуществляют
проверку на текущее присутствие активности других процессов внутри монитора. Если
какой-нибудь другой процесс будет активен, вызывающий процесс будет приостановлен
до тех пор, пока другой процесс не освободит монитор. Если монитор никаким другим
процессом не используется, вызывающий процесс может в него войти.
Реализация взаимного исключения при входе в монитор возлагается на компилятор,
но чаще всего для этого используется мьютекс или двоичный семафор. Поскольку
обеспечением взаимного исключения занимается компилятор, а не программист,
вероятность того, что будут допущены какие-то неправильные действия, становится
гораздо меньше. В любом случае тот, кто создает монитор, не должен знать, как именно
компилятор обеспечивает взаимное исключение. Достаточно лишь знать, что после
превращения всех критических областей в процедуры монитора никакие два процесса
не будут выполнять код своих критических областей в одно и то же время.
Хотя мониторы и обеспечивают простой способ достижения взаимного исключения,
как мы видели ранее, этого недостаточно. Нам также нужен способ, позволяющий
заблокировать процессы, которые не в состоянии продолжить свою работу. В случае
с решением задачи производителя-потребителя проще всего поместить все тесты на
заполненность и опустошенность буфера в процедуры монитора, но как тогда произ-
водитель должен заблокироваться, обнаружив, что буфер заполнен до отказа?
Для решения этой проблемы нужно ввести условные переменные, а также две прово-
димые над ними операции — wait и signal. Когда процедура монитора обнаруживает
невозможность продолжения своей работы (например, производитель обнаружил, что
буфер заполнен), она осуществляет операцию wait в отношении какой-нибудь услов-
ной переменной, скажем, full. Это действие призывает процесс к блокированию. Оно
также позволяет войти в монитор другому процессу, которому ранее этот вход был за-
прещен. Мы уже встречались с условными переменными и с этими операциями, когда
рассматривали пакет Pthreads.
Этот другой процесс, например потребитель, может активизировать работу своего
приостановленного партнера, осуществив операцию signal в отношении условной пере-
менной, изменения значения которой ожидает его партнер. Чтобы в мониторе в одно
и то же время не находились сразу два активных процесса, нам необходимо правило,
предписывающее дальнейшее развитие событий после осуществления операции signal.
Хоар предложил позволить только что активизированному процессу приостановить
2.3. Взаимодействие процессов
169
работу другого процесса. Бринч Хансен предложил разрешить проблему, потребовав
обязательного и немедленного выхода из монитора того процесса, который осуществил
операцию signal. Иными словами, операция signal должна фигурировать только в каче-
стве завершающей операции в процедуре монитора. Мы воспользуемся предложением
Бринча Хансена, поскольку оно проще по замыслу и реализации. Если операция signal
осуществляется в отношении условной переменной, изменения которой ожидают
сразу несколько процессов, активизирован будет лишь один из них, определяемый
системным планировщиком.
В резерве есть еще одно, третье решение, которое не было предложено ни Хоаром, ни
Бринчем Хансеном. Суть его в том, чтобы позволить сигнализирующему процессу про-
должить свою работу и позволить ожидающему процессу возобновить работу только
после того, как сигнализирующий процесс покинет монитор.
Условные переменные не являются счетчиками. Они не аккумулируют сигналы для
последующего использования, как это делают семафоры. Поэтому, если сигнал об-
рабатывает условную переменную, изменения которой никто не ожидает, этот сигнал
теряется навсегда. Иными словами, операция wait должна предшествовать операции
signal. Это правило значительно упрощает реализацию. На практике проблем не возни-
кает, поскольку куда проще, если понадобится, отследить состояние каждого процесса
с переменными. Процесс, который мог бы при других условиях осуществить операцию
signal, может увидеть, взглянув на переменные, что в ней нет необходимости.
Схема решения задачи производителя-потребителя с использованием мониторов
показана на воображаемом языке Pidgin Pascal в листинге 2.10. Здесь преимущества
использования Pidgin Pascal проявляются в его простоте и точном следовании модели
Хоара — Бринча Хансена.
Листинг 2.10. Набросок решения задачи производителя-потребителя с помощью
мониторов. В любой момент времени может быть активна только одна процедура
монитора. В буфере содержится N мест
monitor ProducerConsumer
condition full, empty;
integer count;
procedure insert(item: integer);
begin
if count = N then wait(full);
insert item(item);
count := count + 1;
if count =1 then signal(empty)
end;
function remove : integer;
begin
if count =0 then wait(empty);
remove = remove item;
count := count − 1;
if count = N− 1 then signal(full)
end;
count := 0;
end monitor;
170
Глава 2. Процессы и потоки
procedure producer;
begin
while true do
begin
item = produce item;
ProducerConsumer.insert(item)
End
end;
procedure consumer;
begin
while true do
begin
item = ProducerConsumer.remove;
consume item(item)
end
end;
Может создаться впечатление, что операции wait и signal похожи на операции sleep
и wakeup, которые, как мы видели ранее, приводят к фатальному состоянию состязатель-
ности. Конечно же, они очень похожи, но с одной весьма существенной разницей: sleep
и wakeup терпели неудачу, когда один процесс пытался заблокироваться, а другой — его
активизировать. При использовании мониторов такая ситуация исключена. Автомати-
ческая организация взаимного исключения в отношении процедур монитора гаранти-
рует следующее: если, скажем, производитель, выполняя процедуру внутри монитора,
обнаружит, что буфер полон, он будет иметь возможность завершить операцию wait, не
испытывая волнений о том, что планировщик может переключиться на выполнение про-
цесса потребителя еще до того, как операция wait будет завершена. Потребителю вообще
не будет позволено войти в монитор, пока не завершится операция wait и производитель
не будет помечен как неспособный к дальнейшему продолжению работы.
Хотя Pidgin Pascal является воображаемым языком, некоторые реально существу-
ющие языки программирования также поддерживают мониторы, быть может, и не
всегда в форме, разработанной Хоаром и Бринчем Хансеном. Одним из таких языков
является Java — объектно-ориентированный язык, поддерживающий потоки на уров-
не пользователя, а также разрешающий группировать методы (процедуры) в классы.
При добавлении к объявлению метода ключевого слова synchronized Java гарантирует
следующее: как только один поток приступает к выполнению этого метода, ни одному
другому потоку не будет позволено приступить к выполнению любого другого метода
этого объекта с ключевым словом synchronized. Без использования ключевого слова
synchronized гарантии чередования отсутствуют.
В листинге 2.11 приведено решение задачи производителя-потребителя с использова-
нием мониторов, реализованное на языке Java. Решение включает в себя четыре класса.
Самый первый класс, ProducerConsumer, создает и запускает два потока — p и c. Второй
и третий классы — producer и consumer — содержат код для производителя и потребите-
ля соответственно. И наконец, класс our_monitor представляет собой монитор. Он со-
стоит из двух синхронизированных потоков, которые используются для фактического
помещения записей в общий буфер и извлечения их оттуда. В отличие от предыдущего
примера, здесь мы наконец-то приводим полный код методов insert и remove.
Потоки производителя и потребителя функционально идентичны своим двойникам
во всех предыдущих примерах. У производителя имеется бесконечный цикл, генери-