поддерживаю выше заданые вопросы
|
Операционные системы - аспекты параллелизма
3.4.4. Использование специальных команд ЦП
Задача взаимного исключения была поставлено достаточно давно, и очень скоро была осознана ее важность и необходимость эффективного решения. Для поддержки решения были добавлены специальные команды в набор инструкций ЦП (различные в различны архитектурах). Мы рассмотрим решения, основанные на использовании команд Test&Set и Swap.
Принцип работы команды Test&Set может быть описан в виде следующей функции:
int Test_and_Set (int *target){ int tmp = *target; *target = 1; return tmp; }
Однако, поскольку Test&Set является одной командой ЦП, ее выполнение не может быть прервано, то есть управление не может быть передано другому потоку до ее завершения. Благодаря этому, можно предложить следующее решение задачи взаимного исключения.
int lock = 0; /* Признак блокировки критического ресурса */ while( true ) { while( Test_and_Set( &lock ) ) ; CSi(); lock = 0; NCSi(); }
Решение основано на использовании для каждого критического ресурса специального признака блокировки (в примере – переменная lock). Значение lock = 0 указывает, что ресурс в настоящий момент не используется, значение lock = 1 – что ресурс свободен. Проверка условия работает корректно, поскольку невозможно прервать выполнение инструкции.
Решение с использованием команды swap использует тот же подход.
Описание работы команды swap в виде функции.
void Swap( int *a, int *b ){ int tmp = *a; *a = *b; *b = tmp; }
Решение задачи взаимного исключения с использованием команды swap.
int lock = 0; /* Признак блокировки критического ресурса */ int key; /* Вспомогательная переменная */ while( true ) { key = 1; do{ Swap( &lock, &key ); }while (key); CSi(); lock = 0; NCSi(); }
Использование других команд ЦП, добавленных для поддержки решения задачи взаимного исключения, основано на том же самом принципе: создается дополнительная переменная, выполняющая роль признака блокировки, и специализированная команда позволяет одновременно изменить значение данной переменной и проанализировать предыдущее значение.
Таким образом, решение задачи взаимного исключения принимает следующий вид (см. рис. 3.5).
При работе с признаком занятости (блокировки) ресурса поток использует "активное ожидания", то есть выполняет цикл, ожидая освобождения признака блокировки. Очевидно, существует множество случаев, когда данное решение неэффективно. Например, если при наличии одного процессора какой-то поток находится в состоянии активного ожидания, поток, захвативший признак блокировки, не может продолжать свое исполнение и никакой другой поток тоже не может. Таким образом, поток выполняет бесполезную работу до того момента, пока планировщик не вытеснит его с ЦП.
Данной проблемы можно избежать, если после выполнения каждой итерации цикла добровольно отдавать ЦП системе для передачи другому потоку (используя вызов yield() в UNIX, Sleep() в Win32); в этом случае при каждом получении ЦП поток выполняет только одну итерацию. Еще более эффективное решение инкапсулирует все операции над признаками блокировки в функции, и очередная итерация выполняется только при вызове функции, которая освобождает соответствующий признак блокировки.
Еще один важный момент заключается в том, что на уровне ЦП специализированные инструкции выполняются в виде последовательных операций на различных стадиях конвейера, то есть на уровне ЦП данные операции не являются атомарными. В случае однопроцессорной системы это не имеет значения, поскольку нельзя прервать выполнение потока в середине инструкции, а в многопроцессорной системе с общей памятью или в системе с NUMA-архитектурой (Non-Uniform Memory Access) при одновременном выполнении на двух процессорах специализированной команды для одного и того же признака блокировки мы получим тот же самый эффект гонок, который ранее наблюдали для потоков. Для решения данной проблемы существуют специальные команды или префиксы команд, которые позволяют временно блокировать доступ других процессоров к общей памяти.
Несмотря на затратность активного ожидание существуют ситуации, когда его использование в изначальном виде наиболее эффективно.
- Предположим, несколько потоков выполняются на многопроцессорной системе, и один из них установил признак блокировки и работает в критической секции. Если другому потоку, выполняющемуся на другом процессоре, потребовался тот же самый критический ресурс, и известно, что критические секции невелики, то вызов yield()/Sleep() может обслуживаться дольше, чем активное ожидание освобождения ресурса.
- Можно использовать активное ожидание в случае, если ожидается асинхронное изменение состояние признака блокировки. Например, если мы ожидаем прихода данных от внешнего устройства (сетевого контроллера, звукового контроллера и т.д.) и признак блокировки изменяется модулем, отвечающим за обслуживание событий от соответствующего устройства.
В прикладной программе можно самостоятельно реализовать механизм решения задачи взаимного исключения, используя один из приведенных алгоритмов. Однако обычно в операционной системе или специализированной библиотеки на основе низкоуровневых алгоритмов реализуются высокоуровневые средства синхронизации, которые и предоставляются прикладным программам.
3.5. Высокоуровневые механизмы синхронизации
Существует три высокоуровневых механизма синхронизации:
- Семафоры
- Мониторы
- Синхронные сообщения
Известно, что он взаимозаменяемые, то есть с помощью любого из них можно реализовать оба остальных.
3.5.1. Семафоры
Семафоры – примитивы синхронизации более высокого уровня абстракции, чем признаки блокировки; предложены Дийкстрой (Dijkstra) в 1968 г. в качестве компонента операционной системы THE.
Семафор – это целая неотрицательная переменная sem, для которой определены 2 атомарные операции: P(sem) и V(sem).
- P(sem) (wait/down) – ожидает выполнения условия sem > 0, затем уменьшает sem на 1 и возвращает управление
- V(sem) (signal/up) увеличивает sem на 1
Атомарность операций обеспечивается на уровне реализации (посредством использования одного из алгоритмов, описанных выше). Возможны различные реализации семафоров, ниже мы предложим одну из них, в которой каждый семафор имеет очередь потоков, ожидающих увеличения его значения. В этом случае операции P и Vмогут быть реализованы следующим образом.
Определение структуры семафора.
typedef struct{ int value; list<thread> L; } semaphore;
При вызове потоком P(sem) если sem>0 (семафор "свободен"), его значение уменьшается на 1, и выполнение потока продолжается; если sem<=0 (семафор "занят"), поток переводится в состояние ожидания, помещается в очередь, соответствующую данному семафору, и запускается какой-либо другой готовый к выполнению поток.
P(S){ S.value = S.value - 1; if( S.value < 0 ){ add this thread to S.L; block; }
При вызове потоком V(sem) если очередь потоков, ассоциированная с данным семафором, не пуста – один из потоков, ожидающих увеличения значения семафора, разблокируется и переводится в состояние "готов к выполнению"; поток, вызвавший V (sem), продолжает свое выполнение. В случае если нет потоков, ожидающих освобождения семафора, значение семафора увеличивается.
V(S){ S.value = S.value + 1; if( S.value <= 0 ){ remove a thread T from S.L; wakeup T; }
Значение поля структуры семафора S.value в данной реализации может принимать отрицательные значения, их нужно интерпретировать следующим образом: значение семафора равно 0, число потоков, ожидающих увеличения значения семафора, равно |S|.
Семафоры обычно используются для организации согласованного доступа к критическим ресурсам. Можно выделить два вида семафоров.
- Двоичный семафор может принимать значения 0 и 1, инициализируется значением 1. Используется для обеспечения эксклюзивного доступа к ресурсу (например, при работе в критической секции).
- Счетный семафор может принимать значения от 0 до N и представляет ресурсы, состоящие из нескольких однородных элементов, где N – число единиц ресурса.
Обычно инициализируется значением N и позволяет потокам исполняться, пока есть неиспользуемые элементы.
Семафоры первого вида часто оформляются в виде специального механизма синхронизации – мьютексов (mutex, от mutual exclusion). Мьютекс – двоичный семафор, обычно используемый для организации согласованного доступа к неделимому общему ресурсу. Мьютекс может принимать значения 1 (свободен) и 0 (занят). Над мьютексами определены следующие операции:
- acquire(mutex) – уменьшить (занять) мьютекс;
- release(mutex) – увеличить (освободить) мьютекс;
- tryacquire(mutex) – часто реализуемая неблокирующая операция, выполняющая попытку уменьшить (занять) мьютекс, и всегда сразу возвращающая управление.
Мьютексы в конкретных реализациях могут иметь дополнительные полезные свойства.
1. Запоминание потока-владельца.
Мьютекс, запоминающий владельца (то есть поток, успешно выполнивший операции acquire() или tryacquire()), освобождается только после вызова операции release() потоком-владельцем. Такие мьютексы удобно использовать для классической организации эксклюзивного доступа к разделяемому ресурсу, включающей следующие шаги: (1) захватить мьютекс, защищающий ресурс; (2) использовать ресурс; (3) освободить мьютекс, защищающий ресурс.
Мьютекс, не запоминающий поток-владелец, может быть освобожден другим потоком – это позволяет установить блокировку критического ресурса в одной части программы, для того чтобы использовать занятый ресурс позднее, возможно, в другом потоке. Использование таких мьютексов необходимо, например, при выполнении асинхронных операций, когда один поток подготавливает ресурсы для выполнения операции и инициирует ее, а обработку завершения операции осуществляет другой поток.
2. Рекурсивность.
Поток может многократно захватить рекурсивный мьютекс (вызывать aquire()); для освобождения мьютекса поток должен соответствующее число раз вызвать release(). Рекурсивные мьютексы удобны в ситуациях, когда имеется множество функций, работающих с одним и тем же критическим ресурсом, каждая функция выполняет последовательность операций "захватить мьютекс, защищающий ресурс", "использовать ресурс", "освободить мьютекс", и функции вызывают друг друга.
3. Наследование приоритета.
Предположим, имеются три потока: высокоприоритетный, среднеприоритетный и низкоприоритетный, и некоторый мьютекс захвачен низкоприоритетным потоком. Если высокоприоритетному потоку потребуется данный мьютекс, он выполнит вызов aquire() и перейдет в состояние ожидания. Центральный процессор перейдет среднеприоритетному потоку, который может выполняться в течение неограниченного времени, не предоставляя низкоприоритетному потоку возможности освободить занятый мьютескс. Таким образом, выполнение высокоприоритетного потока будет зависеть от поведения среднеприоритетного потока – такая ситуация называется "инверсия приоритетов". Один из способов борьбы с данным явлением – наследование приоритета – поток, захвативший мьютекс, временно наследует максимальный из приоритетов потоков, ждущих освобождения данного мьютекса.
Позже мы рассмотрим примеры использования семафоров и мьютексов, а сейчас отметим, что семафоры и мьютексы представляют собой специальный вид разделяемых переменных, для которых отсутствует связь между ними и защищаемым критическим ресурсом. Это делает невозможным автоматическую поверку корректности использования семафоров и мьютексов (например, компилятором).
Объединение механизмов синхронизации с защищаемым ресурсом выполнение в мониторах.