Методы взаимоисключений. Семафоры и мониторы. Взаимоблокировки (тупики). Системные вызовы. Синхронизирующие объекты ОС. Аппаратно-программные средства поддержки мультипрограммирования.
Выбери формат для чтения
Загружаем конспект в формате pdf
Это займет всего пару минут! А пока ты можешь прочитать работу в формате Word 👇
Лекция 6
6.1. Методы взаимоисключений
Организация взаимоисключения для критических участков, конечно, позволит
избежать возникновения race condition, но не является достаточной для правильной и
эффективной параллельной работы кооперативных процессов. Сформулируем пять
условий, которые должны выполняться для хорошего программного алгоритма
организации взаимодействия процессов, имеющих критические участки, если они могут
проходить их в произвольном порядке [10, 17].
1. Задача должна быть решена чисто программным способом на обычной машине,
не имеющей специальных команд взаимоисключения. При этом предполагается,
что основные инструкции языка программирования (такие примитивные
инструкции, как load, store, test) являются атомарными операциями.
2. Не должно существовать никаких предположений об относительных скоростях
выполняющихся процессов или числе процессоров, на которых они
исполняются.
3. Если процесс Pi исполняется в своем критическом участке, то не существует
никаких других процессов, которые исполняются в своих соответствующих
критических секциях. Это условие получило название условия
взаимоисключения (mutual exclusion).
4. Процессы, которые находятся вне своих критических участков и не собираются
входить в них, не могут препятствовать другим процессам входить в их
собственные критические участки. Если нет процессов в критических секциях и
имеются процессы, желающие войти в них, то только те процессы, которые не
исполняются в remainder section, должны принимать решение о том, какой
процесс войдет в свою критическую секцию. Такое решение не должно
приниматься бесконечно долго. Это условие получило название условия
прогресса (progress).
5. Hе должно возникать бесконечного ожидания для входа процесса в свой
критический участок. От того момента, когда процесс запросил разрешение на
вход в критическую секцию, и до того момента, когда он это разрешение
получил, другие процессы могут пройти через свои критические участки лишь
ограниченное число раз. Это условие получило название условия ограниченного
ожидания (bound waiting).
Надо заметить, что описание соответствующего алгоритма в нашем случае означает
описание способа организации пролога и эпилога для критической секции.
Критический участок должен сопровождаться прологом и эпилогом, которые не имеют
отношения к активности одиночного процесса. Во время выполнения пролога процесс
должен, в частности, получить разрешение на вход в критический участок, а во время
выполнения эпилога – сообщить другим процессам, что он покинул критическую
секцию.
Наиболее простым решением поставленной задачи является организация пролога и
эпилога запретом на прерывания:
while (some condition)
{
запретить все прерывания
critical section
разрешить все прерывания
remainder section
}
Поскольку выход процесса из состояния исполнения без его завершения
осуществляется по прерыванию, внутри критической секции никто не может вмешаться
в его работу. Если прерывания запрещены, невозможно прерывание по таймеру.
Отключение прерываний исключает передачу процессора другому процессу. Таким
образом, при запрете прерываний процесс может считаться и сохранять совместно
используемые данные, не опасаясь вмешательства другого процесса. Однако этот
способ практически не применяется, так как опасно доверять управление системой
пользовательскому процессу – он может надолго занять процессор, а результат сбоя в
критической ситуации может привести к краху ОС и, следовательно, всей системы.
Кроме того, нужного результата можно не достичь в многопроцессорной системе, так
как запрет прерываний будет относиться только к одному процессу, остальные
процессоры продолжают работу и сохраняют доступ к разделенным данным.
Тем не менее, запрет и разрешение прерываний часто применяются как пролог и
эпилог к критическим секциям внутри самой операционной системы, например, при
обновлении содержимого PSW (Programming Status Word).
Для синхронизации потоков одного процесса программист может
использовать глобальные блокирующие переменные. С этими переменными, к которым
все потоки процесса имеют прямой доступ, программист работает, не обращаясь к
системным вызовам ОС.
Каждому набору критических данных ставится в соответствие двоичная переменная.
Поток может войти в критическую секцию только тогда, когда значение этой
переменной-замка равно 0, одновременно изменяя ее значение на 1 – закрывая замок.
При выходе из критической секции поток сбрасывает ее значение в 0 – замок
открывается.
shared int lock = 0;
while (some condition)
{
while(lock); lock = 1;
critical section
lock = 0;
remainder section
}
К сожалению, внимательное изучение показывает, что такое решение не удовлетворяет
условию взаимоисключения, так как действие while(lock); lock = 1 ; не является
атомарным. Допустим, что поток P0 протестировал значение переменной lock и принял
решение двигаться дальше. В этот момент, еще до присваивания переменной lock
значения 1, планировщик передал управление потоку P1. Он тоже изучает содержимое
переменной lock и тоже принимает решение войти в критический участок. Мы получаем
два процесса, одновременно выполняющих свои критические секции.
Попробуем решить задачу сначала для двух процессов. Очередной подход будет также
использовать общую для них обоих переменную с начальным значением 0. Только
теперь она будет играть не роль замка для критического участка, а явно указывать, кто
может следующим войти в него. Для i-го процесса это выглядит так:
shared int turn = 0;
while (some condition)
{
while(turn != i);
critical section
turn = 1-i;
remainder section
}
Легко видеть, что взаимоисключение гарантируется, процессы входят в критическую
секцию строго по очереди: P0, P1, P0, P1, P0, ... Но наш алгоритм не удовлетворяет
условию прогресса. Например, если значение turn равно 1 и процесс P0 готов войти в
критический участок, он не может сделать этого, даже если процесс P1 находится
в remainder section.
Недостаток предыдущего алгоритма заключается в том, что процессы ничего не знают о
состоянии друг друга в текущий момент времени. Давайте попробуем исправить эту
ситуацию. Пусть два процесса имеют разделяемый массив флагов готовности входа
процессов в критический участок
shared int ready[2] = {0, 0};
Когда i-й процесс готов войти в критическую секцию, он присваивает элементу
массива ready[i] значение, равное 1. После выхода из критической секции он,
естественно, сбрасывает это значение в 0. Процесс не входит в критическую секцию,
если другой процесс уже готов к входу в критическую секцию или находится в ней.
shared int turn = 0;
while (some condition)
{
ready[i] = 1;
while(ready[1-i]);
critical section
ready[i] = 0;
remainder section
}
Полученный алгоритм обеспечивает взаимоисключение, позволяет процессу, готовому
к входу в критический участок, войти в него сразу после завершения эпилога в другом
процессе, но все равно нарушает условие прогресса. Пусть процессы практически
одновременно подошли к выполнению пролога. После выполнения
присваивания ready[0] = 1 планировщик передал процессор от процесса 0 процессу 1,
который также выполнил присваивание ready[1] = 1. После этого оба процесса
бесконечно долго ждут друг друга на входе в критическую секцию. Возникает
ситуация, которую принято называть тупиковой (deadlock).
Первое решение проблемы, удовлетворяющее всем требованиям и использующее идеи
ранее рассмотренных алгоритмов, было предложено датским математиком Деккером
(Dekker). В 1981 году Петерсон (Peterson) предложил более изящное решение. Пусть
оба процесса имеют доступ к массиву флагов готовности и к переменной очередности.
shared int ready[2] = {0, 0};
shared int turn;
while (some condition)
{
ready[i] = 1;
turn =1- i;
while(ready[1-i] && turn == 1-i);
critical section
ready[i] = 0;
remainder section
}
При исполнении пролога критической секции процесс Pi заявляет о своей готовности
выполнить критический участок и одновременно предлагает другому процессу
приступить к его выполнению. Если оба процесса подошли к прологу практически
одновременно, то они оба объявят о своей готовности и предложат выполняться друг
другу. При этом одно из предложений всегда последует после другого. Тем самым
работу в критическом участке продолжит процесс, которому было сделано последнее
предложение.
Наличие аппаратной поддержки взаимоисключений позволяет упростить алгоритмы и
повысить их эффективность точно так же, как это происходит и в других областях
программирования. Мы уже обращались к аппаратным средствам для решения задачи
реализации взаимоисключений, когда говорили об использовании механизма запретаразрешения прерываний.
Многие вычислительные системы помимо этого имеют специальные команды
процессора, которые позволяют проверить и изменить значение машинного слова или
поменять местами значения двух машинных слов в памяти, выполняя эти действия как
атомарные операции. Рассмотрим, как концепции таких команд могут быть
использованы для реализации взаимоисключений.
О выполнении команды Test-and-Set, осуществляющей проверку значения логической
переменной с одновременной установкой ее значения в 1, можно думать как о
выполнении функции
int Test_and_Set (int *target)
{
int tmp = *target;
*target = 1;
return tmp;
}
С использованием этой атомарной команды мы можем модифицировать алгоритм для
переменной-замка так, чтобы он обеспечивал взаимоисключения
shared int lock = 0;
while (some condition)
{
while(Test_and_Set(&lock));
critical section
lock = 0;
remainder section
}
К сожалению, даже в таком виде полученный алгоритм не удовлетворяет условию
ограниченного ожидания для алгоритмов. Недостатком рассмотренного способа
взаимоисключения является необходимость постоянного опроса другими потоками,
требующими тот же ресурс, блокирующей переменой, когда один из потоков находится
в критической секции. На это будет бесполезно тратиться процессорное время. Для
устранения этого недостатка во многих ОС предусматриваются системные вызовы для
работы с критическими секциями.
Большинство алгоритмов, рассмотренных выше, хотя и являются корректными, но
достаточно громоздки и не обладают элегантностью. Более того, процедура ожидания
входа в критический участок включает в себя достаточно длительное вращение
процесса в пустом цикле, с потреблением вхолостую драгоценного времени
процессора. Существуют и другие серьезные недостатки у алгоритмов, построенных
средствами обычных языков программирования.
6.2. Семафоры и мониторы
Одним из первых механизмов, предложенных для синхронизации поведения процессов,
стали семафоры, концепцию которых описал Дейкстра (Dijkstra) в 1965 году. Семафор
представляет собой целую переменную, принимающую неотрицательные значения,
доступ любого процесса к которой, за исключением момента ее инициализации, может
осуществляться только через две атомарные операции: P (от датского слова proberen –
проверять) и V (от verhogen – увеличивать). Классическое определение этих операций
выглядит следующим образом:
P(S): пока S == 0 процесс блокируется;
S = S - 1;
V(S): S = S + 1;
Эта запись означает следующее: при выполнении операции P над семафором S сначала
проверяется его значение. Если оно больше 0, то из S вычитается 1. Если оно меньше
или равно 0, то процесс блокируется до тех пор, пока S не станет больше 0, после чего
из S вычитается 1. При выполнении операции V над семафором S к его значению просто
прибавляется 1.
Подобные переменные-семафоры могут с успехом использоваться для решения
различных задач организации взаимодействия процессов. В ряде языков
программирования они были непосредственно введены в синтаксис языка (например, в
ALGOL-68), в других случаях применяются через использование системных вызовов.
Соответствующая целая переменная располагается внутри адресного пространства
ядра операционной системы. Операционная система обеспечивает атомарность
операций P иV, используя, например, метод запрета прерываний на время выполнения
соответствующих системных вызовов. Если при выполнении операции P
заблокированными оказались несколько процессов, то порядок их разблокирования
может быть произвольным, например, FIFO.
Одной из типовых задач, требующих организации взаимодействия процессов, является
задача producer-consumer (производитель-потребитель). Пусть два процесса
обмениваются информацией через буфер ограниченного размера. Производитель
закладывает информацию в буфер, а потребитель извлекает ее оттуда. Грубо говоря,
на этом уровне деятельность потребителя и производителя можно описать следующим
образом:
Producer: while(1)
{
produce_item;
put_item;
}
Consumer:
while(1)
{
get_item;
consume_item;
}
Если буфер забит, то производитель должен ждать, пока в нем появится место, чтобы
положить туда новую порцию информации. Если буфер пуст, то потребитель должен
дожидаться нового сообщения. Как можно реализовать эти условия с помощью
семафоров? Возьмем три семафора empty, full и mutex. Семафор fullбудем
использовать для гарантии того, что потребитель будет ждать, пока в буфере появится
информация.
Семафор empty будем использовать для организации ожидания производителя при
заполненном буфере, а семафор mutex – для организации взаимоисключения на
критических участках, которыми являются действия put_item и get_item (операции
"положить информацию" и "взять информацию" не могут пересекаться, поскольку
возникнет опасность искажения информации). Тогда решение задачи выглядит так:
Semaphore mutex = 1;
Semaphore empty = N, где N – емкость буфера;
Semaphore full = 0;
Producer: while(1)
{
produce_item;
P(empty);
P(mutex);
put_item;
V(mutex);
V(full);
}
Consumer: while(1)
{
P(full);
P(mutex);
put_item;
V(mutex);
V(empty);
consume_item;
}
Легко убедиться, что это действительно корректное решение поставленной задачи.
Попутно заметим, что семафоры использовались здесь для достижения двух целей:
организации взаимоисключения на критическом участке и синхронизации скорости
работы процессов.
Хотя решение задачи producer-consumer с помощью семафоров выглядит достаточно
элегантно, программирование с их использованием требует повышенной осторожности
и внимания, чем, отчасти, напоминает программирование на языке ассемблера.
Допустим, что в рассмотренном примере мы случайно поменяли местами операции P,
сначала выполняя ее для семафора mutex, а уже затем для семафоров full и empty.
Допустим теперь, что потребитель, войдя в свой критический участок ( mutex сброшен),
обнаруживает, что буфер пуст. Он блокируется и начинает ждать появления
сообщений. Но производитель не может войти в критический участок для передачи
информации, так как тот заблокирован потребителем. Получаем тупиковую ситуацию.
В сложных программах произвести анализ правильности использования семафоров с
карандашом в руках становится очень непростым занятием. В то же время обычные
способы отладки программ зачастую не дают результата, поскольку возникновение
ошибок зависит от interleaving'а атомарных операций, и ошибки могут быть трудно
воспроизводимы. Для того чтобы облегчить труд программистов, в 1974 году Хоаром
(Hoare) был предложен механизм еще более высокого уровня, чем семафоры,
получивший название мониторов. Рассмотрим конструкцию, несколько отличающуюся
от оригинальной.
Мониторы представляют собой тип данных, который может быть с успехом внедрен в
объектно-ориентированные языки программирования. Монитор обладает своими
собственными переменными, определяющими его состояние. Значения этих
переменных извне монитора могут быть изменены только с помощью вызова функцийметодов, принадлежащих монитору. В свою очередь, эти функции-методы могут
использовать в своей работе только данные, находящиеся внутри монитора, и свои
параметры. На абстрактном уровне можно описать структуру монитора следующим
образом:
monitor monitor_name
{
описание переменных ;
void m1(...){...
}
void m2(...)
{
...
}
...
void mn(...)
{
...
}
{
блок инициализации внутренних переменных ;
}
Здесь функции m1,..., mn представляют собой функции-методы монитора, а блок
инициализации внутренних переменных содержит операции, которые выполняются
только один раз: при создании монитора или при самом первом вызове какой-либо
функции-метода до ее исполнения.
Важной особенностью мониторов является то, что в любой момент времени только один
процесс может быть активен, т. е. находиться в состоянии "готовность" или
"исполнение", внутри данного монитора. Поскольку мониторы представляют собой
особые конструкции языка программирования, компилятор может отличить вызов
функции, принадлежащей монитору, от вызовов других функций и обработать его
специальным образом, добавив к нему пролог и эпилог, реализующий
взаимоисключение. Так как обязанность конструирования механизма
взаимоисключений возложена на компилятор, а не на программиста, работа
программиста при использовании мониторов существенно упрощается, а вероятность
появления ошибок становится меньше.
Однако одних только взаимоисключений недостаточно для того, чтобы в полном объеме
реализовать решение задач, возникающих при взаимодействии процессов. Нам нужны
еще и средства организации очередности процессов, подобно
семафорам full и empty в предыдущем примере. Для этого в мониторах было введено
понятие условных переменных (condition variables), над которыми можно совершать две
операции – wait и signal, до некоторой степени похожие на операции P и Vнад
семафорами.
Если функция монитора не может выполняться дальше, пока не наступит некоторое
событие, она выполняет операцию wait над какой-либо условной переменной. При этом
процесс, выполнивший операцию wait, блокируется, становится неактивным, и другой
процесс получает возможность войти в монитор.
Когда ожидаемое событие происходит, другой процесс внутри функции-метода
совершает операцию signal над той же самой условной переменной. Это приводит к
пробуждению ранее заблокированного процесса, и он становится активным. Если
несколько процессов дожидались операции signal для этой переменной, то активным
становится только один из них. Что нужно предпринять для того, чтобы не оказалось
двух процессов, разбудившего и пробужденного, одновременно активных внутри
монитора? Хор предложил, чтобы пробужденный процесс подавлял исполнение
разбудившего процесса, пока он сам не покинет монитор. Несколько позже Хансен
(Hansen) предложил другой механизм: разбудивший процесс покидает монитор
немедленно после исполнения операции signal. Рассмотрим подход Хансена. Применим
концепцию мониторов к решению задачи "производитель-потребитель".
monitor ProducerConsumer
{
condition full, empty;
int count;
void put()
{
if(count == N) full.wait;
put_item;
count += 1;
if(count == 1) empty.signal;
}
void get()
{
if (count == 0) empty.wait;
get_item();
count -= 1;
if(count == N-1) full.signal;
}
{
count = 0;
}
}
Producer:
while(1)
{
produce_item;ProducerConsumer.put();
}
Consumer:
while(1)
{
ProducerConsumer.get();
consume_item;
}
Легко убедиться, что приведенный пример действительно решает поставленную задачу.
6.3. Взаимоблокировки (тупики)
Коффман и другие исследователи доказали, что для возникновения тупиковой
ситуации должны выполняться четыре условия [37].
1. Условие взаимного исключения. Каждый ресурс в данный момент или отдан
ровно одному процессу, или доступен.
2. Условие удерживания и ожидания. Процессы, в данный момент удерживающие
полученные ранее ресурсы, могут запрашивать новые ресурсы.
3. Условие отсутствия принудительной выгрузки ресурсов. У процесса нельзя
забрать принудительно ранее полученные ресурсы. Процесс, владеющий ими,
должен сам освободить ресурсы.
4. Условие циклического ожидания. Должна существовать круговая
последовательность из двух и более процессов, каждый из которых ждет доступа
к ресурсу, удерживаемому следующим членом последовательности.
Для того чтобы произошла взаимоблокировка, должны выполняться все эти четыре
условия. Если хотя бы одно отсутствует, тупиковая ситуация невозможна.
При столкновении с взаимоблокировками используются четыре стратегии.
•
•
•
•
Пренебрежение проблемой в целом.
Обнаружение и восстановление. Позволить взаимоблокировке произойти,
обнаружить ее и предпринять какие-либо действия.
Избегать тупиковых ситуаций с помощью аккуратного распределения ресурсов.
Предотвращать с помощью структурного опровержения одного из четырех
условий, необходимых для взаимоблокировки.
Если взаимоблокировки случаются в среднем раз в пять лет, а сбои ОС, компиляторов и
неисправности аппаратуры происходят в среднем один раз в неделю, то подходит
первая стратегия. К этому надо добавить, что большинство операционных систем
потенциально страдают от взаимоблокировок, которые не обнаруживаются, не говоря
уже об автоматическом выходе из тупика.
Вторая техника представляет собой обнаружение и восстановление. При использовании
этого метода система не пытается предотвратить попадания в тупиковые ситуации.
Вместо этого она позволяет произойти взаимоблокировке, старается определить, когда
это случилось, и затем совершает некоторые действия по возврату системы к
состоянию, имевшему место до того, как система попала в тупик.
Рассмотрим методы обнаружения взаимоблокировок.
Обнаружение взаимоблокировки при наличии одного ресурса каждого типа достаточно
просто. Для такой системы можно построить граф ресурсов и процессов, о котором уже
говорилось, и если в графе нет циклов, система в тупик не попала.
Например, пусть система из семи процессов (A, B, C, D, E, F, G) и шести
ресурсов (R, S, T, V,W, U) в некоторый момент соответствует следующему списку
[17].
1. Процесс A занимает ресурс R и хочет получить ресурс S.
Вопрос: заблокирована ли эта система, и если да, то какие процессы в этом участвуют?
Чтобы ответить на этот вопрос, нужно составить граф ресурсов и процессов (рис. 6.1).
Рис. 5.9. Граф ресурсов и процессов
Этот граф содержит цикл, указывающий, что процессы D, E, G заблокированы
(зрительно легко видно). Однако в этом случае в операционной системе необходима
реализация формального алгоритма, выявляющего тупики.
Рассмотрим возможность обнаружения взаимоблокировок при наличии нескольких
ресурсов каждого типа. Пусть имеется множество процессов P={P1, P2,... Pn},
всего n процессов, и множество ресурсов E={E1, E2,... Em}, где m – число классов
ресурсов. В любой момент времени некоторые из ресурсов могут быть заняты и,
соответственно, недоступны. Пусть А – вектор доступных ресурсов A={A1, A2,... Am}.
Очевидно, что Aj<= Ej, j = 1, 2, …, m.
Введем в рассмотрение две матрицы:
C={ci,j| i = 1, 2,…, n; j = 1, 2, …, m} – матрица текущего распределения
ресурсов, где ci,j – количество ресурсов j-ого класса, которые занимает процессPi ;
R={ri,j| i = 1, 2,…, n; j = 1,2, …, m} – матрица требуемых (запрашиваемых)
ресурсов, ri,j – количество ресурсов j-ого класса, которые хочет получить процесс Pi.
Справедливо m соотношений по ресурсам:
Алгоритм обнаружения взаимоблокировок основан на сравнении векторов доступных и
требуемых ресурсов. В исходном состоянии все процессы не маркированы (не
отмечены). По мере реализации алгоритма на процессы будет ставиться отметка,
служащая признаком того, что они могут закончить свою работу и, следовательно, не
находятся в тупике. После завершения алгоритма любой немаркированный процесс
находится в тупиковой ситуации.
Алгоритм обнаружения тупиков состоит из следующих шагов.
1. Отыскивается процесс Pi, для которого i-я строка матрицы R меньше вектора A,
т.е. Ri <= A, или rj,I
Тебе могут подойти лекции
А давай сэкономим
твое время?
твое время?
Дарим 500 рублей на первый заказ,
а ты выбери эксперта и расслабься
Включи камеру на своем телефоне и наведи на Qr-код.
Кампус Хаб бот откроется на устройстве
Не ищи – спроси
у ChatGPT!
у ChatGPT!
Боты в Telegram ответят на учебные вопросы, решат задачу или найдут литературу
Попробовать в Telegram
Оставляя свои контактные данные и нажимая «Попробовать в Telegram», я соглашаюсь пройти процедуру
регистрации на Платформе, принимаю условия
Пользовательского соглашения
и
Политики конфиденциальности
в целях заключения соглашения.
Пишешь реферат?
Попробуй нейросеть, напиши уникальный реферат
с реальными источниками за 5 минут
с реальными источниками за 5 минут
Методы взаимоисключений. Семафоры и мониторы. Взаимоблокировки (тупики). Системные вызовы. Синхронизирующие объекты ОС. Аппаратно-программные средства поддержки мультипрограммирования.
Хочу потратить еще 2 дня на работу и мне нужен только скопированный текст,
пришлите в ТГ