Глава 5. Алгоритмы синхронизации
Предыдущая глава | Программа курса | Следующая глава
В предыдущей главе мы говорили о внешних проблемах кооперации, связанных с организацией взаимодействия процессов со стороны операционной системы. Допустим, что надежная связь процессов организована, и они умеют обмениваться информацией. Нужно ли нам предпринимать еще какие-либо действия для организации правильного решения задачи взаимодействующими процессами? Нужно ли нам изменять их внутреннее поведение? Выяснению этих вопросов и посвящена настоящая глава.
5.1. Interleaving, race condition и взаимоисключения
Давайте временно отвлечемся от операционных систем, процессов и нитей исполнения и поговорим просто об некоторых “активностях”. Под активностями мы будем понимать последовательное выполнение некоторых действий, направленных на достижение определенной цели. Активности могут иметь место в программном и техническом обеспечении, в обычной деятельности людей и животных. Мы будем разбивать активности на некоторые неделимые или атомарные операции. Например, активность “приготовление бутерброда” можно разбить на следующие атомарные операции:
Неделимые операции могут иметь некоторые внутренние невидимые действия (взять батон хлеба в левую руку, взять нож в правую руку, собственно произвести отрезание). Мы же называем их неделимыми потому, что считаем одним целым, выполняемыми за раз, без прерывания деятельности.
P: | a b c |
Q: | d e f, |
где a, b, c, d, e, f атомарные операции. При последовательном выполнении активностей мы получаем следующую последовательность атомарных действий:
PQ: a b c d e f
Что произойдет при исполнении этих активностей псевдопараллельно, в режиме разделения времени? Активности могут расслоиться на неделимые операции с различным их чередованием, то есть может произойти то, что на английском языке принято называть словом interleaving. Возможные варианты чередования:
а b c d e f
a b d c e f
a b d e c f
a b d e f c
a d b c e f
......
d e f a b c
То есть атомарные операции активностей могут чередоваться всевозможными способами с сохранением своего порядка расположения внутри активностей. Так как псевдопараллельное выполнение двух активностей приводит к чередованию их неделимых операций, то результат псевдопараллельного выполнения может отличаться от результата последовательного выполнения. Рассмотрим пример. Пусть у нас есть две активности P и Q, состоящие из двух атомарных операций каждая:
P: | x=2 | Q: | x=3 |
y=x-1 | y=x+1 |
Что мы получим в результате их псевдопараллельного выполнения, если переменные x и y являются общими для активностей? Легко видеть, что возможны четыре разных набора значений для пары (x, y): (3, 4), (2, 1), (2, 3) и (3, 2). Мы будем говорить, что набор активностей (например, программ) детерминирован, если всякий раз при псевдопараллельном исполнении для одного и того же набора входных данных он дает одинаковые выходные данные. В противном случае он недетерминирован. Выше приведен пример недетерминированного набора программ. Понятно, что детерминированный набор активностей можно безбоязненно выполнять в режиме разделения времени. Для недетерминированного набора такое исполнение нежелательно.
Можно ли до получения результатов, заранее, определить является ли набор активностей детерминированным или нет? Для этого существуют достаточные условия Бернстайна. Изложим их применительно к программам с разделяемыми переменными.
Введем наборы входных и выходных переменных программы. Для каждой атомарной операции наборы входных и выходных переменных — это наборы переменных, которые атомарная операция считывает и записывает. Набор входных переменных программы R(P) (R от слова read) суть объединение наборов входных переменных для всех ее неделимых действий. Аналогично, набор выходных переменных программы W(P) (W от слова write) суть объединение наборов выходных переменных для всех ее неделимых действий. Например, для программы
P: | x=u+v |
y=x*w |
получаем R(P) = {u, v, x, w}, W(P) = {x, y}. Заметим, что переменная x присутствует как в R(P), так и в W(P).
Теперь сформулируем условия Бернстайна.
Если для двух данных активностей P и Q:
тогда выполнение P и Q детерминировано.
Если эти условия не соблюдены, возможно, что параллельное выполнение P и Q детерминировано, но возможно, что и нет.
Случай двух активностей естественным образом обобщается на их большее количество.
Условия Бернстайна информативны, но слишком жестки. По сути дела они требуют практически невзаимодействующих процессов. А нам хотелось бы, чтобы детерминированный набор образовывали активности, совместно использующие информацию и обменивающиеся ей. Для этого нам необходимо ограничить число возможных чередований атомарных операций, исключив некоторые чередования с помощью механизмов синхронизации выполнения программ, обеспечив тем самым упорядоченный доступ программ к некоторым данным.
Про недетерминированный набор программ (и активностей вообще) говорят, что он имеет race condition (состояние гонки, состояние состязания). В приведенном выше примере процессы состязаются за вычисление значений переменных x и y.
Задачу упорядоченного доступа к разделяемым данным (устранение race condition), в том случае, если нам не важна его очередность, можно решить, если обеспечить каждому процессу эксклюзивное право доступа к этим данным. Каждый процесс, обращающийся к разделяемым ресурсам, исключает для всех других процессов возможность одновременного с ним общения с этими ресурсами, если это может привести к недетерминированному поведению набора процессов. Такой прием называется взаимоисключением (mutual exclusion). Если очередность доступа к разделяемым ресурсам важна для получения правильных результатов, то одними взаимоисключеньями уже не обойтись.
Важным понятием при изучении способов синхронизации процессов является понятие критической секции (critical section) программы. Критическая секция - это часть программы, исполнение которой может привести к возникновению race condition. Чтобы исключить эффект гонок по отношению к некоторому ресурсу, необходимо организовать работу так, чтобы в каждый момент времени только один процесс мог находиться в своей критической секции, связанной с этим ресурсом. Иными словами, необходимо обеспечить реализацию взаимоисключения для критических секций программ. Реализация взаимоисключения для критических секций программ с практической точки зрения означает, что по отношению к другим процессам, участвующим во взаимодействии, критическая секция начинает выполняться как атомарная операция. Давайте рассмотрим следующий пример, в котором псевдопараллельные взаимодействующие процессы представлены действиями различных студентов:
Время |
Студент 1 |
Студент 2 |
Студент 3 |
17-05 |
Приходит в комнату |
||
17-07 |
Обнаруживает, что хлеба нет |
||
17-09 |
Уходит в магазин |
||
17-11 |
Приходит в комнату |
||
17-13 |
Обнаруживает, что хлеба нет |
||
17-15 |
Уходит в магазин |
||
17-17 |
Приходит в комнату |
||
17-19 |
Обнаруживает, что хлеба нет |
||
17-21 |
Уходит в магазин |
||
17-23 |
Приходит в магазин |
||
17-25 |
Покупает 2 батона на всех |
||
17-27 |
Уходит из магазина |
||
17-29 |
Приходит в магазин |
||
17-31 |
Покупает 2 батона на всех |
||
17-33 |
Уходит из магазина |
||
17-35 |
Приходит в магазин |
||
17-37 |
Покупает 2 батона на всех |
||
17-39 |
Уходит из магазина |
||
17-41 |
Возвращается в комнату |
||
17-43 |
|||
17-45 |
|||
17-47 |
Возвращается в комнату |
||
17-49 |
|||
17-51 |
|||
17-53 |
Возвращается в комнату |
Здесь критический участок для каждого процесса — от операции “Обнаруживает, что хлеба нет” до операции “Возвращается в комнату” включительно. В результате отсутствия взаимоисключения мы из ситуации “Нет хлеба” попадаем в ситуацию “Слишком много хлеба”. Если бы этот критический участок выполнялся как атомарная операция — “Достает 2 батона хлеба”, то проблема образования излишков была бы снята.
Время |
Студент 1 |
Студент 2 |
Студент 3 |
17-05 |
Приходит в комнату |
||
17-07 |
Достает два батона хлеба |
||
17-43 |
Приходит в комнату |
||
17-47 |
Приходит в комнату |
Сделать процесс добывания хлеба атомарной операцией можно было бы следующим образом: перед началом этого процесса закрыть дверь изнутри на засов и уходить добывать хлеб через окно, а по окончании процесса вернуться в комнату через окно и отодвинуть засов. Тогда пока один студент добывает хлеб, все остальные находятся в состоянии ожидания под дверью.
Итак, для решения задачи необходимо, чтобы в том случае, когда процесс находится в своем критическом участке, другие процессы не могли войти в свои критические участки. Мы видим, что критический участок должен сопровождаться прологом (entry section) – “закрыть дверь изнутри на засов” - и эпилогом (exit section) – “отодвинуть засов”, которые не имеют отношения к активности одиночного процесса. Во время выполнения пролога процесс должен, в частности, получить разрешение на вход в критический участок, а во время выполнения эпилога - сообщить другим процессам, что он покинул критическую секцию.
В общем случае структура процесса, участвующего во взаимодействии, может быть представлена следующим образом:
Здесь под remainder section понимаются все атомарные операции, не входящие в критическую секцию.
Оставшаяся часть этой главы посвящена различным способам программной организации пролога и эпилога критического участка.
5.3. Программные алгоритмы организации
взаимодействия процессов
5.3.1. Требования, предъявляемые к алгоритмам
Организация взаимоисключения для критических участков, конечно, позволит избежать возникновения race condition, но не является достаточной для правильной и эффективной параллельной работы кооперативных процессов. Сформулируем пять условий, которые должны выполняться для хорошего программного алгоритма организации взаимодействия процессов, имеющих критические участки, если они могут проходить их в произвольном порядке:
Задача должна быть решена чисто программным способом на обычной машине, не имеющей специальных команд взаимоисключения. При этом предполагается, что основные инструкции языка программирования (такие примитивные инструкции как load, store, test) являются атомарными операциями.
Не должно существовать никаких предположений об относительных скоростях выполняющихся процессов или числе процессоров, на которых они исполняются.
Если процесс Pi исполняется в своем критическом участке, то не существует никаких других процессов, которые исполняются в своих соответствующих критических секциях. Это условие получило название условия взаимоисключения (mutual exclusion).
Процессы, которые находятся вне своих критических участков и не собираются входить в них, не могут препятствовать другим процессам входить в их собственные критические участки. Если нет процессов в критических секциях, и имеются процессы, желающие войти в них, то только те процессы, которые не исполняются в remainder section, должны принимать решение о том, какой процесс войдет в свою критическую секцию. Такое решение не должно приниматься бесконечно долго. Это условие получило название условия прогресса (progress).
Не должно возникать бесконечного ожидания для входа процесса в свой критический участок. От того момента, когда процесс запросил разрешение на вход в критическую секцию, и до того момента, когда он это разрешение получил, другие процессы могут пройти через свои критические участки лишь ограниченное число раз. Это условие получило название условия ограниченного ожидания (bound waiting).
Надо заметить, что описание соответствующего алгоритма в нашем случае означает описание способа организации пролога и эпилога для критической секции.
Наиболее простым решением поставленной задачи является следующая организация пролога и эпилога:
Поскольку, выход процесса из состояния исполнения без его завершения осуществляется по прерыванию, то внутри критической секции никто не может вмешаться в его работу. Однако такое решение чревато далеко идущими последствиями, поскольку разрешает процессу пользователя разрешать и запрещать прерывания во всей вычислительной системе. Допустим, что в результате ошибки или злого умысла пользователь запретил прерывания в системе и зациклил или завершил свой процесс. Без перезагрузки системы в такой ситуации не обойтись.
Тем не менее, запрет и разрешение прерываний часто применяются как пролог и эпилог к критическим секциям внутри самой операционной системы, например, при обновлении содержимого PCB.
В качестве следующей попытки решения задачи для пользовательских процессов рассмотрим другое предложение. Возьмем некоторую переменную, доступную всем процессам, и положим ее начальное значение равным 0. Процесс может войти в критическую секцию только тогда, когда значение этой переменной-замка равно 0, одновременно изменяя ее значение на 1 — закрывая замок. При выходе из критической секции процесс сбрасывает ее значение в 0 — замок открывается (как в случае с покупкой хлеба студентами в разделе 5.2.).
shared int lock = 0;
while (some condition) {
К сожалению, внимательное изучение показывает, что такое решение, не удовлетворяет условию взаимоисключения, так как действие while(lock); lock = 1; не является атомарным. Допустим, что процесс P0 протестировал значение переменной lock и принял решение двигаться дальше. В этот момент, еще до присваивания переменной lock значения 1, планировщик передал управление процессу P1. Он тоже изучает содержимое переменной lock и тоже принимает решение войти в критический участок. Мы получаем два процесса одновременно выполняющих свои критические секции.
Попробуем решить задачу сначала для двух процессов. Очередной подход будет также использовать общую для них обоих переменную с начальным значением 0. Только теперь она будет играть не роль замка для критического участка, а явно указывать, кто может следующим войти в него. Для i-го процесса это выглядит так:
Легко видеть, что взаимоисключение гарантируется, процессы входят в критическую секцию строго по очереди: P0, P1, P0, P1, P0, ... Но наш алгоритм не удовлетворяет условию прогресса. Например, если значение turn равно 1 и процесс P0 готов войти в критический участок, он не может сделать этого, даже если процесс P1 находится в remainder section.
Недостаток предыдущего алгоритма заключается в том, что процессы ничего не знают о состоянии друг друга в текущий момент времени. Давайте попробуем исправить эту ситуацию. Пусть два наши процесса имеют разделяемый массив флагов готовности входа процессов в критический участок
Когда i-й процесс готов войти в критическую секцию, он присваивает элементу массива ready[i] значение равное 1. После выхода из критической секции он, естественно, сбрасывает это значение в 0. Процесс не входит в критическую секцию, если другой процесс уже готов ко входу в критическую секцию или находится в ней.
Полученный алгоритм обеспечивает взаимоисключение, позволяет процессу, готовому к входу в критический участок, войти в него сразу после завершения эпилога в другом процессе, но все равно нарушает условие прогресса. Пусть процессы практически одновременно подошли к выполнению пролога. После выполнения присваивания ready[0] = 1 планировщик передал процессор от процесса 0 процессу 1, который также выполнил присваивание ready[1] = 1. После этого оба процесса бесконечно долго ждут друг друга на входе в критическую секцию. Возникает ситуация, которую принято называть тупиковой (deadlock).
Первое решение проблемы, удовлетворяющее всем требованиям и использующее идеи ранее рассмотренных алгоритмов, было предложено датским математиком Деккером (Dekker). В 1981 году Петерсон (Peterson) предложил более изящное решение. Пусть оба процесса имеют доступ к массиву флагов готовности и к переменной очередности.
При исполнении пролога критической секции процесс Pi заявляет о своей готовности выполнить критический участок и одновременно предлагает другому процессу приступить к его выполнению. Если оба процесса подошли к прологу практически одновременно, то они оба объявят о своей готовности и предложат выполняться друг другу. При этом одно из предложений всегда последует после другого. Тем самым работу в критическом участке продолжит процесс, которому было сделано последнее предложение.
Давайте докажем, что все пять наших требований к алгоритму действительно удовлетворяются.
Удовлетворение требований 1 и 2 очевидно.
Докажем выполнение условия взамоисключения методом от противного. Пусть оба процесса одновременно оказались внутри своих критических секций. Заметим, что процесс Pi может войти в критическую секцию, только если ready[1-i] == 0 или turn == i. Заметим также, что если оба процесса одновременно выполняют свои критические секции, то значения флагов готовности для обоих процессов совпадают и равны 1. Могли ли оба процесса войти в критические секции из состояния, когда они оба одновременно находились в процессе выполнения цикла while? Нет, так как в этом случае переменная turn должна была бы одновременно иметь значения 0 и 1 (когда оба процесса выполняют цикл, то значения переменных измениться не могут). Пусть процесс P0 первым вошел в критический участок, тогда процесс P1 должен был выполнить перед вхождением в цикл while, по крайней мере, один предваряющий оператор (turn = 0;). Однако после этого он не может выйти из цикла до окончания критического участка процесса P0, так как при входе в цикл ready[0] == 1 и turn == 0, и эти значения не могут измениться до тех пор, пока процесс P0 не покинет свой критический участок. Мы получили противоречие. Следовательно, имеет место взаимоисключение.
Докажем выполнение условия прогресса. Возьмем, без ограничения общности, процесс P0. Заметим, что он не может войти в свою критическую секцию только при совместном выполнении условий ready[1] == 1 и turn == 1. Если процесс P1 не готов к выполнению критического участка, то ready[1] == 0 и процесс P0 может осуществить вход. Если процесс P1 готов к выполнению критического участка, то ready[1] == 1 и переменная turn имеет значение либо 0, либо 1, позволяя либо процессу P0, либо процессу P1 начать выполнение критической секции. Если процесс P1 завершил выполнение критического участка, то он сбросит свой флаг готовности ready[1] == 0 , разрешая процессу P0 приступить к выполнению критической работы. Таким образом, условие прогресса выполняется.
Отсюда же вытекает выполнение условия ограниченного ожидания. Так как в процессе ожидания разрешения на вход процесс P0 не изменяет значения переменных, то он сможет начать исполнение своего критического участка после не более чем одного прохода по критической секции процесса P1.
5.3.7. Алгоритм булочной (Bakery algorithm)
Алгоритм Петерсона дает нам решение задачи
корректной организации взаимодействия двух процессов. Давайте рассмотрим теперь
соответствующий алгоритм для n взаимодействующих процессов, который получил название
алгоритм булочной, хотя применительно к нашим условиям его следовало бы скорее
назвать алгоритм регистратуры в поликлинике. Основная его идея выглядит так. Каждый
вновь прибывающий клиент (он же процесс) получает талончик на обслуживание с номером.
Клиент с наименьшим номером на талончике обслуживается следующим. К сожалению,
из-за неатомарности операции вычисления следующего номера алгоритм булочной не
гарантирует, что у всех процессов будут талончики с разными номерами. В случае
равенства номеров на талончиках у двух или более клиентов первым обслуживается
клиент с меньшим значением имени (имена можно сравнивать в лексикографическом
порядке). Разделяемые структуры данных для алгоритма — это два массива
shared int number[n];
Изначально элементы этих массивов инициируются значениями false и 0 соответственно. Введем следующие обозначения
(a,b) < (c,d),
если a < c
или если a == c
и b < d
max(a0,
a1, ...., an) —
это число k
такое, что k >=
ai
для всех i = 0, ...,n
Структура процесса Pi для алгоритма булочной приведена ниже
Доказательство того, что этот алгоритм удовлетворяет условиям 1 – 5, проделайте самостоятельно в качестве упражнения.
5.4. Аппаратная поддержка взаимоисключений
Наличие аппаратной поддержки взаимоисключений позволяет упростить алгоритмы и повысить их эффективность точно так же, как это происходит и в других областях программирования. Мы уже обращались к общепринятому hardware для решения задачи реализации взаимоисключений, когда говорили об использовании механизма запрета/разрешения прерываний.
Многие вычислительные системы помимо этого имеют специальные команды процессора, которые позволяют проверить и изменить значение машинного слова или поменять местами значения двух машинных слов в памяти, выполняя эти действия как атомарные операции. Давайте обсудим, как концепции таких команд могут быть использованы для реализации взаимоисключений.
5.4.1. Команда Test-and-Set (Проверить и присвоить 1)
О выполнении команды Test-and-Set, осуществляющей проверку значения логической переменной с одновременной установкой ее значения в 1, можно думать, как о выполнении функции
С использованием этой атомарной команды мы можем модифицировать наш алгоритм для переменной-замка, так чтобы он обеспечивал взаимоисключения
К сожалению, даже в таком виде полученный алгоритм не удовлетворяет условию ограниченного ожидания для алгоритмов. Подумайте, как нужно его изменить для соблюдения всех условий.
5.4.2. Команда Swap (Обменять значения)
Выполнение команды Swap, обменивающей два значения, находящихся в памяти, можно проиллюстрировать следующей функцией
Применяя атомарную команду
Swap, мы можем
реализовать предыдущий алгоритм, введя дополнительную логическую переменную
key локальную для каждого процесса:
Последовательное выполнение некоторых действий, направленных на достижение определенной цели, называется активностью. Активности состоят из атомарных операций, выполняемых неразрывно, как единичное целое. При исполнении нескольких активностей в псевдопараллельном режиме атомарные операции различных активностей могут перемешиваться между собой с соблюдением порядка следования внутри активностей. Это явление получило название interleaving (чередование). Если результаты выполнения нескольких активностей не зависят от варианта чередования, то этот набор активностей называется детерминированным. В противном случае он носит название недетерминированного. Существует достаточное условие Бернстайна для определения детерминированности набора активностей, но оно накладывает очень жесткие ограничения на набор, требуя практически не взаимодействующих активностей. Про недетерминированный набор активностей говорят, что он имеет race condition (условие гонки, состязания). Устранение race condition возможно при ограничении допустимых вариантов чередований атомарных операций с помощью синхронизации поведения активностей. Участки активностей, выполнение которых может привести к race condition, называют критическими участками. Необходимым условием для устранения race condition является организация взаимоисключения на критических участках: внутри соответствующих критических участков не может одновременно находиться более одной активности.
Для эффективных программных алгоритмов устранения race condition помимо условия взаимоисключения требуется выполнение следующих условий: алгоритмы не используют специальных команд процессора для организации взаимоисключений, алгоритмы ничего не знают о скоростях выполнения процессов, алгоритмы удовлетворяют условиям прогресса и ограниченного ожидания. Всем этим условиям удовлетворяют алгоритм Петерсона для двух процессов и алгоритм булочной для нескольких процессов.
Применение специальных команд процессора, выполняющих ряд действий как атомарную операцию, - Test-And-Set, Swap – позволяет существенно упростить алгоритмы, удовлетворяющие оставшимся условиям.