В простых программах на Delphi, написанных начинающими работать с потоками, синхронизация часто является частью логики приложения. Как продемонстрировано в предыдущей главе, очень легко допустить неуловимые ошибки в логике синхронизации, а разработка отдельной схемы синхронизации для каждого приложения требует большого труда. Лишь немногие механизмы синхронизации используется неоднократно: почти весь потоки, связанные с вводом-выводом, обмениваются данными через общие буферы, и часто используются списки и очереди без встроенной синхронизации. Эти факторы указывают, что следует уделить внимание построению библиотеки потокобезопасных объектов и структур данных: проблемы, возникающие в межпотоковом обмене- непростые, но несколько общих решений подойдут почти во всех случаях.
Иногда необходимо написать потокобезопасный класс, поскольку никакой другой метод неопустим. Код в DLL, который имеет доступов к уникальным системным данным, должен содержать синхронизацию потоков, даже если DLL и не содержит никаких объектов потоков. Так как большинство программистов на Delphi использует средства языка (классы) для обеспечения возможности модульной разработки и повторного использования кода, эти DLL будут содержать классы, и эти классы должны быть потокобезопасными. Некоторые могут быть довольно простыми, как, например,вышеописанные общие буферные классы. Тем не менее, вполне вероятно что некоторые из этих классов могли осуществлять блокировку ресурсов или другие механизмы синхронизации специфическими средствами ради решения конкретной задачи.
Классы могут быть самыми разными, программисты с определенным опытом в Delphi знают, что концепция класса используется многими способами. Некоторые классы используются в основном как структуры данных, другие - как обертки для упрощения сложной внутренней структуры. Иногда семейства совместно работающих классов использутся, чтобы обеспечивать гибкость в достиженеии общей цели, как хорошо демонстрирует механизм потков данных (streams) в Delphi. Аналогичное разнообразие существует и среди потокобезопасных классов. В некоторых случаях классификация может получиться немного расплывчатой, но тем не менее, можно выделить четыре различных типа потокобезопасных классов.
Это самый простой тип многопоточного класса. Обычно расширяемый класс имеет довольно ограниченную функциональность и самодостаточен. В простейшем случае создание поттокобезопасного класса может состоять просто из добавления мьютекса и двух дополнительных функций - методов класса, Lock (блокировка) и UnLock. Кроме того, функции, манипулирующие данными класса, могут выполнять блокировку и операции разблокировки автоматически. Какой метод использовать, зависит в основном от количества возможных операций с объектом, и желания программиста самостоятельно создать функции блокировки для обеспечения атомарности составных действий.
Это небольшое расширение вышеуказанного типа, обычно они состоят из буферных классов: списков, стеков и очередей. Дополнительно к поддержке атомарности, эти классы могут выполнять автоматическое управление потоками данных в работающем с буфером потоке. Это часто состоит в задержке потоков, пытающихся читать из пустого буфера или писать в заполненный. Разработка таких классов более полно обсуждается в Главе 10. Множество оперыций может поддерживаться одним классом: с одной стороны, будут обеспечиваться полностью неблокировки действия, с другой стороны, все действия могут блокироваться если они не могут успешно завершить. Компромисс часто достигается, когда действия асинхронны, но обеспечивают обратную связь или обмен сообщениями, когда прежде неудачное действие, вероятно, достигнет цели. Сокеты Win32 API является хорошим примером интерфейса потока данных, которые осуществляют все вышеуказанные возможности в том,что касается управление потоками.
Мониторы являются логическим шагом вперед от классов управления потоками данных. Они обычно допускают параллельный доступ к данным, которые требуют более сложной синхронизации и блокировки, чем обеспечивает простая потокобезопасная инкапсуляция существующего класса Delphi. Системы управления базами данных относятся к наиболее высокой категории мониторов: обычно в них предусмотрена сложная блокировка и схема управления транзакциями для обеспечения максимальной степени параллелизма при доступе к общим данным с минимальным ущербом производительности из-за конфликтов потоков. СУБД работают довольно специфическим образом, используя управление транзакциями для тщательного контроля над составными операциями, и при этом они также обеспечивают гарантии непрерывности выполняемых операций до самого их завершения. Другим хорошим примером монитора является файловая система. Файловые системы Win32 позволяют нескольким потокам иметь доступ ко многочисленным файлам, которые могут быть открыты несколькими разными процессами одновременно в различных режимах. Большая часть хорошей файловой системы состоит из управления дескрипторами и блокировочных схем, которые обеспечивают оптимальную производительность, гарантируя при этом сохранение атомарности и непрерывности операций. Все потоки могут обращаться к файловой системе, и она гарантирует, что никакие операции не будут вступать в конфликт, и как только операция завершится, ее результат непременно будет записан на диск. В частности файловая система NTFS базируется на журнале событий ("log based"), и обеспечивает сохранность данных даже в случае отказа питания или зависания операционной системы.
Как уже упоминалось ранее, списки, стеки и очереди часто применяются при реализации задачи взаимодействия потоков. Класс TThreadList осуществляет базовый вид синхронизации, требующийся для потоков. Вдобавок ко всем методам, имеющимся в TList, предусмотрены два дополнительных: Lock и UnLock. Использование их должно быть довольно очевидно для читателей, которые проработали предыдущие главы: список блокируется перед манипуляциями с данными и разблокируется после них. Если поток выполняет над списком многочисленные действия , который должны быть атомарны, то список должен оставаться блокированным. Список не осуществляет никакой неявной синхронизации для принадлежащих ему объектов. Программист может при желании разработать механизмы дополнительной блокировки для обеспечения таких возможностей, а кроме того, использовать блокировку списка для контроля всех операций со структурами данных, принадлежащими ему.
Этот класс предоставляет виртуальные методы Acquire и Release, которые используются во всех главных классах синхронизации Delphi, предоставляя основу для реализации концепции владения, блокировки или захвата простых объектов синхронизации, подобно тому, как уже обсуждалось ранее. Классы критической секции и классы событий (event) являются его наследниками.
Этот класс не нуждается в подробном описании. Я подозреваю, что его включение в Delphi - просто дань тем программистам, кто испытывает антипатию к Win32 API. Следует только отметить, что он предоставляет четыре метода: Acquire, Release, Enter и Leave. Два последних только вызывают два первых, для удобства программистов, предпочитающих один из наборов терминов.События (Event) - несколько другой способ обеспечения синхронизации. Вместо осуществления взаимного исключения, они применяются, чтобы заставить какое-то количество потоков ожидать, пока что-то не произойдет, а при возникновении этого события освобождать один или все эти потоки. TSimpleEvent - класс события, который определяет различные параметры по умолчанию, наиболее вероятно использующиеся в приложениях Delphi. События тесно связаны с семафорами и обсуждаются в последующей главе.
Это объект синхронизации полезен в ситуациях, когда многим потокам нужно читать из коллективного ресурса, но запись в него ведется сравнительно редко. В таком случае часто нет необходимости полностью блоктровать ресурс. В первых главах я утверждал, что любое несинхронизированное использование общих ресурсов, вероятно, приведет к конфликтам потоков. Хотя это и верно, не всегда обязательно использовать полное взаимное исключение. Полное взаимное исключение подразумевает, что в каждый момент только один поток осуществляет какую-то операцию. Мы можем ослабить это требование, если понимаем, что есть два основных типа конфликтов потоков:
Конфликты первого рода происходят, если один поток пишется в раздел ресурса после того, как другой поток прочитал это значение, и считает его верным. Этот тип конфликтов проиллюстрирован в Главе 3. Конфликт второго рода бывает, когда два потока пишут в общий ресурс, один после другого, причем читающий поток не знает о более ранней записи. Это приводит к уничтожению первой записи. Конечно, некоторые действия вполне допустимы: чтение после чтения, чтение после записи. Эти две операции постоянно выполняются в однопоточных программах! Это, очевидно, указывает, что мы можем немного ослабить критерии согласованности работы с данными. Минимальные условия:
Синхронизатор MultiReadExclusiveWriteSynchroniser обеспечивает эти условия с помощью четырех функций: BeginRead, BeginWrite, EndRead и EndWrite. При вызове их до и после записи достигается необходимая синхронизация. Что же касается программиста, то он может рассматривать этот синхронизатор как очень похожий на критическую секцию, с тем исключением, что поток использует его или для чтения или для записи.
Хотя в последующих главах и рассматриваюся детали создания потокобезопасных классов и различные преимущества и ловушки, в которые можно попасть при их проектировании, видимо, стоит отметить несколько простых пунктов, которые нужно будет всегда учитывать.
Ответственность за блокировку в потокобезопасном классе можно отдать на откуп или разработчику класса, или программисту - пользователю класса. Если класс обеспечивает только простую функциональность, обычно лучше, чтобы за блокировку отвечали пользователи класса. Они, вероятно, будут используют несколько экземпляров данного класса, и дав им ответственность за блокировку, мы гарантируем, что не будет неожиданных зацикливаний, а также даем им выбор уровня блокировки, чтобы добиться либо простоты, либо эффективности. Для более сложных классов, например, мониторов, ответственность за блокировку обычно перекладывают на сам класс (или набор классов), скрывая таким образом сложность блокировки от конечного пользователся класса
В целом, ресурсы должны блокироваться по возможности минимально, и их блокировка должна тщательно настраиваться. Хотя упрощенные схемы блокировки схем и уменьшают шансы внесения в код трудноуловимых ошибок, они могут значительно снизить преимущества в производительности при использовании потоков. Конечно, нет ничего плохого в том,чтобы начинать с простого, но при возникновении проблем с производительностью схему блокировки придется изучатьи проверять более тщательно.
Ничто не работает всегда без ошибок. При использовании вызовов Win32 API, учтите возможность неудачи операции. Если вы относитесь к тем программистам, кто не против проверять тысячи кодов ошибок, то это выполнимо. Иначе вы можете захотеть написать класс-обертку, который инкапсулирует возможности объектов синхронизации Win32, вызывая исключения, когда происходят ошибки. В любом случае хорошо подумайте об использовании блоков try... finally, чтобы в случае неудачи гарантировать, что объекты синхронизации останутся в предсказуемом состоянии.
Вся потоки созданы равными, но некоторые более равны, чем другие (Orwell, Animal Farm). Планировщик должен поделить время CPU между всеми потоками, в любой момент работающими на машине. Для этого ему нужно иметь некоторое представление о том, насколько каждый поток будет использовать CPU, и как важно исполнять конкретный поток, когда он готов работать. Большинство потоков ведут себя одним из двух способов: во время выполнения они задействуют в основном или CPU или ввод/вывод.
Интенсивно использующие CPU потоки обычно выполняют долгие численные расчеты в фоновом режиме. Они будут занимать все отведенные им ресурсы процессора, но редко будут приостанавливаться для ожидания ввода-вывода или взаимодействия с другими потоками. Довольно часто время их выполнения не особенно критично. Например, поток в программе компьютерной графики может осуществлять долгую операцию по обработке изображения (фильтрация или вращение картинки), и она может занять несколько секунд или даже минут. С точки зрения планировщика, выделяющего кванты процессорного времени, этому потоку никогда не нужно запускаться безотлагательно, так как пользователя не волнует, двенадцать или тринадцать секунд займет выполнение этой операции, и никакой другой поток в системе не ждет результатов этого действия как можно скорее.
По другому обстоит дело с выделением времени потокам, связанным с вводом-выводом. Они обычно не занимают много процессорного времени, и могут состоять из сравнительно небольших кусков кода обработки. Они очень часто приостанавливаются (блокируются) устройствами ввода-вывода, а когда они получают информацию, то обычно запускаются на короткое время, обрабатывают ввод, и почти немедленно приостанавливаются снова, если больше нет доступной для обработки информации. Примером может служить поток, обрабатывающий действия по перемещению мыши и коррекции положения курсора. Каждый раз при передвижении мыши поток запускается на очень малую долю секунды, обновляя курсор, и затем приостановливается. Потоки подобного типа обычно гораздо более критичны ко времени: они не запускаются надолго, но их запуск должен происходить немедленно. В большинстве GUI систем неприемлемо, чтобы курсор мыши не откликался на ввод даже в течение короткого периода времени, и, следовательно, поток, отвечающий за работу с мышью, является довольно критичным ко времени. Пользователи WinNT могут обратить внимание, что даже когда компьютер занимается интенсивными вычислениями, курсор мыши реагирует немедленно. Весь операционные системы с вытесняющей могозадачностью, включая Win32, обеспечивают поддержку этих концепций, разрешая программисту назначать "приоритеты" потоков. Обычно потоки с более высокими приоритетами связаны со вводом-выводом, а потоки с более низкими приоритетами связаны с вычислительными задачами процессора. Реализация приоритетов потоков в Win32 слегка отличается от реализации, например, в UNIX, так что обсуждаемые здесь детали относятся только к Win32.
Большинство операционных системы назначают потокам приоритет для определения, сколько времени CPU должен получать каждый поток. В Win32 фактический приоритет каждого потока вычисляется динамически исходя из множества факторов, некоторые из которых могут непосредственно быть установлены программистом, а некоторые - нет. К этим факторам относятся класс приоритета (Priority Class) процесса, уровень приоритета (Priority Level) потока, используемые вместе для определения базового приоритета (Base Priority) и уровня повышения приоритета (Priority Boost), действующих для этого потока. Класс приоритета устанавливается для каждого запущенного процесса. Почти для всех приложений Дельфи он будет Normal, за исключением скринсейверов, которым можно установить класс приоритета Idle. Обычно программисту в Дельфи не нужно изменять класс приоритета запущенного процесса. Уровень приоритета каждого потока можно затем установить в рамках класса , назначенного для процесса, что более полезно, и программист может использовать вызов API SetThreadPriority для изменения уровня приоритета потока. Допустимые значения параметра: THREAD_PRIORITY_HIGHEST, THREAD_PRIORITY_ABOVE_NORMAL, THREAD_PRIORITY_NORMAL, THREAD_PRIORITY_BELOW_NORMAL, THREAD_PRIORITY_LOWEST и THREAD_PRIORITY_IDLE. Поскольку реальный базовый приоритет потока вычисляется на основе как уровня приоритета, так и класса приоритета процесса, потоки с уровнем приоритета Above Normal в процессе с классом приоритета Normal будут обладать большим базовый приоритетом, чем потоки с уровнем приоритета Above Normal в процессе с классом приоритета Below Normal. Как только базовый приоритет потока вычислен, он остается фиксированным на все время жизни потока или пока уровень приоритета (или класс процесса) не изменится. Тем не менее, фактический приоритет, используемый в каждый момент, планировщик задач слегка изменяет в результате повышения приоритета.
Повышение приоритета является механизмом, используемым планировщиком, чтобы постараться учесть поведение потоков во время выполнения. Некоторые потоки будут полностью занимать CPU или ввод/вывод во время своего исполнения, и планировщик может повысить приоритет этих потоков, но не выделит им полный квант времени. Кроме того, потокам, которые владеют дескрипторами окон, находящихся в данный момент на переднем плане (foreground) также дается небольшое повышение приоритета для улучшения реакции на действия пользователя
Какой приоритет дать моему потоку?
Поняв основы обращения с приоритетам, мы можем теперь попытаться назначить
нужные уровни приоритетов уровни потокам нашего приложения. Учтите, что по умолчанию
поток VCL выполняется с уровнем приоритета normal. В обшем большинство Дельфи-приложений
пишутся так, чтобы по возможности обеспечивать пользователю наиболее быструю
ответную реакцию, так что редко нужно увеличивать приоритет потока выше normal
- при этом всякий раз при выполнении потока будет происходить задержка многих
действий, например, перерисовки окна. Большинство потоков, имеющих дело со вводом/выводом
или передачей данных в приложениях Дельфи можно оставить приоритетом normal,
так как, если нужно, планировщик задач даст потоку больше времени, а если поток
занимает слишком много процессорного времени, то увеличения доли времени не
произойдет, что приводит к разумной скорости операций в основном потоке VCL.
И наоборот, понижение приоритетов может быть очень полезно. Если вы уменьшите
приоритет потока, выполняющего фоновую интенсивную обработку, требующую вычислительных
ресурсов, машина покажется пользователю лучше откликающейся на его действия,
чем если бы у этого потока оставить нормальный приоритет. Обычно пользователь
значительно терпимее относится к небольшим задержкам в выполнении низкоприоритетных
потоков: он может переключиться на другие задачи, и при этом компьютер и приложение
не теряют восприимчивости к вводу.
© Martin Harvey
2000.