Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

2курсИБ(ОС) / lab5теория

.pdf
Скачиваний:
19
Добавлен:
07.06.2015
Размер:
520.63 Кб
Скачать

while (!done)

{

P(occupied); // ожидание

//код внутри критического участка V(occupied); // сигнал

//код за пределами критического участка

}

}

Операция P(S) над семафором выполняется следующим образом:

if S > 0

S = S – 1

else

вызывающий поток помещается в очередь ждущих потоков семафора

Поскольку инициализация была значением 1, то только один поток сможет войти в критический участок в любой момент времени.

Когда поток закончит свою работу в критическом участке, он вызовет операцию V(S)

if один или более потоков ожидают семафора S

возобновить работу следующего потока из очереди ждущих потоков

else

S = S + 1

Корректная реализация семафоров требует, чтобы P и V были неделимыми. Также обычно считают, что очередь потоков, заблокированных из-за ожидания семафора, обслуживается по FIFO.

Семафоры также могут применяться в целях синхронизации двух и более параллельных потоков между собой. Пусть T1 хочет знать о появлении определенного события. Пусть при этом T2 в состоянии обнаружить появление этого события.

Тогда T1 выполняет некоторые подготовительные команды, запрашивает операцию P для семафора, который был инициализирован с помощью нулевого значения, блокируя T1. В конце концов, T2 вызывает операцию V, чтобы уведомить о наступлении события другие потоки. Благодаря этому T1 получает возможность продолжить работу, хотя значение семафора остается равным нулю.

Даже если T1 еще не вошел в состояние ожидания, то механизм все равно сработает. Если T2 выставил значение семафора с 0 на 1, то, когда T1 вызовет операцию P, то значение семафора уменьшится до 0, а поток T1 продолжит работу, минуя состояние ожидания.

Еще один пример синхронизации — отношение «производитель — потребитель», которое обсуждали раньше. Оба потока имеют доступ к переменной sharedValue.

// семафоры, синхронизирующие доступ к sharedValue Semaphore valueProduced = new Semaphore(0); Semaphore valueConsumed = new Semaphore(1);

int sharedValue; // разделяемая переменная

startThreads(); // инициализация и запуск обоих потоков

Поток производителя Producer:

void main()

{

int nextValueProduced; // хранилище генерируемого значения

while (!done)

{

nextValueProduced = generateTheValue(); // генерация значения P(valueConsumed); // ожидаем, пока значение не будет считано sharedValue = nextValueProduced; //критический участок V(valueProduced); // сигнализируем о создании значения

}

}

Поток потребителя Consumer

void main()

{

int nextValueConsumed; // хранилище полученного значения

while(!done)

{

P(valueProduced); // ждем, пока значение не будет сгенерировано nextValueConsumed = sharedValue; // критический участок V(valueConsumed); //сигнализируем о считывании значения processTheValue(nextValueConsumed); // обработка значения

}

}

Также существуют еще считающие семафоры (counting semaphores), которые также называют общими семафорами (general). Они могут принимать неотрицательные целые значения, большие единицы. Такие семафоры особенно полезны, если некоторый ресурс выделяется из пула идентичных ресурсов. При инициализации подобного семафора в его счетчике указывается количество ресурсов в пуле. Каждая операция P уменьшает значение семафора на 1; каждая V — увеличение. Если делается попытка выполнить P, когда счетчик уже ноль, то потоку придется ждать, пока какой-нибудь ресурс не возвратится в пул.

Реализация семафоров.

Семафоры могут быть реализованы в пользовательских приложениях или ядре. Имея в своем распоряжении алгоритм Деккера или машинные инструкции testAndSet / swap, можно легко реализовать P и V с помощью активного ожидания. Но активное ожидание приводит к напрасной трате процессорных циклов.

Когда мы рассматривали переходы между состояниями процессов и потоков, то, в частности, видели, что потоки, запрашивающие операции ввода / вывода, добровольно блокируют себя до момента завершения операции ввода / вывода. Блокированный поток не может находиться в состоянии активного ожидания — он остается в спящем состоянии, пока система его не разбудит и он не перейдет в состояние готовности.

Операции над семафорами могут быть реализованы в ядре путем блокирования ждущих потоков, чтобы исключить активное ожидание. Семафор представлен защищенным значением и очередью, внутри которой потоки ожидают выполнения операций V. Когда поток пытается выполнить операцию P над семафором, равным нулю, поток освобождает процессор и блокирует себя в ожидании операции V. Система помещает значение в очередь потоков, ожидающих семафора. После этого система может выделить процессор следующему потоку из списка готовых. Любой ждущий семафор постепенно перемещается в начало очереди (FIFO).

Разумеется, для потоков, одновременно запрашивающих P и V операции над семафором, ядро обязано гарантировать взаимоисключающий доступ к семафору. В однопроцессорной

системе неделимость операций P и V можно обеспечить путем запрещения прерываний на время их выполнения (благо, они короткие). Однако тут следует действовать очень осторожно: запрещение прерываний может привести к снижению производительности или даже возникновению взаимоблокировок.

В ядре многопроцессорной системы можно выделить один процессор именно для того, чтобы управлять списком готовых потоков и определять, какой из процессоров будет выполнять тот или иной поток.

Другой подход (к реализации ядра многопроцессорной системы) подразумевает контроль доступа (с помощью активного ожидания) разделяемого списка готовых потоков.

Ядро распределенной операционной системы также может использовать отдельный процессор для диспетчеризации потоков, но, как правило, каждый процессор управляет своим собственным списком готовых потоков и, по сути, имеет свое собственное ядро. Когда поток передается на выполнение от одного процессора к другому, то и ответственность за управление потоком также переходит от одного ядра к другому.

Мы уже говорили, что использование в параллельных программах описанных примитивов (алгоритма Деккера, семафоров) — не самая удачная идея. Каждый поток может удерживать ресурс, ожидаемый другим потоком. О взаимоблокировке мы еще поговорим, а пока посвятим некоторое время параллельному программированию. Оно набирает популярность в связи с распространением многопроцессорных и распределенных систем. Для него требуются более сложные механизмы.

Монитор (monitor) — это механизм организации параллелелизма, который содержит как данные, так и процедуры, необходимые для реализации динамического распределения конкретного общего ресурса или группы общих ресурсов. Концепция монитора была предложена Дейкстрой (его фраза «тестирование может доказать наличие ошибок, но не их отсутствие»), затем ее переработал Брич Хансен и усовершенствовал Хоар.

Чтобы обеспечить выделение нужного ему ресурса, поток должен обратиться к процедуре входа в монитор (monitor entry routine). Необходимость входа в монитор в разные моменты возникает у разных потоков. Но вход в монитор находится под жестким контролем: здесь осуществляется взаимоисключение потоков, так что в каждый момент времени только одному потоку разрешается войти в монитор. Остальным приходится ждать.

Внутренние данные монитора могут быть либо глобальными (доступными всем процедурам монитора), либо локальными — доступными какой-то одной. Ко всем этим данным можно обращаться только внутри монитора. Такое структурное решение называют сокрытием или инкапсуляцией информации.

Обращаясь к процедуре входа в монитор, когда больше ни один поток не выполняется внутри монитора, данный поток блокирует монитор и осуществляет вход в него. Когда монитор заблокирован, остальные потоки, желающие войти, будут вынуждены ждать. Далее, когда поток войдет в монитор, может случиться так, что потоку придется ожидать освобождения некоторого ресурса (выделенного другому потоку). Т.к. в мониторе гарантируется взаимоисключение, потоку придется ждать вне монитора, чтобы другой поток мог вернуть ресурс в мониторе.

В конце концов поток, занимавший этот ресурс, обратится к монитору, чтобы возвратить ресурс системе. Процедура монитора при этом может принять уведомление о возвращении ресурса, а потом ожидать, пока не поступит запрос от другого потока. Если уже имеются потоки, ожидающие данный ресурс, процедура входа в монитор выполняет примитив оповещения. Если таких потоков нет, оповещение не вызывает никаких последствий.

Чтобы исключить бесконечное откладывание, монитор присваивает давно ждущим потокам более высокий приоритет.

Мониторы позволяют реализовать взаимоисключение и синхронизацию потоков. Так, в

отношении «производитель — потребитель», если производитель обнаруживает, что потребитель еще не успел считать значение из общего буфера, должен будет подождать вне монитора, управляющего разделяемым буфером, пока потребитель не прочтет содержимое буфера. Аналогично, потребитель, обнаружив, что буфер пуст, будет ждать вне монитора. При этом поток внутри монитора использует условные переменные (condition variables), чтобы дождаться наступления определенного условия вне монитора. Монитор связывает каждую условную переменную с определенной причиной, по которой поток может быть переведен в состояние ожидания. В связи с этим в команды ожидания и оповещения включаются имена условий:

wait (условная переменная) signal (условная переменная)

Условные переменные не похожи на обычные: когда определяется такая переменная, заводится очередь. Поток, выдавший команду ожидания, включается в эту очередь (и выходит из монитора), а поток, выдавший команду оповещения, позволяет ожидающему потоку выйти из очереди и войти в монитор. При обслуживании очереди обычно используется дисциплина FIFO, но иногда более полезны различные приоритетные схемы.

Перед тем как поток сможет повторно войти в монитор, из него должен будет выйти поток, выдавший команду оповещения. Брич Хансен заметил, что оператору return выхода из монитора часто предшествует множество операций оповещения, и предложил понятие монитора оповещения-и-выхода (signal-and-exit), в котором поток выходит из монитора сразу после выдачи команды оповещения. В последующих примерах рассматриваются именно такие мониторы. Также существует альтернатива — signal-and-continue (оповещение-и- продолжение), позволяющая потокам оповещать о скором освобождении монитора.

Предположим, что нескольким потокам необходим доступ к определенному ресурсу, который может быть использован в конкретный момент только одним потоком. Вот как может выглядеть монитор-распределитель ресурсов (метки monitorEntry используются, чтобы помечать процедуры монитора и отличать их от процедур, предназначенных для выполнения внутри монитора).

// однократная инициализация монитора

boolean inUse = false; // переменная состояния Condition available; // условная переменная

// опрос ресурса

monitorEntry void getResource()

{

if (inUse) // используется ли ресурс?

{

wait(available); // ожидание доступности ресурса

}

inUse = true; // показывает, что ресурс занят

}

// возвращение ресурса monitorEntry void returnResource()

{

inUse = false;

signal(available); // позволяет ждущему потоку продолжить работу

}

Ожидание доступности — поток выходит из монитора и помещается в очередь, связанную с условной переменной available.

Еще один пример монитора — кольцевой буфер (circular buffer), иногда называемый ограниченным буфером (bounded buffer). Пусть есть потребитель и производитель, работающие с разными (однако не сильно отличающимися «в среднем») скоростями, и нам нужно передавать данные от производителя потребителю, обеспечивая синхронизацию между ними.

Для реализации кольцевого буфера можно использовать массив. Производитель записывает данные в последовательные элементы массива, а потребитель их считывает в порядке записи (FIFO). При таком подходе производитель может опережать потребителя на несколько элементов.

В конце концов производитель заполнит все элементы массива. Если производителю понадобится записать еще данные, он должен будет начать их запись с начала массива (полагая, что потребитель их оттуда уже прочитал). В результате массив обрабатывается как замкнутое кольцо.

char circularBuffer[] = new char[BUFFER_SIZE}; //буфер int writerPosition = 0; // следующая ячейка для записи int readerPosition = 0; // следующая ячейка для чтения int occupiedSlots = 0; // количество ячеек с данными Condition hasData; // условная переменная

Condition hasSpace; //условная переменная

// процедура входа в монитор производителя для записи данных monitorEntry void putChar(char slotData)

{

//ожидание переменной-условия hasSpace, если буфер заполнен if (occupiedSlots == BUFFER_SIZE)

{

wait(hasSpace);

}

//запись символа в буфер

circularBuffer[writerPosition] = slotData; ++occupiedSlots;

writerPosition = (writerPosition + 1) % BUFFER_SIZE; signal(hasData); // оповещение о доступности данных

}

// процедура входа в монитор потребителя для чтения данных monitorEntry void getChar(outputParameter slotData)

{

//ожидание переменной-условия hasData, если буфер пуст if (occupiedSlots == 0)

{

wait(hasData);

}

//считывание символа в выходной параметр slotData slotData = circularBuffer[readPosition]; occupiedSlots--;

readerPosition = (readerPosition + 1) % BUFFER_SIZE; signal(hasSpace);

}

Если скорости производителя и потребителя отличаются сильно, эффекта не будет. Буфер быстро заполнится, и производитель вынужден будет ждать. Или, напротив, постоянно ждать придется потребителю.

ОС используют кольцевой буфер для управления спулингом. Пример — вывод данных на принтер. Поток, генерирующий данные для печати, часто называют спулером, а считывающий поток — деспулером. Здесь полезность (при большой разнице в скоростях) в том, что буфер может быть достаточно большим, а спулер быстро завершит работу, сгенерировав все необходимые данные (и буфер не будет более пополняться).

Вычислительные системы обычно оперируют двумя видами потоков — читателями (потребителями, которые читают данные) и писателями (производителями). Например, система предварительной продажи билетов (в основе БД) — будет произведено много обращений по запросам кассира-оператора, прежде чем пассажир выберет и купит билет на конкретный рейс.

Читатели не меняют содержимого базы данных, и такие потоки могут обращаться к БД одновременно. А вот писатели должны обладать монопольным доступом (конечно, не обязательно к базе, но к индивидуальной записи как минимум).

Впервые эту задачу сформулировали и решили Куртуа, Хейманс и Парнас. Мы рассмотрим алгоритм, предложенный Хоаром.

int readers = 0; // количество читателей

boolean writeLock = false; // true, если писатель записывает данные Condition canWrite; // условная переменная

Condition canRead; //условная переменная

// процедура входа в монитор перед началом чтения данных monitorEntry void beginRead()

{

//ожидание вне монитора, если писатель осуществляет запись, либо

//ожидает возможности начать запись

if (writeLock || queue(canWrite))

{

wait(canRead); // ждем, пока чтение не будет разрешено

}

++readers; // появляется еще один читатель

signal(canRead);

}

// процедура входа в монитор после чтения данных monitorEntry void endRead()

{

--readers;

// если больше читателей нет if (readers == 0)

{

signal(canWrite); // разрешаем работать писателю

}

}

// процедура входа в монитор перед началом записи данных monitorEntry void beginWrite()

{

// ожидание в случае выполнения чтения или записи данных if (readers > 0 || writeLock)

{

wait(canWrite); // ждем, пока запись не будет разрешена

}

writeLock = true;

signal(canRead);

}

// процедура входа в монитор по окончании записи данных monitorEntry void endWrite()

{

writeLock = false;

// если читатель ждет входа, оповестить об этом if (queue(canRead))

{

signal(canRead); // каскадное оповещение всех ждущих читателей

}

else // если нет читателей, оповещаем писателя

{

signal(canWrite);

}

}

Ближайшая цель — рассмотреть рабочее многопоточное java-приложение.

Каждому объекту в java назначается свой монитор. Мониторы представляют собой главный механизм взаимоисключения и синхронизации; также важным инструментом является директива synchronized. Мы также обсудим, чем отличаются мониторы в Java от мониторов на псевдокоде.

Когда поток намеревается выполнить метод, защищенный с помощью монитора на Java (метод, объявленный как synchronized), он должен сначала попасть в очередь входа (entry queue, иногда entry set) — группу потоков, ожидающих возможности войти в монитор. Если никто не претендует войти, поток может войти в монитор немедленно. Если же какой-нибудь поток уже находится внутри монитора, другим потокам придется ждать, пока монитор освободится.

Мониторы на Java относят к мониторам «оповещения-и-продолжения». Это значит, что поток, выполняемый внутри монитора, может оповещать другие потоки о предстоящем освобождении монитора, но при этом будет удерживать блокировку монитора до момента выхода из него. Выйти из монитора поток может либо ввиду ожидания условной переменной, либо завершив выполнение кода, защищенного монитором.

Поток, который выполняется внутри монитора и должен дожидаться условной переменной, выдает команду ожидания. Метод wait заставляет поток снять блокировку с монитора и переместиться в очередь ожидания (wait queue, wait set), в которой находятся потоки, ожидающие возможности вновь войти в монитор. Они остаются в очереди ожидания, пока не получат оповещение от другого потока. Поскольку условные переменные в Java задаются неявным образом, поток может получить оповещение, войти в монитор и обнаружить, что ожидаемое им условие не выполняется. Таким образом, может получиться, что поток получит оповещение несколько раз, прежде чем условие будет соблюдено.

Потоки выдают команды оповещения путем вызова методов notify() и notifyAll(). Первый

метод будит единственный поток в очереди ожидания. Для определения потока, который сможет войти в монитор следующим, применяются различные алгоритмы, зависящие от реализации JVM. Поэтому программист не может рассчитывать на определенную дисциплину очереди при вызове notify(). Еще одна неприятность состоит в том, что порядок удаления потоков из очередей входа и ожидания также может приводить к значительным задержкам (Дейтел говорит о ситуации бесконечно откладывания как о возможной). Поэтому, если доступом к монитору обладает более двух потоков, лучше использовать notifyAll().

Метод notifyAll() оповещает все потоки в очередях входа и ожидания. При пробуждении всех потоков планировщик самостоятельно определяет, какому потоку предоставить монитор. Планировщик обычно реализует дисциплину очереди, позволяющую избежать ситуации бесконечного откладывания.

Однако поскольку будут разбужены все потоки, это связано с большим количеством накладных расходов.

Итак, примером будет отношение «производитель — потребитель». Производители и потребители обращаются к общему разделяемому буферу, а за синхронизацию доступа отвечает монитор.

// class SynchronizedBuffer.java

public class SynchronizedBuffer implements Buffer {

private int buffer = -1; // доступ производителя и потребителя. private int occupiedBuffers = 0; // счетчик занятости буфера

public synchronized void set(int value){

String name = Thread.currentThread().getName();

// если нет пустых элементов, переведем поток в состояние

ожидания

while (occupiedBuffers == 1) { try {

System.err.println(name + “пытается начать запись”); displayState(“Буфер заполнен. ” + name + “

ожидает.”);

wait(); // ждем, пока буфер не будет опустошен } // try

catch (InterruptedException exception){ exception.printStackTrace();

}// catch

}//while

buffer = value;

++occupiedBuffers; // нельзя писать новые данные displayState(name + “ записывает ” + buffer); notify();

}

public synchronized int get() {

String name = Thread.currentThread().getName();

// если нет данных для чтения — в состояние ожидания while (occupiedBuffers == 0) {

try {

System.err.println(name + “пытается начать чтение”); displayState(“Буфер пуст. ” + name + “ ожидает.”); wait(); // ждем, пока буфер будет заполнен

} // try

catch (InterruptedException exception){

exception.printStackTrace();

}// catch

}//while

--occupiedBuffers; // нельзя писать новые данные displayState(name + “ считывает ” + buffer); notify();

return buffer;

}

public void displayState(String operation){

StringBuffer outputLine = new StringBuffer(operation); outputLine.setLength(40);

outputLine.append(buffer + “\t\t” + occupiedBuffers); System.err.println(outputLine); System.err.println();

}

}

Заметим, что displayState не объявлен как synchronized, хотя по факту таковым является.

// SharedBufferTest2.java

 

 

 

 

public class SharedBufferTest2 {

 

 

 

 

pulbic static void main(String[] args) {

=

new

SynchronizedBuffer

sharedLocation

SynchronizedBuffer();

 

 

 

 

StringBuffer columnHeads = new StringBuffer(“Операция”);

columnHeads.setLength(40);

буфера

\t\t

индикатор

columnHeads.append(“значение

занятости”);

 

 

 

 

System.err.println(columnHeads);

System.err.println(); sharedLocation.displayState(“Начальное состояние”);

Producer producer = new Producer(sharedLocation);

Consumer consumer = new Consumer(sharedLocation);

producer.start();

consumer.start();

}

}

Классы Producer и Consumer были рассмотрены выше.

Приведем пример организации кольцевого буфера на java. Однако нужно сделать такие модификации: генерировать в Producer значения от 11 до 20 (не от 1 до 4), а Consumer будет считывать 10 значений вместо 4.

//CircularBuffer.java

//CircularBuffer synchronizes access to an array of shared buffers.

public class CircularBuffer implements Buffer

{

//each array element is a buffer private int buffers[] = { -1, -1, -1 };

//occupiedBuffers maintains count of occupied buffers

private int occupiedBuffers = 0;

//variables that maintain read and write buffer locations private int readLocation = 0;

private int writeLocation = 0;

//place value into buffer

public synchronized void set( int value )

{

//get name of thread that called this method String name = Thread.currentThread().getName();

//while buffer full, place thread in waiting state while ( occupiedBuffers == buffers.length )

{

//output thread and buffer information, then wait

try

{

System.err.println( "\nAll buffers full. " + name + " waits." );

wait(); // wait until space is available } // end try

//if waiting thread interrupted, print stack trace catch ( InterruptedException exception )

{

exception.printStackTrace(); } // end catch

} // end while

//place value in writeLocation of buffers buffers[ writeLocation ] = value;

//output produced value

System.err.println( "\n" + name + " writes " + buffers[ writeLocation ] + " " );

//indicate that one more buffer is occupied ++occupiedBuffers;

//update writeLocation for future write operation writeLocation = ( writeLocation + 1 ) % buffers.length;

//display contents of shared buffers System.err.println( createStateOutput() );

notify(); // return a waiting thread to ready state } // end method set

// return value from buffer public synchronized int get()

{

//get name of thread that called this method String name = Thread.currentThread().getName();

//while buffer is empty, place thread in waiting state while ( occupiedBuffers == 0 )

{

//output thread and buffer information, then wait

try

Соседние файлы в папке 2курсИБ(ОС)