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

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

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

Комбинированная модель

В Solaris, Win XP+ (в Win изначально было 1-к-1; сейчас хитрая схема с пулом) предпринимались попытки использовать гибридную модель, т. е. отношение «многие-ко- многим». Количество потоков уровня пользователя и количество потоков уровня ядра может не совпадать. Для реализации этой модели может быть использован пул потоков, что позволяет приложению задавать количество требуемых потоков уровня ядра. Второй момент (это упоминалось уже в примере про web-сервер) потоки существуют в пуле постоянно (их называют рабочими потоками, worker threads). Разработчики приложений могут использовать более тонкую настройку — т. е. связывать с одним потоком уровня ядра те потоки уровня пользователя, которые имеют низкий уровень параллелизма, а те, которые хорошо поддаются распараллеливанию, связывать каждый со «своим» потоком.

Еще один механизм, призванный оптимизировать исполнение потоков уровня пользователя, связанных с одним потоком уровня ядра, — это технология активации планировщика (sheduler activation; upcall). ОС создает поток активации планировщика для каждого процессора, выделяемого в распоряжение процесса, позволяя библиотекам уровня пользователя одновременно запускать различные потоки для выполнения их на разных процессорах. Если поток уровня пользователя заблокирован, ОС создает новый поток активации планировщика, чтобы уведомить библиотеку уровня пользователя об этом. Библиотека, в свою очередь, может перепланировать использование потоков. Такая технология позволяет предотвратить блокирование многопоточного процесса из-за блокирования одного из потоков. Вместе с тем нельзя не отметить, что технология нарушает принцип, согласно которому «более низкий» уровень обслуживает «более высокий», но не обращается к нему.

Минусы: усложнение архитектуры ПО, отсутствие стандартного способа реализации такой модели потоков. Та же Solaris прошла путь от модели многие-ко-многим (2.2), механизм активации планировщика (2.6) к простой, но хорошо масштабируемой схеме «один-к- одному» (ОС, к сожалению, фактически перестала развиваться и существовать).

Что касается Windows, модель с пулом рабочих потоков актуальна в текущих версиях.

Теперь рассмотрим некоторые моменты реализации потоков.

Как вы, вероятно, помните, сигналы прерывают выполнение процессов. После своего появления в ОС UNIX сигналы фактически стали стандартом взаимодействия процессов. Однако поддержка потоков в ОС на тот момент не была предусмотрена.

Если каждый процесс состоит из одного потока, то с доставкой сигналов проблем нет. Если же потоков много, то происходит следующее. В случае синхронного сигнала (который обычно инициируется процессором), сигнал доставляется потоку, который выполняется на процессоре, инициировавшем сигнал. А если сигнал асинхронный, то ОС нужно как-то идентифицировать получателя. В ОС UNIX и спецификации POSIX принято «заставлять» отправителя указывать ID процесса-получателя. Сделано это было в целях совместимости с приложениями, ранее написанными для UNIX.

Т.е. процессор, вырабатывая сигнал, должен указать ID процесса (не потока). А чтобы решить вопрос, кому из потоков доставить сигнал, спецификация POSIX предусматривает механизм маскирования сигналов. Маскирование сигнала позволяет потоку запретить прием сигналов определенного типа — т. е. принимать только те, которые его интересуют. При таком подходе ОС принимает сигналы, предназначенные для процесса, и передает их всем потокам этого процесса. Если в ОС принята модель для потоков «один-к-одному», то ОС может добавить маску сигналов каждому потоку ядра, который соответствует пользовательскому потоку. Если же реализована схема «многие-ко-многим», это сильно затрудняет задачу. Один из способов решения этой задачи (реализованный в Solaris 7)

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

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

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

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

Стандарт POSIX (Portable Operating System Interface for Computer Environment — интерфейс переносимой ОС) представляет собой целый набор стандартов для ОС, и в том числе определяет стандартный интерфейс взаимодействия между потоками и библиотекой. Потоки, которые используют API потоков POSIX, называются Pthread. Отметим, что POSIX не затрагивает детали реализации — Pthread-потоки могут быть реализованы как в ядре, так и в библиотеках уровня пользователя.

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

в Linux; Microsoft этому стандарту не соответствует.

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

Для создания дочерних процессов в Linux используется функция fork, способная создавать копию ресурсов родительской задачи (термин task обозначает в Linux и процессы, и потоки). Для поддержки потоков используется модифицированная версия fork() под названием clone().

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

Начиная с версии 2.6 ядра Linux была включена поддержка потоков, связанных отношением «один-к-одному», что позволяет поддерживать произвольное количество потоков в системе. Для задач в Linux можно нарисовать следующую диаграмму переходов.

Что происходит в Windows? (XP)

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

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

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

(процесса) виртуальному адресному пространству. Свои локальные данные потоки хранят в локальной памяти потока (thread local storage).

Кроме этого, потоки могут создавать нити (fiber), которые отличаются от потоков тем, что планирование расписания выполнения нитей осуществляет создавший их поток, а не планировщик. Нити выполняются в контексте создавших их потоков и (по аналогии с потоками) имеют свою локальную память (fiber local storage). Нити облегчают разработчикам перенос приложений, содержащих потоки уровня пользователя. Взаимодействие между потоками и нитями похоже на взаимодействие между процессами и потоками. Перед тем как поток сможет создать нить, Win API конвертирует его в нить, и далее все будет происходить по схеме многопоточного приложения, где роль потоков играют нити, созданные одним потоком (и разделяющие его контекст).

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

Жизненный цикл потока в Windows изображен на следующей диаграмме.

ЖЦ описать можно так: initialization → ready → standby → running. Поток выходит из состояния running по завершении работы, по окончании выделенного ему кванта времени, в случае приоритетного вытеснения, приостановки, необходимости вынужденного ожидания объекта. Когда выполнены все инструкции, поток переходит в состояние завершения terminated. Однако система не всегда немедленно удаляет поток в состоянии завершения. Удаление произойдет только тогда, когда все занимаемые им ресурсы будут освобождены.

Если поток подвергается приоритетному вытеснению или же истекает выделенный ему квант времени, он возвращается в состояние ready. Выполняющийся поток переходит в состояние waiting,если ему приходится ждать события (например, завершения операции ввода / вывода). Кроме того, другой поток (с достаточными правами доступа) или система может приостановить выполнение потока, переведя его в состояние ожидания до момента возобновления. По завершении ожидания поток может перейти либо в состояние готовности, либо в переходное (transition). Система помещает поток в переходное состояние, если данные потока в текущий момент недоступны (например, поток давно не выполнялся, и система воспользовалась занимаемой им памятью в других целях), но в остальном поток готов к выполнению. Когда будет загружен стек потока (обратно в память), поток перейдет в состояние ready. Если состояние потока определить невозможно, говорят, что он находится в состоянии неизвестности (unknown). Это может произойти в результате ошибки. .

Достаточно просто реализована поддержка потоков в Java.

Следующий код показывает, как чередуется выполнение двух потоков (прокомментировать код).

class ThreadTest extends Thread {

private int delay; private int repeat;

ThreadTest(String name, int d) { super(name);

delay = d;

repeat = (int)(Math.random() * 10);

}

@Override

public void run(){

for (int i = 0; i < repeat; i++) { try {

System.out.println(getName() + " засыпает"); Thread.sleep(delay);

}

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

}

System.out.println(getName() + " просыпается");

}

}

}

public class PingPongDemo {

public static void main(String[] args) { ThreadTest t1 = new ThreadTest("ping", 33); ThreadTest t2 = new ThreadTest("PONG", 100); t1.start();

t2.start();

}

}

Эти два потока, в общем-то ничего не делают и не взаимодействуют ни между собой, ни с какими-либо общими ресурсами. Они просто печатают по очереди слова на экране. Если

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

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

Например, предположим, что каждый поток имеет в своем составе вот такой фрагмент кода (Assembler)

LOAD mailCount ADD 1

STORE mailCount

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

Если у нас больше одного потока одновременно принимают почтовые сообщения, может сложиться ситуация, когда команды LOAD и ADD выполнены, а команда STORE отложена, поскольку истек квант времени, назначенный потоку. Таким образом может быть потеряна запись об одном или нескольких сообщениях (в зависимости от того, сколько раз изменится переменная mailCount до момента, пока поток, не успевший сохранить ее значение, получит процессор в свое распоряжение.

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

Рассмотрим еще один пример.

Достаточно часто в приложениях можно выделить отношение «производитель — потребитель» (produser – consumer). Один из «лежащих на поверхности» примеров — спулинг печати. Текстовый редактор сбрасывает данные в буфер (файл), после чего данные считывает принтер, выполняющий печать документа. Аналогично происходит запись на компакт-диск: сначала приложение помещает данные в буфер фиксированного размера, и по мере записи на диск этот буфер опустошается.

Напишем небольшую программу на Java, имитирующую именно это отношение. Как должно оно выглядеть в идеале?

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

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

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

файл Buffer.java

package threads_buffer;

public interface Buffer {

public void set (int value); public int get();

}

файл Producer.java package threads_buffer;

public class Producer extends Thread {

private Buffer sharedLocation;

public Producer(Buffer shared){ super("Producer"); sharedLocation = shared;

}

public void run(){

for (int count = 1; count <= 4; count++){ try {

Thread.sleep((int)(Math.random() * 3001)); sharedLocation.set(count);

}

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

}

}

System.out.println(getName() + " заканчивает генерирование "); System.out.println("Завершение потока " + getName() + ".");

}

}

файл Consumer.java

package threads_buffer;

public class Consumer extends Thread {

private Buffer sharedLocation;

public Consumer(Buffer shared){ super("Consumer"); sharedLocation = shared;

}

public void run(){

int sum = 0;

for (int count = 1; count <= 4; ++count){

try{

Thread.sleep((int)(Math.random() * 3001)); sum += sharedLocation.get();

}

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

}

}

System.out.println(getName() + " сумма прочитанных значений: " + sum); System.out.println("Завершение потока " + getName() + ".");

}

}

файл UnsynchronizedBuffer.java

package threads_buffer;

public class UnsynchronizedBuffer implements Buffer {

private int buffer = -1;

public void set(int value){ System.out.println(Thread.currentThread().getName() + " записывает " +

value);

buffer = value;

}

public int get() {

System.out.println(Thread.currentThread().getName() + " считывает " +

buffer);

return buffer;

}

}

файл SharedBufferTest.java

package threads_buffer;

public class SharedBufferTest {

public static void main(String[] args) {

Buffer sharedLocation = new UnsynchronizedBuffer(); Producer producer = new Producer(sharedLocation); Consumer consumer = new Consumer(sharedLocation);

producer.start();

consumer.start();

}

}

Вот, к примеру, выдача результата в одном из запусков программы:

Consumer считывает -1 Producer записывает 1 Producer записывает 2 Producer записывает 3 Consumer считывает 3 Consumer считывает 3 Producer записывает 4

Producer заканчивает генерирование Завершение потока Producer. Consumer считывает 4

Consumer сумма прочитанных значений: 9 Завершение потока Consumer.

Или еще один запуск:

Consumer считывает -1 Consumer считывает -1 Producer записывает 1 Consumer считывает 1 Consumer считывает 1

Consumer сумма прочитанных значений: 0 Завершение потока Consumer.

Producer записывает 2 Producer записывает 3 Producer записывает 4

Producer заканчивает генерирование Завершение потока Producer.

Заметим, конечно, что в приведенном примере принципиально важно, что потоки обращаются к разделяемым данным, которые подвергаются изменениям. Если бы данные не менялись, следовало бы позволить потокам работать параллельно «обычным образом».

Существует концепция критических участков (critical section; иногда говорят о критических областях — critical region). Когда поток обращается к разделяемым данным, говорят, что он находится в своем критическом участке. Чтобы предотвратить ошибки, продемонстрированные в примере выше, ОС должна гарантировать, что только один поток будет выполнять инструкции в своем критическом участке. Любой другой поток, который попытается войти в критический участок, будет переведен в режим ожидания.

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

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

Возвращаясь к примеру с приемом электронной почты, мы можем записать псевдокод,

демонстрирующий использование примитивов взаимоисключения: while (true) {

// получение почты — за пределами критического участка enterMutualExclusion() // вошли в критический участок

// приращение mailCount

exitMutualExclusion() //вышли из критического участка

}

Как могут быть реализованы примитивы взаимоисключения? Необходимо соблюдать следующие четыре ограничения.

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

применить к многопроцессорным системам).

2.Не должно выдвигаться никаких предположений об относительных скоростях выполнения асинхронных параллельных потоков.

3.Потоки, находящиеся вне своих критических участков, не должны препятствовать другим потокам входить в их критические участки.

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

Мы рассмотрим механизм взаимоисключения потоков, предложенную голландским математиком Деккером (Dekker's algorithm), усовершенствованный Э. Дейкстрой — реализацию для двух потоков. Кроме того, будут обсуждаться алгоритмы Петерсона и Лэмпорта.

Для записи будем использовать псевдокод (С-подобный синтаксис).

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

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

int threadNumber = 1;

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

void main() { while(!done)

{

while (threadNumber == 2); // enterMutualExclusion

//код внутри критического участка threadNumber == 2; // exitMutualExclusion

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

}

}

Поток T2: void main() {

while(!done)

{

while (threadNumber == 1); // enterMutualExclusion

//код внутри критического участка threadNumber == 1; // exitMutualExclusion

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

}

}

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

Второй, более важный недостаток, состоит в нарушении ограничения 3, сформулированного выше. Например, поток T1 должен первым входить в свой критический участок, поскольку установленное значение threadNumber = 1. Кроме того, более быстрый поток будет вынужден замедляться до скорости более медленного. В этом случае используют термин жесткая или пошаговая синхронизация. Иногда она оправдана — например, в программе с буфером, приведенной выше. Однако эта жесткость обусловлена в первую очередь размером буфера; в реальных программах размер буфера позволяет помещать в него довольно много элементов, и потоки могут работать с разными скоростями.

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