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

OC Windows & OC Linux. Лабораторные работы по курсу «Операционные системы»

.pdf
Скачиваний:
0
Добавлен:
06.09.2026
Размер:
2 Мб
Скачать
Рассмотрим еще раз, как работает механизм 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
действие, то мы заблокируем
мо-
многониточном