Большинство представленных до сих пор в этом руководстве примеров было довольно грубыми. При проектировании многократно используемых компонентов или структуры большого многопоточного приложения, метод "полета вслепую" не подходит. Разработчик приложений или компонентов должен создавать классы со встроенными средствами обеспечения потокобезопасности, то есть классы, к которым возможен доступ из других потоков, содержащие подходящие внутренние механизмы, гарантирующие сохранность данных. Для этого разработчик компонентов должен позаботиться о решении некоторых проблем, которые возникают при использовании мьютексов в очень сложных приложениях. Если вы впервые пытаетесь написать потокобезопасный класс, не откладывайте из-за кажущейся сложности изучение вопросов, рассматриваемых в этой главе. Довольно часто может быть принято упрощенное решение, которое ценой некоторой эффективности позволяет избежать многих упомянутых здесь проблем. Заметьте, что решения, в которых упоминаются мьютексы, ообычно так же хорошо применимы и к критическим секциям. Я для краткости не буду каждый раз это отмечать.
Хотя возможность справиться с проблемами приобретения мьютекса и есть, лучше все-таки заранее гарантировать, чтобы тупиковые ситуации вообще не возникли. Поскольку такое зацикливание вызвано циклическими зависимостями, оно может быть устранено насильным упорядоченим приобретения мьютексов. Это упорядочение очень просто. Пусть у нас есть программа с мьютексами M1, M2, M3, ... Mn, где одним или несколькими мьютексами могут владеть потоки программы.
Представим момент, в который потоку нужно сравнить элементы списка X и Y. Если поток всегда блокирует X, а потом Y, то может возникнуть зацикливание, если одному потоку нужно сравнивать элементы 1 и 2, а другому потоку - сравнивать 2 и 1. Одно из простых решений состоит в том, чтобы всегда блокировать сначала элемент с меньшим номером, или сортировать входные индексы, осуществлять блокировку, и правильно получать результаты сравнения. Однако более интересная ситуация создается, если объект содержит информацию о другом объекте, с которым требуется сравнение. В этом случае поток может блокировать первый объект, получить индекс второго объекта в списке, найти, что он располагается в списке ниже, блокировать его, и затем произвести сравнение. Все очень легко. Проблема возникает, когда второй объект находится в списке выше первого. Мы не можем блокировать его немедленно, так как это приведет к тупику. Теперь нам придется разблокировать первый объект, блокировать второй, и затем снова блокировать первый объект. Вот так удастся избежать тупика. Здесь пример процедуры косвенного сравнения, представляющей данный подход.
Зная, что могут появиться такие проблемы, разработчики операционных систем 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.