2курсИБ(ОС) / lab5теория
.pdfВторая версия программы на псевдокоде позволяет избежать жесткой синхронизации. В первой версии была только одна глобальная переменная, теперь заведем две:
boolean t1Inside = false; boolean t2Inside = false;
startThreads(); // инициализация и запуск обоих потоков
Поток T1: void main() {
while(!done)
{
while (t2Inside); // enterMutualExclusion t1Inside = true; // enterMutualExclusion
//код внутри критического участка t1Inside == false; // exitMutualExclusion
//код за пределами критического участка
}
}
Поток T2: void main() {
while(!done)
{
while (t1Inside); // enterMutualExclusion t2Inside = true; // enterMutualExclusion
//код внутри критического участка t2Inside == false; // exitMutualExclusion
//код за пределами критического участка
}
}
Теперь жесткой синхронизации не стало: поток T1 может входить в свой критический участок столько раз, сколько ему необходимо — пока значение t2Inside остается ложным. Кроме того, если поток T1 входит в критический участок, делая значение t1Inside истинным, то T2 переходит в режим активного ожидания. Когда T1 завершит свое выполнение в критическом участке, он изменит значение t2Inside на ложное.
Мы ушли от жесткой синхронизации, но не избавились от всех проблем. Действительно, рассмотрим следующую ситуацию. Пусть значения обеих переменных – t1Inside и t2Inside — ложно, и оба потока одновременно пытаются войти каждый в свой критический участок. Поток T1 проверил значение переменной t2Inside (оно ложное) и должен перейти к выполнению следующей команды. Однако (предположим), что в этот момент произошло его приоритетное вытеснение, и сделать переменную t1Inside истинной он не успел.
Теперь поток T2 начинает свое выполнение, обнаруживает, что значение t1Inside ложное, и входит в свой критический участок. Он выполняет какие-то действия, после чего происходит уже его приоритетное вытеснение системой.
Поток T1 начинает с того места, на котором он был приостановлен, — а именно, устанавливает значение t1Inside истинным и входит в свою критическую секцию. Таким образом, оба потока выполняются в своих критических участках, нарушая требование взаимоисключения.
Третья версия программы Во второй версии слабое место — то, что между моментом, когда поток, находящийся в
цикле ожидания, определяет, что он может идти дальше (циклы while), и моментом, когда
поток устанавливает свой флаг-признак входа в критический участок, может пройти достаточное время, чтобы система могла позволить другому потоку войти в его критический участок.
Попробуем устанавливать флаг перед выполнением цикла ожидания. Он будет свидетельствовать о том, что поток хочет войти в свой критический участок.
boolean t1WantsToEnter = false; boolean t2WantsToEnter = false;
startThreads(); // инициализация и запуск обоих потоков
Поток T1: void main() {
while(!done)
{
t1WantsToEnter = true; // enterMutualExclusion while (t2WantsToEnter); // enterMutualExclusion
//код внутри критического участка t1WantsToEnter == false; // exitMutualExclusion
//код за пределами критического участка
}
}
Поток T2: void main() {
while(!done)
{
t2WantsToEnter = true; // enterMutualExclusion while (t1WantsToEnter); // enterMutualExclusion
//код внутри критического участка t2WantsToEnter == false; // exitMutualExclusion
//код за пределами критического участка
}
}
Однако теперь мы можем оказаться в ситуации взаимоблокировки. Если каждый поток перед входом в цикл ожидания установит свой флаг и обнаружит, что флаг другого потока тоже установлен, то оба потока будут бесконечно оставаться в цикле while.
Четвертая версия призвана разорвать бесконечные циклы ожидания. Попробуем кратковременно устанавливать ложное значение флага.
boolean t1WantsToEnter = false; boolean t2WantsToEnter = false;
startThreads(); // инициализация и запуск обоих потоков
Поток T1: void main() {
while(!done)
{
t1WantsToEnter = true; // enterMutualExclusion while (t2WantsToEnter) // enterMutualExclusion
{
t1WantsToEnter = false; // enterMutualExclusion
// ожидание в течение небольшого случайного промежутка времени t1WantsToEnter = true;
}
// код внутри критического участка
t1WantsToEnter == false; // exitMutualExclusion // код за пределами критического участка
}
}
Поток T2: void main() {
while(!done)
{
t2WantsToEnter = true; // enterMutualExclusion while (t1WantsToEnter) // enterMutualExclusion
{
t2WantsToEnter = false; // enterMutualExclusion
// ожидание в течение небольшого случайного промежутка времени t2WantsToEnter = true;
}
//код внутри критического участка t2WantsToEnter == false; // exitMutualExclusion
//код за пределами критического участка
}
}
Этот подход позволяет другому потоку выйти из цикла ожидания. Так что проблемы взаимоисключения и взаимоблокировок решены, но остается еще бесконечное откладывание (indefinite postponement).
Может произойти следующее. Каждый поток установит истинное значение своего флага, проведет проверку в начале цикла, пойдет в тело (внутреннего) цикла, установит ложное значение своего флага, подождет в течение некоторого промежутка времени, установит истинное значение своего флага и повторит всю эту последовательность, начиная с проверки при входе в цикл. Условия проверки при этом будут оставаться истинными. А потоки будут выполняться «тандемом», друг за другом.
Теперь посмотрим, что же предложил сделать Деккер (на него ссылался Дейкстра в своей публикации 1965 г.). В его алгоритме также используются флаги-признаки, но при этом реализуется концепция «предпочтительного» потока (которому будет позволено войти в участок в случае конфликта).
int favoredThread = 1
boolean t1WantsToEnter = false; boolean t2WantsToEnter = false;
startThreads(); // инициализация и запуск обоих потоков
Поток T1: void main() {
while(!done)
{
t1WantsToEnter = true; while (t2WantsToEnter)
{
if (favoredThread == 2)
{
t1WantsToEnter = false;
while (favoredThread == 2); // активное ожидание t1WantsToEnter = true;
}// if
}// while
//код внутри критического участка favoredThread = 2;
t1WantsToEnter == false;
//код за пределами критического участка
}
}
Поток T2: void main() {
while(!done)
{
t2WantsToEnter = true; while (t1WantsToEnter)
{
if (favoredThread == 1)
{
t2WantsToEnter = false;
while (favoredThread == 1); // активное ожидание t2WantsToEnter = true;
}// if
}// while
//код внутри критического участка favoredThread = 1;
t2WantsToEnter == false;
//код за пределами критического участка
}
}
Как алгоритм Деккера исключает возможность бесконечного откладывания? Обратим внимание, что примитив enterMutualExclusion реализуется установкой значения флагапризнака в истину и внутренним циклом while, а примитив exitMutualExclusion реализуется установкой флагов «предпочтительного потока» и ложного значения флагапризнака.
Работа происходит следующим образом. Поток T1 (для определенности) уведомляет о своем желании войти в свой критический участок. После этого он переходит к циклу, в котором проверяет, не хочет ли также войти в свой критический участок поток T2. Если флаг потока T2 не установлен, T1 пропускает тело цикла ожидания и входит в свой критический участок. Если же T1 при выполнении цикла проверки (второго while) обнаруживает, что флаг T2 установлен, то возникает ситуация соревнования между потоками за право вхождения в свои критические участки. Поток T1 входит в тело цикла ожидания и анализирует значение переменной favoredThread. Эта переменная используется для разрешения конфликтов (когда оба потока одновременно хотят войти в свой критический участок). Если «избранным» потоком является T1, то он не заходит внутрь if и повторно выполняет цикл проверки, ожидая, когда T2 сбросит флаг (со временем он должен это сделать). Если же поток T1 выяснит, что преимуществом обладает T2, то он войдет в блок if и установит значение своего флага-признака в false, после чего блокируется в цикле ожидания (третье вложение while). Это будет продолжаться, пока T2 остается «предпочтительным». Сбрасывая свой флаг, T1 позволяет T2 войти в свой критический участок.
По прошествии некоторого времени T2 выйдет из своего критического участка, установит «предпочтительным» потоком T1, а значение своего флага-признака выставит в false. Теперь у T1 появляется возможность выйти из (самого) внутреннего цикла ожидания и установить свой флаг в истинное значение. После этого T1 выполняет внешний цикл проверки (второе вложение while). Если (недавно сброшенный) флаг-признак потока T2 имеет (по-прежнему) значение false, то T1 входит в свой критический участок. Если же T2 попытается войти в свой
критический участок (т. е. его флаг-признак будет иметь значение true), то T1 вновь придется войти в тело цикла ожидания и попытаться выполнить оператор if. Но мы помним, что теперь именно T1 является предпочтительным, и в блок if T1 не войдет, он будет многократно выполнять цикл проверки, пока T2 не сбросит свой флаг и не позволит войти T1 в свой критический участок.
Алгоритм Деккера гарантирует взаимоисключение, предупреждая возникновение взаимоблокировок и бесконечного откладывания. Однако строгое доказательство этого факта отнюдь не является простым.
Тем не менее попробуем пояснить, как такое происходит.
Пусть T1 выходит из (самого) внутреннего цикла активного ожидания, но не успевает установить значение своего флага-признака в true, будучи вытеснен. Предположим также, что поток T2 в это время попытается войти в свой критический участок. Когда T1 вновь получит процессор в свое распоряжение, он установит свой флаг-признак. Если поток T2 вновь попытается войти в свой критический участок (проверяя значение флага-признака потока T1), он должен будет сбросить свой флаг-признак и перейти в режим активного ожидания (самый внутренний цикл). Таким образом, поток T1 получит возможность войти в свой критический участок.
Долгое время алгоритм Деккера фактически являлся отражением состояния дел в сфере взаимоисключения с активным ожиданием. Но в 1981 г. Гарри Петерсон опубликовал новый, более простой алгоритм взаимоисключения с использованием активного ожидания.
int favoredThread = 1;
boolean t1WantsToEnter = false; boolean t2WantsToEnter = false;
startThreads(); // инициализация и запуск обоих потоков
Поток T1: void main() {
while(!done)
{
t1WantsToEnter = true; favoredThread = 2;
while (t2WantsToEnter && favoredThread == 2);
//код внутри критического участка t1WantsToEnter == false;
//код за пределами критического участка
}
}
Поток T2: void main() {
while(!done)
{
t2WantsToEnter = true; favoredThread = 1;
while (t1WantsToEnter && favoredThread == 1);
//код внутри критического участка t2WantsToEnter == false;
//код за пределами критического участка
}
}
Посмотрим, что происходит с потоком T1. Он устанавливает свой флаг-признак в true, а значение «предпочтительного» потока в 2, позволяя потоку T2 войти в свой критический
участок. После этого T1 пребывает в состоянии активного ожидания, пока флаг-признак T2 установлен в значение true, а сам T2 является предпочтительным потоком. Если одно из этих условий нарушится, поток T1 сможет войти в свой критический участок и выполнить необходимые действия. После них он сбросит свой флаг-признак в значение false, проинформировав тем самым систему о выходе из критического участка.
Пусть T1 будет вытеснен сразу после входа в собственный критический участок. Тогда поток T2 будет вынужден пребывать в состоянии активного ожидания, пока T1 не сбросит свой флаг.
Докажем, что алгоритм Петерсона гарантирует взаимоисключение.
Отметим, что значение favoredThread задается перед входом потока в цикл ожидания, причем таким образом, что «предпочтительным» считается другой поток, если оба потока пытаются войти в свои критические участки.
Флаги tjWantsToEnter контролируются исключительно потоками Tj.
Пусть T1 выполняется в своем критическом участке. Это значит, что t1WantsToEnter == true, favoredThread == 1 либо t2WantsToEnter == false.
Чтобы T2 мог войти в свой критический участок, нужно, чтобы либо t1WantsToEnter == false, либо favoredThread == 2, либо чтобы эти условия были выполнены одновременно.
Если t1WantsToEnter == false, T2 может войти в свой критический участок (флаг-признак T1 сброшен, значение favoredThread роли не играет). Однако, если поток T1 находится внутри своего критического участка, то его флаг-признак не должен быть сброшен. Таким образом, мы исключили первую и третью возможности.
Далее, для входа в критический участок T2 должен установить свой флаг-признак, а значение favoredThread в 1. Значение же 2 могло быть установлено только потоком T1 перед выполнением цикла взаимоисключения. В цикл ожидания мы попадаем только если t2WantsToEnter будет истинным, но — если это так — то исполнение кода T2 повлечет за собой установку favoredThread в 1. Рассмотрев разные комбинации вариантов, мы можем увидеть, что оба потока ни при каких условиях не могут одновременно оказаться исполняющими свои критические участки.
Алгоритм Петерсона застрахован от взаимоблокировок и бесконечного откладывания. Взаимоблокировка могла бы произойти, если бы оба потока могли одновременно оказаться в режиме активного ожидания в своих циклах while. Но поскольку переменная favoredThread принимает только значения 1 или 2 и не модифицируется внутри циклов ожидания, то проверка внутри while увенчается успехом лишь для одного из потоков. Бесконечное откладывание исключено тем, что каждый из потоков устанавливает в переменной favoredThread значение, указывающее на другой поток. Поскольку бесконечное откладывание возникает, когда один из потоков все время выходит из критического участка, а потом входит в него, заставляя другой поток находиться в состоянии активного ожидания, «перемена» значений предпочтительного потока достаточна, чтобы поток отдал управление другому потоку.
Программное решение проблемы реализации для n потоков первым предложил Дейкстра. В его алгоритме (исходной версии) существовала возможность бесконечного откладывания потоков. Кнут усовершенствовал алгоритм Дейкстры, однако некоторые потоки могли (потенциально) испытывать большие задержки (хотя бесконечного откладывания уже не было). Далее ряд исследователей предлагали более совершенные алгоритмы: Эйзенберг и Макгайр предложили решение, в котором любой поток гарантированно входит в свой критический участок за не более чем n-1 попытку. Лэсли Лэмпорт разработал алгоритмы, применимый, в частности, для распределенных систем обработки данных (его также называют алгоритмом кондитера). Его достоинство состояло в том, что потоки могли быстро входить в свои критические участки при отсутствии конкуренции (что на самом деле случается довольно часто).
Также следует упомянуть таких исследователей, как Бернс с соавторами, Андерсон и Ким, а
также ряд других. Однако большинство этих алгоритмов довольно сложны для понимания, используют большое количество разделяемых переменных и / или сложных циклов. Алгоритм Лэмпорта предлагает решение, основанное на «бытовом опыте», а также не требует, чтобы операции являлись атомарными. Так что рассмотрим это решение.
Представьте себе кафе-кондитерскую. Там есть один служащий, который выполняет заказы клиентов. В некоторых кафе посетителям выдаются номерки, по которым они и обслуживаются (по принципу — первым пришел, первым обслужили). Номерки присваиваются по возрастанию. После обслуживания очередного клиента следующим обслуживают того, у кого наименьшее значение номерка из присутствующих.
Конечно, Лэмпорт не «в точности» копирует ситуацию. Однако Вы можете представлять себе поток в качестве клиента, получившего номерок. И именно этот номерок определяет, когда поток сможет войти в свой критический участок. Взаимоисключение гарантировано сбросом значения номерка после выхода потока из критического участка. Алгоритм Лэмпорта позволяет потокам получать одинаковые номерки, но имеет механизм разрешения конфликтов, гарантирующий, что только один поток будет работать в своем критическом участке в текущий момент времени.
//массив, регистрирующий потоки с номерками boolean choosing[n];
//начальное значение номерка для каждого потока равно 0 int ticket[n];
startThreads(); // инициализация и запуск всех потоков
Поток Tj
void main() {
j = threadNumber(); // сохранение номерка текущего потока
while (!done)
{
// получение номерка
choosing[j] = true; // начинаем выбор номерка ticket[j] = maxValue(ticket) + 1;
choosing[j] = false; // заканчиваем выбор номерка
//определяем номер потока, который будет обслужен следующим,
//сравнивая текущий номерок с номерками других потоков
for (int i = 0; i < n; i++)
{
if (i == j)
{
continue; //проверять свой номерок не нужно
}
// активное ожидание, пока выбран i-ый поток while(choosing[i] != false);
//активное ожидание, пока текущий номерок является наименьшим while(ticket[i] != 0 & ticket[i] < ticket[j]);
//для равных номерков выбираем поток с меньшим номером потока if (ticket[i] == ticket[j] && i < j)
{
//ожидаем, пока i-ый поток не выйдет
//из критического участка
while(ticket[i] != 0); // активное ожидание
}
} // for
//код внутри критического участка ticket[j] = 0; // exitMutualExclusion
//код вне критического участка
}// while
}// окончание потока Tj
Попробуйте рассмотреть самостоятельно ситуации, когда потоки могли бы оказаться в состоянии взаимоблокировки и убедитесь, что алгоритм Лэмпорта исключает эти ситуации.
Конечно, наряду с программными средствами решения проблемы взаимоисключения существуют и аппаратные.
В однопроцессорной системе необходимость использования примитивов взаимоисключения связана с тем, в частности, что потоки могут обращаться к разделяемым данным и изменять их в некотором смысле случайным образом. Вытеснение потоков может происходить из-за прихода прерывания от таймера. Поэтому простейший способ — применить маскирование прерываний. Однако это не очень хорошая идея — ведь тогда поток, попавший, к примеру, в бесконечный цикл, может никогда не освободить процессор. Однако для какого-то доверенного кода можно сделать исключение — и этим пользуются и разработчики Windows, и разработчики Linux. Разумеется, речь идет о коде, на выполнение которого нужно немного времени.
Еще один «аппаратный подход» заключается в использовании команды Test-and-Set. Такая команда позволяет сделать потоку операцию «прочитать данные из памяти — обновить данные» атомарной. Подобные операции также часто называют атомарными операциями RMW (read – modify – write).
Команда test-and-set(a,b) работает так: читает значение логической переменной b, копирует его в a, затем устанавливает для b значение true — и все это в рамках одной операции.
boolean occupied = false;
startThreads(); // инициализация и запуск всех потоков
Поток T1
void main() {
boolean p1mustWait = true;
while (!done)
{
while (p1mustWait)
{
testAndSet(p1mustWait, occupied);
}
//код внутри критического участка p1mustWait = true;
occupied = false;
//код вне критического участка
}// while
}// поток T1 Поток T2 void main() {
boolean p2mustWait = true;
while (!done)
{
while (p2mustWait)
{
testAndSet(p2mustWait, occupied);
}
//код внутри критического участка p2mustWait = true;
occupied = false;
//код вне критического участка
}// while
}// поток T2
Приведенная здесь реализация гарантирует взаимоисключение, но не исключает бесконечного откладывания: есть вероятность, что при выходе из критического участка поток попадет в цикл, вызвав команду test-and-set прежде, чем другой поток сможет продолжить свое выполнение.
Команда test-and-set реализована не во всех архитектурах. Однако есть и другие команды, позволяющие обеспечить подобное поведение.
Весьма распространенной в программах является операция обмена значениями двух переменных. В «обычных» языках программирования на это требуется три команды. Но поскольку это часто повторяющийся фрагмент, в некоторых архитектурах предусмотрена специальная инструкция swap(a, b). Для ее выполнения используется временный регистр.
boolean occupied = false;
startThreads(); // инициализация и запуск всех потоков
Поток T1
void main() {
boolean p1mustWait = true;
while (!done)
{
do
{
swap(p1mustWait, occupied); } while (p1mustWait)
// код внутри критического участка p1mustWait = true;
occupied = false;
//код вне критического участка
}// while
}// поток T1
Поток T2
void main() {
|
boolean p2mustWait = true; |
не |
while (!done) |
|
{ |
do
{
swap(p2mustWait, occupied); } while (p2mustWait)
//код внутри критического участка p2mustWait = true;
occupied = false;
//код вне критического участка
}// while
}// поток T2
Как можно видеть, инструкции test-and-set и swap фактически взаимозаменяемые.
Еще одним механизмом реализации взаимоисключения являются семафоры, описанные Дейкстрой в своей статье о взаимодействии последовательных процессов (воплощены в ОС THE multiprogramming system). Если кратко, семафор — это защищенная переменная (Protected Variable), значение которой можно опрашивать и менять только с помощью специальных операций P (proberen, голл., проверять) и V (verhogen, голл., приращивать). Поток вызывает операцию P (также называемую операцией ожидания, wait), когда он намеревается войти в свой критический участок, и операцию V (операцию оповещения, signal), когда у него возникает необходимость выйти из критического участка.
Перед использованием семафора его необходимо инициализировать — в целях синхронизации. Инициализация задает значение защищенной переменной, показывающее, что ни один поток не выполняется внутри критического участка. Кроме того, создается очередь ссылок на защищенные семафором потоки, ожидающие входа в свои критические участки.
Заметим, что операции P и V — это абстракции, инкапсулирующие в себе детали реализации взаимоисключения. Эти операции могут быть применены к системе с любым количеством взаимодействующих между собой потоков.
Пример бинарного семафора.
// создание семафора, установка его значения равным 1 Semaphore occupied = new Semaphore(1);
startThreads(); // инициализация и запуск всех потоков
Поток Tj
void main() {
