Добавил:
ivanov666
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз:
Предмет:
Файл:OC Windows & OC Linux. Лабораторные работы по курсу «Операционные системы»
.pdf
Рассмотрим еще раз, как работает механизм mutex на следующем примере:
Рассмотрим теперь простой пример использования mutex для
организации взаимного исключения при доступе к такому общему
ресурсу, как стандартный вывод.
Пусть у нас есть набор процедур, выводящих некие группы
строк на стандартный вывод, эти процедуры будут вызываться из
разных нитей. При этом для нас важно, чтобы некоторые из этих
групп не прерывались выводом
щий код мог бы выглядеть таким образом (сначала рассмотрим случай с одной группой):
pthread_mutex_t print_lock = PTHREAD_MUTEX_INITIALIZER;
void print1() {
pthread_mutex_lock(&print_lock);
printf("Print 1 - line 1\n");
printf("Print 1 - line 2\n");
pthread_mutex_unlock(&print_lock);
}
То есть при входе в процедуру мы захватываем mutex, а перед
выходом из нее его освобождаем. Таким образом, мы организовали
из нашей процедуры критическую секцию. В случае если у нас есть
других строк. Тогда соответствую-
201

другие процедуры, которые могут вызываться одновременно с данной, и в них следует организовать критические секции. Например:
void print2() {
pthread_mutex_lock(&print_lock);
printf("Print 2\n");
pthread_mutex_unlock(&print_lock);
}
При этом мы естественно должны использовать тот же mutex.
Сразу видно, что на самом деле mutex связан не с процедурой, а с
данными (в данном случае со стандартным выводом), и что защищает он именно данные. Используя подход, ориентированный на данные, а не на код, мы с меньшей вероятностью совершим такую
ошибку
, как неиспользование mutex'а при доступе к общему ресурсу.
Из тех же соображений снижения количества ошибок mutex, который защищает какие-либо данные, логично располагать рядом с
этими данными – тогда его легко заметить.
При разработке многонитевых программ перед нами может
встать вопрос о том, что именно должен защищать mutex. Например,
в случае, если
мы имеем некий массив, доступ к одним и тем же
элементам которого осуществляется из различных нитей, то мы можем пойти несколькими путями. Во-первых, мы можем иметь для
каждого элемента массива свой персональный mutex, который его
защищает. Во-вторых, мы можем завести один mutex, который будет
защищать все элементы массива. Кроме того
, существует целый ряд
промежуточных вариантов, например иметь k mutex'ов, где каждый
m-ный из них защищает элементы массива, остаток деления индекса
которых на k равен m, можно иметь защищаемый одним mutex список элементов, к которым сейчас осуществляется доступ и т.п.
Одним словом, перед нами встает вопрос: насколько велик дол-
жен быть mutex, т.е
. сколько объектов он должен защищать. Чем
больше объектов mutex защищает, тем больше он считается (стоит
подчеркнуть, что речь идет не о физических его размерах, скажем, в
памяти, а скорее о логических размерах). При принятии подобного
решения нам приходится учитывать два противоположных фактора:
• Mutex'ы не бесплатны. Операция захвата mutex'а занимает
некоторое время, как впрочем и операция его освобождения. Таким
образом, чем больше mutex'ов мы вынуждены захватывать/освобо-
202

ждать, тем больше времени мы тратим на накладные расходы. Кроме того, каждый mutex занимает некоторый, возможно маленький,
объем памяти; соответственно, чем больше абсолютное число
mutex'ов в нашей программе, тем больше памяти расходуется на
них. Соответственно, чем меньше mutex'ов мы вынуждены захватывать, тем больше времени мы экономим, а чем меньше
абсолютное
количество mutex'ов в нашей программе, тем больше экономим мы
памяти.
• Mutex'ы по своему определению организуют взаимное ис-
ключение и тем самым сериализуют выполнение программы. То
есть возможно там, где у нас нити могли бы выполняться параллельно, например когда они работают с независимыми данными,
они выполняются последовательно. Как следствие, по закону Амдала, уменьшается ускорение работы программы. Поэтому может быть
выгодно иметь больше mutex'ов, защищающих независимые данные,
обеспечивая тем самым большие возможности для параллелизма.
Обычно в сложных программах принятие решения о размерах
тех или иных mutex'ов происходит на основе ряда экспериментов.
Как правило, программу проще писать, если в ней больше mutex'ов,
т.е., скажем, есть по mutex'у на каждый объект. Следовательно
, получает право на жизнь следующая стратегия: изначально программа
пишется с большим количеством mutex'ов, а если при этом возникают проблемы с производительностью или нехваткой других ресурсов, то пытаются провести ее оптимизацию, т.е. уменьшить какимлибо образом количество mutex'ов.
Основную идею этого подхода можно сформулировать и так: не
надо оптимизировать, пока нет проблемы. С другой стороны, если
ваш опыт подсказывает, что в конкретном случае обязательно возникнет проблема с mutex'ами, то имеет смысл сразу принять какоето более оптимальное решение.
Проблема тупиков
Из вышесказанного следует, что иногда в программах появляется необходимость захватывать и удерживать одновременно несколько mutex'
ов. При этом возможно возникновение таких типичных для
параллельного программирования ситуаций, как тупиков.
Тупик – это ситуация, в которой несколько потоков управления
таким образом захватили в монопольное использование ресурсы, что
исключили возможность дальнейшего выполнения для себя. Типич-
203

ный пример тупика – ситуация, когда первая нить захватила mutex A
и пытается захватить mutex B, в то время как вторая нить уже захватила mutex B и для дальнейшего выполнения ей необходимо захватить mutex A. Ясно, что в такой ситуации дальнейшее выполнение
ни одной из нитей невозможно. В общем случае мы имеем несколько нитей, которые захватили mutex'ы
Ниже изображен граф, иллюстрирующий ситуацию тупика между двумя нитями. Захваты и ожидания ресурсов изображены в виде
дуг, нити и ресурсы – в виде вершин. Графически тупик выражается
в виде наличия цикла в данном графе.
неудачным образом.
Каким образом можно решить проблему тупиков?
Во-первых, можно использовать подход, основанный на применении иерархии mutex'ов. При этом подходе мы устанавливаем некое отношение старшинства между всеми mutex'ами в нашей программе, и в любой ситуации, когда нам необходимо захватить несколько mutex'ов одновременно, мы захватываем строго по старшинству
первого, но старше остальных и т.д. Если строго соблюдать это правило, то тупиков не возникнет.
рассмотрим, как действует этот подход на нашем примере с двумя
mutex'ами и двумя нитями. Согласно правилу обе нити
дут захватывать сначала mutex A, и только потом B, ясно, что тогда
тупиковой ситуации не возникнет, так как после того, как первая
нить захватит первый mutex, второй либо будет свободен (если вторая нить нуждается в обоих mutex'ах, то она остановится на захвате
первого), либо будет захвачен, но на конечный отрезок времени (
, т.е. сначала самый старший, потом mutex, который младше
Оставляя в стороне формальное доказательство этого факта,
должны бу-
в слу-
204

чае если вторая нить хочет работать только со вторым mutex'ом, то
ничто не помешает ей его захватить, но тупика не возникнет, так как
она не имеет права пытаться захватить первый, и, следовательно,
обязана освободить захваченный mutex в течение конечного периода
времени).
Стоит заметить, что часто не надо устанавливать отношения иерархии между
граммы нам гарантирует, что два mutex'а никогда не будет захватываться последовательно одной нитью, то нас не интересует отношение старшинства между ними – иногда его можно вывести на основании правила транзитивности из отношений старшинства между
этими и другими mutex'ами, а иногда и
Может возникнуть вопрос – а как именно устанавливать это самое отношение старшинства? Часто это отношение обусловлено самой структурой данных, которые защищают mutex'ы. Например,
очевидно, что если мы имеем контейнер, который защищен mutex'ом
и который содержит объекты, каждый из которых сам защищен
mutex'ом, то логично сначала захватывать mutex контейнера, а
mutex объекта, при этом mutex контейнера можно освободить, дав
другим нитям возможность работать с другими объектами в данном
контейнере.
В случае если мы не можем просто задать иерархию, опираясь
на свойства данных, например если мы имеем некоторое неизвестное заранее количество динамически создаваемых объектов, то можно воспользоваться следующим искусственным приемом
дения иерархии. Этот прием заключается в том, что мы определяем
первый mutex как старший ко второму, если абсолютное значение
адреса первого mutex'а больше соответствующего значения для второго (а можно использовать адреса защищаемых объектов или, более того, ввести нумерацию, выдавая последовательный номер для
каждого mutex'а при его инициализации).
Заметим
использовать некую комбинацию этих двух подходов установления
отношения старшинства.
Иногда подход предотвращения тупиков, основанный на установлении иерархии mutex'ов, неудобен. Так, в случае если мы наперед не знаем, к каким именно объектам нам потребуется получить
доступ и, соответственно, мы не можем
захотим захватить до того, как захватим ряд из них, нам придется
всеми mutex'ами. Так, например, если логика про-
нельзя.
потом
для наве-
, что в реальных приложениях мы скорее всего будем
сказать, какие mutex'ы мы
205

при каждом захвате более старшего, чем ранее захваченные mutex'ы,
освобождать эти mutex'ы, чтобы вскоре снова захватить их, начав с
этого старшего.
Опять же крайне неудобной при таком подходе представляется
работа с двунаправленными списками с защищенными mutex'ами
элементами, обход по которым необходимо осуществлять в обоих
направлениях.
В этих случаях имеет смысл использовать
дотвращению тупиков. Он основывается на использовании неблокирующего варианта операции захвата mutex'а, то есть pthread_mutex_
trylock и называется «попытка и откат» /try and back-off/. Идея этого подхода очень проста: мы захватываем mutex'ы в произвольном
порядке, но используем при этом операцию pthread_mutex_trylock;
если эта операция в некоторый момент нам возвращает EBUSY /, т
если один из mutex'ов, который мы пытаемся захватить, уже захвачен, то мы освобождаем все захваченные mutex'ы, как бы уступая
дорогу нашему конкуренту. Ясно, что при таком подходе тупиков не
возникает; с другой стороны, сразу виден основной недостаток этого
метода, а именно: дополнительные накладные расходы, которые появляются извобождения mutex'ов. Кроме того, легко себе представить ситуацию,
когда две нити пытаются захватить один и тот же набор mutex'ов A и
B, но в разном порядке, и при этом ни одна из них не может сделать
этого, так как все время натыкается на соперничающую нить,
смотря на то, что та регулярно освобождает свой mutex, т.е. нет теоретической гарантии работы схемы. Практически же в ситуациях,
когда конкуренция за ресурсы невысока, данный подход работает
вполне эффективно и надежно.
Прежде чем перейти к анализу того, какие же конкретно преимущества дает
пояснить значение некоторых терминов, которые мы будем активно
использовать.
Асинхронные события – это события, которые происходят независимо (возможно одновременно), за исключением случаев, когда
зависимость устанавливается внешними силами. События в реаль-
за многократных попыток захвата и многократного ос-
Анализ многонитевого программирования
Базовые термины
нам многонитевое программирование, необходимо
206
второй подход к пре-
.е.
не-

ной жизни происходят асинхронно, зависимости между ними устанавливают законы природы; если между событиями нет зависимости, то они могут происходить одновременно.
Термином конкуренция описывается ситуация, когда кажется,
что процессы происходят одновременно, однако на самом деле они
могут происходить последовательно. Этим термином хорошо описывается, например, поведение одновременно выполняющихся про-
цессов
выполняется несколько процессов, но на самом деле в данный конкретный момент выполняется только один процесс, получивший текущий квант процессорного времени.
са выполняются одновременно, то есть параллельно, не пересекаясь.
Настоящий параллелизм может проявляться
цессорных системах, в то время как конкуренция – и на однопро-
цессорных и на многопроцессорных системах. Иными словами, конкуренция представляет собой лишь иллюзию параллелизма. А настоящий параллелизм требует для одновременного выполнения нескольких процессов нескольких исполнителей.
фективном
лизм, и в первую очередь SMP систем, т.е. машин, имеющих несколько процессоров и общую, т.е. равнодоступную каждому из них,
память.
выполнять более чем одну инструкцию одновременно. Это позволит
приложению, основную часть времени работы которого занимают
вычисления, повысить
сорной машине практически в два раза. Для программы, у которой
поддающийся распараллеливанию код выполняется половину времени, ускорение работы составляет примерно одну треть для двухпроцессорной машины.
можем достичь при помощи нитей, очевидно, можно достичь
параллелив приложение на несколько процессов и организовав обмен данными между ними при помощи разделяемой памяти и других средств межпроцессной коммуникации. Однако это даст боль-
на однопроцессорной машине, хотя нам и кажется, что сейчас
Словом параллелизм описывается ситуация, когда два процес-
только на многопро-
Преимущества многонитевого программирования
Преимущество нитевого программирования заключается в эф-
использовании аппаратуры, поддерживающей паралле-
На подобной аппаратуре программа, использующая нити, может
свою производительность на двухпроцес-
Заметим, что того же эффекта, да и вообще всего того, что мы
, рас-
207

шие, чем многонитевой вариант, накладные расходы и усложнит
реализацию.
Использование конкуренции
Используя нити и конкуренцию, мы можем повысить производительность программ даже без использования аппаратуры, обеспечивающей истинный параллелизм. Пусть мы имеем две нити в программе, выполняющейся на однопроцессорной машине:
Пусть первая нить начинает долгую операцию ввода-вывода,
тогда вторая нить получает процессорное время. Таким образом,
вместо того чтобы простаивать, как это было бы в случае однониточного варианта, наша программа продолжает выполняться.
В принципе, того же эффекта можно достигнуть и без нитей,
используя возможности асинхронного, или неблокирующего, вводавывода. Первый
ния или записи, которая должна заблокировать процесс, операция
принимается к исполнению, но блокирования не происходит. Вместо этого можно спокойно продолжать вычисления или осуществлять ввод-вывод по другим каналам. Операционная система известит нас об окончании операции ввода-вывода посылкой соответствующего сигнала.
Второй же способ основывается на том, что время от времени
мы с помощью специального системного вызова опрашиваем файлы, в которые хотим осуществлять ввод-вывод, – готовы ли они к
выполнению той или иной операции. В случае готовности мы можем
выполнить желаемую операцию. Ясно, что в промежутках между
опросами мы
ную работу.
Однако оба этих способа обладают тем существенным недостатком, что код, получающийся при их реализации, достаточно сложен, так как в нем приходится разносить ввод-вывод и обработку
способ основывается на том, что при попытке чте-
можем выполнять какую-то предположительно полез-
208

данных. Реально для каждого файла, в который производится ввод
или вывод, приходится хранить состояние этой операции. Если мы
немного подумаем над этим, то увидим, что фактически мы храним
состояние неких псевдонитей и при помощи нашего кода осуществляем планирование для них. Но поскольку точно так же, как обычная нить легче
ной нити, то в приложениях, рассчитанных на большое количество
одновременных операций ввода-вывода, как правило, используются
именно эти более сложные подходы. Стоит заметить, что идею о
том, что программирование с использованием неблокирующего ввода-вывода суть некое подобие многонитевого программирования,
эксплуатируют
тей, сочетая, с одной стороны, эффективность, присущую неблокирующему вводу-выводу, с ясностью программ, построенных на использовании нитей, с другой стороны.
Особую остроту проблема ввода-вывода приобретает в сетевых
приложениях, где блокирование однониточного процесса медленной
операцией ввода-вывода в сеть при обслуживании одного из запро
сов ведет к приостановке обслуживания других запросов. Как правило, такое поведение для этих приложений неприемлемо.
Именно поэтому практически сразу в них использовались возможности конкурентного выполнения запросов, сначала основанные
на использовании многих процессов, потом – на использовании многих нитей или неблокирующего ввода-вывода. Однако здесь, пожалуй, мы подошли
использование конкуренции для нитей. Мы можем улучшить наши
приложения, сократив время отклика на операции в них, это относится как к приведенной ситуации с сетевым вводом-выводом, так и
к ситуациям, когда, скажем, одна нить в приложении обрабатывает
нажатия кнопок и отвечает за перерисовку окон
пример, выполняет какую-то вычислительную задачу. Таким образом, мы можем повысить качество наших интерфейсов, не заставляя
пользователя ожидать выполнения какой-либо длительной операции, показывая песочные часы.
Этот вариант использования нитей можно рассматривать как
нашу попытку придать программе возможность адекватно реагировать на события внешнего
хронно.
процесса, это состояние и работа с ним легче обыч-
некоторые библиотеки, реализующие механизм ни-
к следующему преимуществу, которое дает нам
, а другая нить, на-
мира, которые обычно происходят асин-
-
209

Опять же, в принципе, проблема вполне может быть решена с
использованием архитектуры, ориентированной на события, которую обычно используют в оконных системах. Основная идея этого
подхода заключается в том, что вся работа асинхронного приложения рассматривается как циклическая обработка событий, любой
внешний ввод или сигнал рассматривается как событие, которое надо
обработать при помощи вызова некоей процедуры или некоего
метода некоего объекта. Хотя при грамотной реализации такой подход скроет от программиста значительную часть сложности, связанной с реализацией асинхронного ввода-вывода, он обладает тем недостатком, что в случае, если нам необходимо в качестве реакции на
событие выполнить некое длительное
на это время обработку остальных событий. Единственный выход –
дробление таких длительных событий на набор мелких событий, что
зачастую бывает сложно сделать, а иногда и вообще невозможно.
Кроме того, ясно видно, что мы опять пытаемся реализовать некую
функциональность, уже реализованную в библиотеке нитей (а именно, планирование переключений
Улучшение структуры программ
Даже если мы уверены в том, что наша программа в многониточном варианте не будет работать производительнее, мы можем
получить некие преимущества от использования нитей. А именно,
выделяя в наших программах независимые события и последовательности событий и оформляя их в виде различных нитей, мы
жем получить программы, лучше отражающие реальность, которые
как следствие будет удобнее сопровождать.
Недостатки у многонитевых программ
Естественно, за все хорошее приходится платить. Чем же нам
приходится платить в случае использования нитей?
Во-первых, возможна потеря производительности. Очевидно,
что даже полностью распараллеливаемое вычислительно интенсивное приложение на однопроцессорной машине, в
варианте будет выполняться медленнее своей однониточной версии.
Это обусловлено, в первую очередь, накладными расходами на
управление нитями и обеспечение согласования между ними (на
синхронизацию). Далее, даже при выполнении на многопроцессорной машине в случае плохо распараллеливаемого кода мы можем
между потоками).
210
действие, то мы заблокируем
мо-
многониточном
Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]
