Часть 6. Снова о синхронизации: Критические секции и мьютексы.

Содержание:

Ограничения Synchronize.

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

Важно помнить, для чего в приложении используются потоки. Основная причина для большинства Delphi-программистов в том, что они хотят, чтобы их приложение оставалось восприимчивым к действиям пользователя, пока выполняются длительные операции или используется передача данных с блокировкой или ввод-вывод. Это часто означает, что основной поток приложения должен выполнять краткие, основанные на событиях подпрограммы,а также отвечать за пользовательский интерфейс, т.е. обеспечивать прием ввода и отображать результаты. Другие потоки приложения будут выполнять "черную работу". Основываясь на этой философии, часто приходится делать так, что большая часть кода, выполняющегося в рабочих потоках, не использует код VCL, который не является потокобезопасным. Рабочие потоки могут выполнять операции с файлами или базами данных, но они редко используют потомков TControl. В этом свете Synchronize может привести к проблемам с производительностью.

Многим потокам нужно связываться с VCL только в простых случаях, как например, передача потока (stream) данных, или выполнение запроса к базе данных и возвращение структуры данных как результат этого запроса. Как отмечено в Главе 3, при модификации общих данных нам нужно только поддерживать атомарность. В качестве простого примера можно рассмотреть поток данных (stream), который записывается рабочим потоком, и периодически читается основным потоком VCL. Нужно ли нам гарантировать, что поток VCL никогда не выполняется одновременно с рабочим? Конечно, нет! Все, что нужно обеспечить - так это то, что только один поток модифицирует этот разделяемый ресурс в каждый момент, таким образом устраняя условия для конфликтов, и делая операции с коллективным ресурсом атомарными. Такой режим называется взаимное исключение (mutual exclusion). Есть много примитивов синхронизации, которые могут быть использованы для осуществления такого режима. Простейшие из них - мьютекс (Mutex), встроенный в Win32, и близкие к мьютексам критические секции (Critical Section). Последние версии Delphi содержат класс, который инкапсулирует вызовы критических секций Win32. Этот класс здесь не обсуждается, поскольку он имеется не во всех 32-битовых версиях Delphi. У программистов, использущих этот класс, не должно быть больших трудностей в использовании соответствующих его методов для достижения таких же эффектов, как и обсуждаемые здесь.

Критические секции.

Критическая секция (Critical Section) позволяет добиться взаимного исключения. Win32 API поддерживает несколько операций с ними:

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

Когда объект критической секции создан, его можно использовать для контроля за общими ресурсами. Две главных операции - EnterCriticalSection и LeaveCriticalSection. В большей части литературы, касающейся темы синхронизации, эти операции называют Wait и Signal, или Lock и Unlock соответственно. Эти альтернативные термины используются также и для других примитивов синхронизации, где они имеют приблизительно эквивалентные значения. По умолчанию при создании критической секции ни один из потоков приложения не владеет ей (ownership). Чтобы управлять критической секцией, поток вызывает EnterCriticalSection, и если критическая секция еще не имеет владельца, то поток становится им. Затем поток обычно совершает действия с общими ресурсами (критическая часть кода, показана двойной линией), а когда заканчивает эти действия, то отказывается от владения критической секцией вызовом LeaveCriticalSection.

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

Что это все значит для Delphi-программиста?

Это означает, что если не нужно проводить действия с VCL, а только обеспечить доступ к данным и их изменение, при написании программ с использование потоков на Delphi программист избавлен от бремени TThread.Synchronize.

В последнем пункте указано "почти", поскольку все-таки возможно вызвать зацикливание точно таким же способом, как и прежде. Все, что нужно для этого сделать - вызвать WaitFor в основном потоке, когда он находится в критической секции. Как мы увидим позже, остановка потока на большой промежуток времени, когда он внутри критической секций - плохая идея. Теперь, когда я более-менее объяснил теорию, представлю еще один пример. Это чуть более изящная и интересная программа нахождения простых чисел. Вначале она пытается найти простые числа, стартуя с числа 2, и продвигаясь по числовому ряду вверх. Каждый раз, определив, что число простое, она обновляет общую структуру данных (список строк) и сообщает основному потоку, что к списку добавлены новые данные. Вот код главной формы.

Это довольно похоже на предыдущие примеры в том, что касается создания потока, но есть и несколько дополнительных полей основной формы, которые следует создать. StringSection - это критическая секция, которая контролирует доступ к ресурсам, разделяемым между потоками. FStringBuf - список строк, который выступает буфером между основной формой и рабочим потоком. Рабочий поток посылает результаты в основную форму, добавляя их к этому списку, который является единственным общим ресурсом в этой программе. И наконец, имеется логическая переменная, FStringSectInit. Эта переменная служит для проверки того, что необходимые объекты синхронизации реально созданы прежде, чем их начали использовать. Общие ресурсы создаются, когда мы запускаем рабочий поток, и уничтожаются сразу после того, как мы убедимся, что рабочий поток завершился. Заметьте, что поскольку список строк, выступающий в роли буфера, распределен динамически , мы должны использовать WaitFor при уничтожения потока, чтобы убедиться, что рабочий поток перестал использовать буфер до его освобождения.

Мы можем использовать WaitFor в этой программе, не беспокоясь о зацикливании, поскольку можно доказать, что никогда не бывает ситуации, в которой оба потока ждут друг друга. Доказать это просто:

  1. Рабочий поток ждет только при попытке получить доступ к критической секции.
  2. Основной поток программы ждет только при ожидании завершения рабочего потока.
  3. Основной поток программы не ожидает, когда он завладеет критической секцией.
  4. Если рабочий поток ожидает критической секции, главная программа освободит критическую секцию до того, как она будет ждать рабочий поток.

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

На заметку.

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

Могут ли данные пропасть или остаться недоступными в буфере?

Рабочий поток указывает основному программному потоку, что в буфере есть данные, которые нужно обрабатывать, посылая ему сообщение. Важно отметить, что при использовании таким образом системы обмена сообщениями Windows синхронизация потоков никак не связывает конкретное сообщение с конкретной модификацией буфера . К счастью, в этом случае причинно-следственная связь работает в нашу пользу: когда буфер обновляется, сообщение посылается после модификации. Это означает, что основной программный поток всегда получает сообщение о модификации буфера после этой модификации. Следовательно, не может случиться так, что данные останутся в буфере на неопределенное время. Если данные сейчас в буфере, то рабочий и основной потоки находятся в процессе посылки или получения сообщения о модификации буфера. Заметьте, что если бы рабочий поток посылал сообщение прямо перед обновлением буфера, могло бы случиться, что основной поток обработал это сообщение, прочитав буфер, прежде, чем рабочий добавил в буфер самые последние результаты, а это означало бы, что последний результат мог бы остаться в буфере в течение некоторого времени.

Как насчет запоздавших сообщений (out of date)?

Законы причины и следствия в предыдущем случае работали хорошо, но, к несчастью, существует также и обратная проблема . Если основная поток занят обновлением долго, возможно, что сообщения выстроятся в очередь, так что мы получим обновления намного позже того, как рабочий поток пошлет сообщения. В большинстве ситуаций это не составит проблем. Тем не менее, надо рассмотреть один частный случай, когда пользователь останавливает рабочий поток или непосредственно, нажав кнопку "Stop", или косвенно, закрывая программу. В этом случае вполне возможно, что основной поток VCL завершает рабочий поток, удаляет все объекты синхронизации и буфера, а затем последовательно получает сообщения, которые находились в очереди в течение некоторого времени. В данном примере я проверял наличие этой проблемы, убеждаясь, что перед обработкой сообщения критическая секция и буферные объекты все еще существуют (строка кода с комментарием Not necessarily the case! ). Этого метода обычно достаточно для большинства приложений.

Проблемы Flow Control и неэффективность списка.

В Главе 2 я утверждал, что при создании потока никакой неявной синхронизации не существует. Это было очевидно уже в первых примерах и продемонстрировано проблемами переключения потоков (проблема синхронизации состояния потоков) . Такая же проблема существует и для синхронизации скорости. Ничто в последнем примере не гарантирует, что рабочий поток будет выдавать результаты достаточно медленно для того, чтобы основной поток VCL был способен успеть их отобразить. Фактически, если программа выполняется так, что рабочий поток начинает поиск небольших простых чисел, вполне вероятно, что при равных долях времени процессора рабочий поток намного опередит поток VCL. Эта проблема решается посредством flow control (управления потоками).

Flow control называют метод, при котором скорость выполнения нескольких потоков сбалансирована так, что скорость ввода в буфер и скорость чтения из него примерно равны. Последний пример очень прост, но подобное происходит и в многих других случаях. Почти каждый механизм ввода-вывода или передачи данных между потоками или процессами использует какой-либо способ управления потоками. В простых случаях можно разрешать только одному куску данных находиться в процессе передачи, задерживая или источник (поток, который выводит данные в буфер) или приемник (поток, который их читает). В более сложных случаях потоки могут выполняться на разных машинах, и "буфер" может состоять из внутренних буферов в каждой машине и буферных возможностей сети, их соединяющей. Большая часть протокола TCP занимается как раз таким управлением потоками. Каждый раз, когда вы загружаете web-страницу, протокол TCP согласует передачу между двумя компьютерами, гарантируя, что независимо от относительной скорости процессора и дисков, вся передача данных происходит со скоростью, с которой обе машины могут справиться [1]. В последнем примере сделана довольно грубая попытка управления потоками. Приоритет рабочего потока установлен так, чтобы планировщик отдавал основному потоку VCL предпочтение перед рабочим всякий раз, когда оба не простаивают. Под управлением Win32 проблема снимается, но абсолютной гарантии нет.

Еще один аспект управления потоками - неограниченный размер буфера в последнем примере. Во-первых, это создает проблему неэффективности - основной поток должен выполнить много перемещений памяти при удалении первого элемента большого списка строк, а во-вторых, это означает, что с использованием описанного метода управления потоками буфер может расти без предела. Попробуйте удалить строку, которая устанавливает приоритет потока. Вы увидите, что рабочий поток выдает результаты быстрее, чем поток VCL может их обработать, что приводит к увеличению списка. Это замедляет поток VCL еще сильнее (так как действия по удалению строк происходят дольше для большого списка), и проблема усугубляется. В конечном вы увидите, что список становится достаточно большим, заполняет всю память, машина начинает сбоить, и все останавливается. Фактически при испытании примера я не мог заставить Delphi реагировать на запрос выхода из программы и пришлось прибегать к использованию менеджера задач Windows NT, чтобы закрыть процесс!

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

Мьютексы.

Читатель может подумать, что я потратил столько времени на объяснение критических секций, и совсем забыл о мьютексах. Но это не так - просто мьютексы не представляют собой никаких новые концепций. Мьютекс работает точно так же, как и критическая секция. Единственное различие в реализациях Win32 - в том, что критическая секция ограничена использованием в пределах только одного процесса. Если у вас есть единая программа, которая использует несколько потоков, то критическая секция - легкий и удобный способ обеспечения ваших потребностей. Тем не менее, при написании DLL часто возможно использование DLL несколькими разными процессами одновременно. В этом случае вы должны использовать вместо критических секций мьютексы. Хотя Win32 API обеспечивает более обширный диапазон функций для работы с мьютексами и другими объектами синхронизации, чем будет рассмотрено нами, следующие функции аналогичны функциям, приведенным выше для критических секций:

Эти функции хорошо документированы в справке Win32 API, и будут более детально обсуждаться позже.



[1] Протокол TCP выполняет также много других странных и удивительных функций, среди которых, например, копирование с потерей информации или оптимизация размеров окна так, чтобы поток данных соответствовал возможностям не только обеих машин, но также и связывающей их сети, минимизируя задержки и увеличивая пропускную способность. Он также содержит back-off алгоритмы для гарантии того, что несколько TCP-соединений могут разделять одно физическое соединение без монополизации физического ресурса одним из них.

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

© Martin Harvey 2000.
 

Hosted by uCoz