Глава 7. Программирование с использованием мьютексов. Управление конкуренцией.

Содержание:

Пора позаботиться о стиле?

Большинство представленных до сих пор в этом руководстве примеров было довольно грубыми. При проектировании многократно используемых компонентов или структуры большого многопоточного приложения, метод "полета вслепую" не подходит. Разработчик приложений или компонентов должен создавать классы со встроенными средствами обеспечения потокобезопасности, то есть классы, к которым возможен доступ из других потоков, содержащие подходящие внутренние механизмы, гарантирующие сохранность данных. Для этого разработчик компонентов должен позаботиться о решении некоторых проблем, которые возникают при использовании мьютексов в очень сложных приложениях. Если вы впервые пытаетесь написать потокобезопасный класс, не откладывайте из-за кажущейся сложности изучение вопросов, рассматриваемых в этой главе. Довольно часто может быть принято упрощенное решение, которое ценой некоторой эффективности позволяет избежать многих упомянутых здесь проблем. Заметьте, что решения, в которых упоминаются мьютексы, ообычно так же хорошо применимы и к критическим секциям. Я для краткости не буду каждый раз это отмечать.

Тупик из-за упорядочения мьютексов.

Если в программе имеется несколько мьютексов, то бывает нетрудно ввести ее в тупик неправильным кодом синхронизации. Наиболее часто это происходит, если существует циклическая зависимость порядка, в котором захватываются мьютексы. В академической литературе это часто называют проблемой трапезы философов. Как мы видели ранее, критерий возникновения зацикливания состоит в том, что потоки ждут, пока другой поток освободит объект синхронизации. Простейший пример - для двух потоков, один из которых захватывает мьютекс A до захвата мьютекса B, а другой захватывает мьютекс B до захвата мьютекса A.
Конечно, вполне возможно получить зацикливание программы и более сложным образом, с циклической цепочкой зависимостей, как показано ниже для четырех потоков и четырех мьютексов от A до D.
Очевидно, что подобные ситуации недопустимы в большинстве приложений. Существует несколько способов решения этой задачи и методы снятия проблем с такими зависимостями, что и позволяет избавиться от зацикливания.

Избавляемся от зацикливания потоков путем ожидания.

Функции Win32, работающие с мьютексами, не требуют, чтобы поток вечно ждал воэможности захвата объекта мьютекса. Функция WaitForSingleObject позволяет определить время, в течение которого поток будет ждать. По его истечении поток будет разблокирован, и функция вернет код ошибки, показывающий, что время ожидания вышло. При использовании мьютексов для обеспечения доступа к критическому участку кода обычно не предполагается, что потоку придется ждать очень долго, так что установка периода ожидания (time-out) в пределах нескольких секунд вполне разумна. Если ваш поток использует этот метод, то он должен, конечно, правильно обрабатывать ошибки, например, повтором попытки или отказом от действия. При использовании критической секции такой возможности нет, так как функции ожидания критической секций ждут бесконечно.

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

Хотя возможность справиться с проблемами приобретения мьютекса и есть, лучше все-таки заранее гарантировать, чтобы тупиковые ситуации вообще не возникли. Поскольку такое зацикливание вызвано циклическими зависимостями, оно может быть устранено насильным упорядоченим приобретения мьютексов. Это упорядочение очень просто. Пусть у нас есть программа с мьютексами M1, M2, M3, ... Mn, где одним или несколькими мьютексами могут владеть потоки программы.

Звучит несколько абстрактно? Рассмотрим простой конкретный пример. В этой части данной главы я упоминаю "блокировку" и "разблокирование" объектов. Эта терминология вполне уместна, когда мьютекс связан с куском данных, и требуется атомарный доступ к этим данным. Следует отметить, что это также означает, что каждый поток, получает доступ (захватывает) мьютекс до доступа к объекту, и освобождает мьютекс после операций с ним: такие действия уже обсуждались ранее, отличие только в терминах, которые более подходят в случае объектно-ориентированной (OO) модели. В этом смысле Object.Lock можно рассматривать как эквивалент EnterCriticalSection(Object.CriticalSection) или, возможно, WaitForSingleObject(Object.Mutex,INFINITE).
У нас есть структура данных - список, к которому имеют доступ несколько потоков. В списке несколько объектов, у каждого из которых есть собственный мьютекс. На данный момент мы считаем, что структура списка статическая, не изменяется, и, таким образом, потоки могут проводить чтение без какой-либо блокировки. Потоки, работающие с этой структурой данных, могут совершить одно из следующих действий: Простой псевдокод для этих функций, без учета приведения типов, обработки исключений и других вторичных проблем, может выглядеть примерно так.

Представим момент, в который потоку нужно сравнить элементы списка X и Y. Если поток всегда блокирует X, а потом Y, то может возникнуть зацикливание, если одному потоку нужно сравнивать элементы 1 и 2, а другому потоку - сравнивать 2 и 1. Одно из простых решений состоит в том, чтобы всегда блокировать сначала элемент с меньшим номером, или сортировать входные индексы, осуществлять блокировку, и правильно получать результаты сравнения. Однако более интересная ситуация создается, если объект содержит информацию о другом объекте, с которым требуется сравнение. В этом случае поток может блокировать первый объект, получить индекс второго объекта в списке, найти, что он располагается в списке ниже, блокировать его, и затем произвести сравнение. Все очень легко. Проблема возникает, когда второй объект находится в списке выше первого. Мы не можем блокировать его немедленно, так как это приведет к тупику. Теперь нам придется разблокировать первый объект, блокировать второй, и затем снова блокировать первый объект. Вот так удастся избежать тупика. Здесь пример процедуры косвенного сравнения, представляющей данный подход.

Из огня да в полымя!

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

Избавляемся от зацикливания "ленивым способом", давая Win32 сделать это за нас.

Зная, что могут появиться такие проблемы, разработчики операционных систем Microsoft предусмотрели еще один путь их решения через другую функцию синхронизации Win32: WaitForMultipleObjects(Ex). Эта функция позволяет программисту ожидать и захватывать многочисленные объекты синхронизации (включая мьютексы) одновременно. В частности, она позволяет потоку ожидать, пока один или все из набора объектов не будут свободны (signalled) (в случае мьютексов - у них не будет владельцев), и тогда захватить объекты. Большое преимущество этого способа состоит в том, что если два потока ожидают мьютексы A и B, то не имеет значения порядок, в котором они идут в наборе объектов, подлежащих ожиданию, и либо ни один из объектов не будут захвачен, либо все они будут захвачены атомарно, так что зацикливание становится невозможным.

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

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

Третий недостаток этого метода, как и метода исключения тупика путем "ожидания" - невозможно использовать эти функции, если вы применяете критические секции; функция EnterCriticalSection не позволяет задать время ожидания, и не возвращает кода ошибки.

Атомарность составных операций - управление конкуренцией оптимистическим и пессимистическим образом.

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

Оптимистическое управление.

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

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

Пессимистическое управление.

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

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

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

Поток может уничтожить объект, реализуя такой алгоритм:

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

Мы подошли к интересной части. Поток может сравнить два объекта, выполняя более простой алгоритм, чем приведенные выше:

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

Избавляемся от недостатков в схеме блокировки.

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

Если вы можете быть уверены, что ваша программа будет работать только под Windows NT (или 2K), то Windows API на самом деле предоставляет еще одно решение проблемы составных действий при разблокировке и новой блокировке объектов. Функция API SignalObjectAndWait позволяет вам атомарно освобождать (signal) один объект синхронизации и ждать другого. Сохраняя эти два действия атомарными, вы можете передать состояние блокировки одного объекта на другой, в то же время гарантируя, что никакие другие потоки не изменят состояние объектов во время передачи. Это означает, что оптимистическое управление параллелизмом в таких ситуациях не требуется.

Еще не все ясно? Можно и попроще!

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

Блокировка всех разделяемых данных часто неплохо работает, если вы можете несколько пожертвовать эффективностью. Большинство пользователей предпочитают программы, которые работают чуть медленнее, чем те, которые непредсказуемо зависают из-за ошибок в схеме блокировки. Если имеется большой объем данных, сохранность которых очень важна, то подойдет работа с данными через BDE. Все системы управления базами данных непременно являются потокобезопасными, то есть вы можете без проблем получать доступ к вашим данным из отдельных потоков. Если вы будете использовать базы данных, то вам придется кое-что узнать об управлении транзакциями, т.е. о reservation и использовании семантики prepare, commit и rollback, но пока примите на веру, что подход, основанный на транзакциях, решает проблемы конфликтов потоков; большая часть сложной работы по кодированию для вас уже сделана. Использование BDE c потоками будет описано позже.


[Содержание] [Назад][Вперед]

© Martin Harvey 2000.
 

Hosted by uCoz