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

u_course

.pdf
Скачиваний:
39
Добавлен:
04.06.2015
Размер:
1.87 Mб
Скачать

Средства разработки параллельных программм

41

полнена вовсе (например, произошло обращение по несуществующему адресу), при этом система должна уметь обработать возникшую ошибку.

2.Значения обрабатываются следующей цепочкой неделимых операций: 1) если необходимо, значения считываются из памяти в регистры; 2) к ним применяются операции; 3) если необходимо, результаты записываются в память. Несмотря на то, что каждая из перечисленных операций не делима, вся цепочка таковой не является, то есть прерывание работы потока может произойти между любыми из этих трех действий.

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

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

Рассмотрим простейшую многопоточную программу:

int y=0, z=0, x=0;

co x = y + z; // y=1; z=2; oc;

Потоки выполняются параллельно и независимо, последовательность их выполнения не определена, более того, почти в любой момент поток может быть приостановлен, а затем продолжен. Предположим, что выражение x = y + z может быть реализовано загрузкой y в регистр с последующим прибавлением к нему z. В таком случае между загрузкой и прибавлением поток может быть прерван и выполнено любое множество операций второго потока. Все это приводит к непредсказуемому поведению программы, а именно, значение переменной x может быть 0, 1, 2 или 3.

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

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

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

Средства разработки параллельных программм

42

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

Определим общую нотацию синхронизации следующим оператором:

< await (B) S; >

Булево выражение B задает условие задержки потока до момента, когда B станет истинным; список последовательных операторов S выполняется неделимым образом при достижении B = TRUE.

Пример:

< await (s > 0) s = s-1; >

Поток задерживается до возникновения условия положительности s, затем уменьшает s на 1, при этом гарантируется, что в момент уменьшения условие положительности s остается истинным

В рамках данного оператора взаимное исключение реализуется сокращенной записью: < S; >. Условное ожидание реализуется так же естественно:

< await (B); >

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

ВЗАИМНОЕИСКЛЮЧЕНИЕ. ЗАДАЧАКРИТИЧЕСКОЙСЕКЦИИ

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

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

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

Предположим, что

n потоков многократно выполняют сначала критическую, а потом не

критическую секцию кода;

любой поток, вошедший в КС, когда-нибудь ее покидает;

поток завершается только вне КС.

Средства разработки параллельных программм

43

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

process CS [i=1 to n] { while (true) {

протокол входа; критическая секция; протокол выхода; некритическая секция;

}

}

Протоколы входа и выхода должны удовлетворять следующим свойст-

вам.

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

2.Отсутствие взаимной блокировки. Если несколько потоков пыта-

ются войти в критическую секцию, и критическая секция свободна, то хотя бы один поток это осуществит.

3.Отсутствие излишних задержек. Если поток пытается войти в кри-

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

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

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

Решения с активным ожиданием

Самыми низкоуровневыми механизмами реализации КС являются алгоритмы с активным ожиданием, использующие флаги.

Рассмотрим реализацию протоколов входа / выхода КС с помощью блокировок (флагов). Пусть lock – логическая переменная: lock=true, когда один из потоков находится в КС и lock=false в противном случае. Тогда круп-

номодульное решение задачи КС выглядит следующим образом:

bool lock=false; # разделяемая переменная

process CS [i=1 to n] { while (true) {

<await (!lock) lock=true;> # протокол входа

Средства разработки параллельных программм

44

критическая секция; lock=false; # протокол выхода

некритическая секция;

}

}

В угловых скобках указано действие, которое должно быть выполнено неделимым образом, т.е. на уровне инструкций процессора (или каким-либо другим способом) должна быть предоставлена возможность выполнения проверки переменной и установки ее значения, а между этими действиями не возможно выполнение любой другой инструкции. Например, подходящей инструкцией процессора является действие проверить-установить (test-set – TS). Результат действия инструкции описывает функция:

bool TS(bool lock) {

<bool initial=lock; # сохранить начальное значение lock lock=true;

return initial; > # возвратить начальное значение lock

}

Реализация такой инструкции возможна практически на любом процессоре (пример – инструкция инкремента с установкой кода условия, показывающего знак результата). Мелкомодульное решение задачи КС на основе инструкции TS() выглядит следующим образом:

bool lock=false; # разделяемая переменная

process CS [i=1 to n] { while (true) {

while (TS(lock) ) skip; # протокол входа

критическая секция; lock=false; # протокол выхода

некритическая секция;

}

}

Недостатком этого алгоритма является то, что lock все время перезаписывается, отсюда возникают конфликты при обращении к памяти. Не исключается и бесконечно долгое откладывание потока (другие потоки все время успешно входят в КС), т.е. алгоритм не обеспечивает выполнение условия

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

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

Средства разработки параллельных программм

45

Рассмотрим для примера реализацию алгоритма поликлиники для n потоков. Пусть turn[1:n] – массив целых чисел с начальными значениями 0. Протокол входа в КС следующий: поток CS[i] сначала присваивает переменной turn[i] значение, которое на 1 больше, чем максимальное среди текущих значений элементов массива turn. Затем поток ожидает, пока его значение turn[i] не станет наименьшим среди ненулевых значений массива turn. Выходя из КС поток CS[i] присваивает turn[i] значение 0. Таким образом, крупномодульное решение алгоритма поликлиники следующее [17]:

int turn[1:n]=([n] 0); # разделяемый массив очереди, инициализированный нулями

process CS [i=1 to n] { while (true) {

#начало протокола входа

<turn[i]=max(turn[1:n])+1;> # см. пояснение 1 for [j=1 to n st j!=i]

<await (turn[j] = = 0 or turn[i] < turn[j]);> # см. пояснение 2

#конец протокола входа

критическая секция; turn[i]=0; # протокол выхода

некритическая секция;

}

}

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

1.Требование неделимости операции поиска максимального элемента можно ослабить, если поток будет предупреждать о своем намерении войти в КС, присваивая своей переменной turn[i] значение 1. В результате несколько потоков могут получить одинаковые номера. Такие потоки можно, например, упорядочить по номеру потока i.

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

В результате мелкомодульное решение задачи КС с помощью алгорит-

ма поликлиники можно записать следующим образом:

Средства разработки параллельных программм

46

int turn[1:n]=([n] 0); # разделяемый массив очереди process CS [i=1 to n] {

while (true) {

# начало протокола входа

turn[i]=1; turn[i]=max(turn[1:n])+1; # см. пояснение 1 for [j=1 to n st j!=i]

while (turn[j] != 0 and

((turn[i] > turn[j] or turn[i] = = turn[j]) and (i>j))) skip; # см. пояснение 2

# конец протокола входа

критическая секция;

turn[i]=0; # протокол выхода

некритическая секция;

}

}

Поясним две использованные неочевидные нотации. Тело цикла

for [j=1 to n st j!=i]

выполняется для всех значений счетчика, кроме значения i. Оператор skip означает бездействие (пустое действие).

БАРЬЕРНАЯСИНХРОНИЗАЦИЯ

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

process Woker [i=1 to n] { while (true) {

код решения задачи i;

ожидание завершения всех n задач; # барьер

}

}

Путей мелкомодульной реализации барьера существует много. Рассмотрим некоторые из них.

Барьер с разделяемым счетчиком

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

Средства разработки параллельных программм

47

Такой алгоритм требует неделимой операции увеличения счетчика на 1. Во многих процессорах есть команда «извлечь и сложить» (FA, Fetch and Add). С учетом этой инструкции мелкомодульная реализация барьера с разделяемым счетчиком может быть записана следующим образом:

int count = 0;

 

process Woker [i= 1 to n] {

 

while (true) {

 

код реализации задачи i;

 

FA(count,1) ;

# < count = count +1;>

while (count != n) skip;

# <await (count == n); >

}

 

}

 

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

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

Барьерная синхронизация с помощью флагов

Для избавления от необходимости читать одну и ту же переменную всеми потоками следует завести массив целочисленных счетчиков – по одному на каждый поток:

int arrive[1:n]=([n] 0);

Каждый поток рапортует о подходе к барьеру установкой своего флага arrive[i] в единицу, тогда сигналом того, что к барьеру подошли все потокы является, например, выполнение условия

arrive[1]+arrive[2]+ … + arrive[n] == n;

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

int continue[1:n]=([n] 0);

сигнализирующего о прохождении барьера всеми потоками, а так же управляющего этим массивом потока Coordinator. Таким образом, рабочие потоки, выполнив свою часть итерации и подойдя к барьеру, сигнализируют об этом через переменную arrive[i], после чего приостанавливаются и ожидают сигнала от потока Coordinator через переменную continue[i]. Управляющий поток сначала

Средства разработки параллельных программм

48

ожидает пока все arrive[i] не станут равными 1 (все потокы подойдут к барьеру), затем сигнализирует об этом, изменяя массив continue.

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

Переменные arrive[1:n] и continue[1:n] называются флагами. Алгоритм должен предусматривать не только корректную установку флагов, но и их сброс после прохождения барьера. Таким образом, для предотвращения гонок для синхронизации барьером необходимо два набора флагов.

Правило синхронизации с помощью флагов:

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

2)флаг нельзя устанавливать до тех пор, пока не известно, что он сброшен.

Крупномодульное решение барьерной синхронизации с помощью флагов с управляющим потоком:

int arrive[1:n]=([n] 0), continue[1:n]=([n] 0);

process Woker [i=1 to n] { while (true) {

код решения задачи i; arrive[i] = 1;

<await (continue[i] == 1);> # возможна реализация с помощью цикла while continue[i] = 0;

}

}

process Coordinator { while (true) {

for [i=1 to n] {

<await (arrive[i] == 1);> # возможна реализация с помощью цикла while arrive[i] = 0;

}

for [i=1 to n] continue[i] = 1;

}

}

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

Средства разработки параллельных программм

49

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

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

Если количество рабочих потоков кратно двум, то потоки можно синхронизировать симметричным барьером-бабочкой (рис. 2.3), который состоит из log2n уровней (n – количество потоков). На уровне s синхронизируются потоки, находящиеся на расстоянии 2s-1 друг от друга. Если количество потоков не кратно двум, то можно провести синхронизацию барьером с распространением (рис. 2.4), в котором так же на каждом s-ом уровне синхронизируются процессы на расстоянии 2s-1 друг от друга, но последовательность синхронизации несколько иная.

поток

1

2

3

4

5

6

7

8

Уровень 1

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Уровень 2

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Уровень

3

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Рис. 2.3. Барьер-бабочка для восьми потоков

Крупномодульное решение симметричной барьерной синхронизации с помощью флагов в общем виде может быть записано следующим образом

[17].

Средства разработки параллельных программм

 

 

 

50

поток

1

2

3

4

5

6

Уровень 1

 

 

 

 

 

Уровень

2

 

 

 

 

 

Уровень 3

Рис. 2.4. Барьер с распространением для шести потоков

int arrive[1:n]=([n] 0);

int num_level =… # количество уровней синхронизации,

# определяется количеством потоков и схемой синхронизации

process Woker [i=1 to n] {

int s; # счетчик уровней прохождения барьера while (true) {

код решения задачи i;

# код барьера

for [s=1 to num_level]

{

(1)<await (arrive[i] == 1);> # проверка: сброшен ли свой флаг,

#установленный для другого потока

#на предыдущей итерации

(2)arrive[i] = 1; # установка своего флага

определить номер потока j для синхронизации на уровне s

(3)<await (arrive[j] == 1);> # ожидание прибытия к барьеру j-ого потока,

#который выставит флаг для этого потока

(4)arrive[j] = 0; # сброс флага j-ого потока

}

}

}

Приведенный алгоритм не совсем верен. Если потоки работают с разной скоростью, то велика вероятность того, что синхронизируются потоки, проходящие разные уровни s барьера, то есть не исключено состояние гонок. Для исправления ситуации можно, например, на каждом уровне синхронизации использовать свои флаги, т.е. использовать двумерный массив флагов arrive[1:n][1:num_level]. Другим решением является использование флагов, которые могут принимать значения большие единицы. Тогда вместо строк (1)-(2) поток, приходя на новый уровень, увеличивает свой флаг на единицу

arrive[i] = arrive[i] +1;

а затем ожидает прибытия потока j на тот же уровень, т.е. (3)-(4) следует заменить оператором ожидания следующего вида:

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]