Глава 11. Синхронизаторы и события (Events).

Содержание:

Дополнительные механизмы синхронизации.

В материалах, рассмотренных в предыдущих главах, описаны все основные механизмы синхронизации. В общем, семафоры и мьютексы позволяют программисту, хотя и с некоторыми усилиями, создать все другие механизмы синхронизации. Несмотря на это, есть некоторые ситуации, которые очень часто встречаются в многопоточном программировании, но с использованием описанных механизмов обеспечить синхронихацию в них нелегко. Для решения этих проблем будут введены два новых примитива синхронизации: The Multi Read Exclusive Write Synchronizer (один пишет, многие читают) и Event (событие). Первый теперь является частью библиотеки VCL Delphi, а второй имеется в Win32 API.

Как добиться оптимальной эффективности.

До сих пор все действия с коллективными ресурсами были взаимоисключающими. Все операции чтения и записи были защищенными в том смысле, что в любой момент могло происходить только одно действие чтения или одно - записи. Тем не менее, во многих практически важных ситуациях, когда критический ресурс должен быть доступен большому количеству потоков, такой метод может оказаться неэффективным. Исключающая блокировка фактически скорее является перестраховкой. Напомню, что в Главе 6 отмечалось, что включает необходимая минимальная синхронизация:

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

Эти условия удовлетворяются на практике довольно часто. Например, база данных склада компании может содержать много элементов, и могут происходить многочисленные действия чтения для определения доступности неких товаров. С другой стороны, база данных корректируется, только когда товары действительно заказываются или отгружаются. Аналогично списки членов общества могут часто проверяться для нахождения адресов, посылки почтовых отправлений или проверки подписки, но введение новых участников, удаление их, или изменение адресов будет происходить довольно редко. Та же самая ситуация и в работе компьютера: основные списки глобальных ресурсов в программе могут часто читаться, но редко изменяться. Требуемый уровень управления параллельным исполнением обеспечивается примитивом, который называется Multiple Read Exclisive Write Synchronizer, в дальнейшем MREWS.

Большинство синхронизаторов поддерживают четыре основных действия: StartRead, StartWrite, EndRead and EndWrite. Поток вызывает StartRead для конкретного синхронизатора, когда он хочет читать из коллективного ресурса. Затем он выполняет одно или более действий чтения, которые гарантированно будут атомарными и последовательными. Как только поток завершает чтение, то вызывает EndRead. Если две операции чтения выполняются внутри данной пары вызовов StartRead и EndRead, то полученные при этом данные всегда правильные: никаких операций записи между вызовами StartRead и EndRead уже не произойдет.

Аналогично при выполнении серии операций записи поток вызывает StartWrite. Затем он может выполнять одно или несколько действий записи, и можно быть уверенным, что все операции записи атомарны. По окончании записи поток вызывает EndWrite. Операции записи не будут перекрываться другими операциями записи, а потоки чтения не смогут получить некорректных результатов из-за чтения в процессе записи.

Простой MREWS.

Есть несколько путей осуществления примитива MREWS. В VCL содержится довольно сложная реализация. Для того, чтобы познакомиться с основными принципами, здесь приведена более простая, но несколько менее функциональная реализация с использованием семафоров. Простой MREWS содержит следующие элементы:

Чтение и запись можно описать так:

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

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

Важные моменты реализации.

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

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

Пример использования простого MREWS.

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

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

Сначала давайте рассмотрим код списка контрольных сумм. Вот он. Основные операции:

Для всех этих публично доступных операций в начале и конце их производятся соответствующие вызовы синхронизации.

Заметьте, что есть методы, название которых начинается с "NoLock". Это методы, которые нужно вызывать из нескольких опубликованных методов. Класс написан таким образом из-за ограничений нашего синхронизатора: вложенные вызовы начала чтения или записи недопустимы. Все действия, которые используют простой синхронизатор, должны вызывать только StartRead или StartWrite, если они уже закончили все предыдущие операции чтения или записи. Более подробно это будет обсуждаться позже. За этим исключением, большая часть кода списка контрольных сумм довольно стандартна, представляет собой в основном обычную обработку списка, и не должна составить никаких трудностей для большинства Delphi-программистов.

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

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

Эта программа не выполняет операций над разделяемыми данных строго атомарным способом. Есть несколько мест в потоке обновления, где неявно подразумевается, что локальные данные корректны, в то время как соответствующий файл, возможно, был изменен. Хороший пример этого - функция потока "check file". Когда контрольная сумма файла вычислена, поток читает загруженную контрольную сумму для этого файла и корректирует ее при несовпадении с только что вычисленной. Эти две операции не атомарны, поскольку не атомарны многократные вызовы объекта списка контрольных сумм. Это проистекает главным образом из того, что с нашим простым синхронизатором не работают вложенные вызовы синхронизации. Одно из возможных решение - дать объекту списка контрольных сумм два новых метода: "Блокировка для чтения" и "Блокировка для записи". Блокировку можно было бы применить для монопольного захвата общих данных, либо для чтения, либо для записи, и тогда проводить многократные операции чтения или записи. Тем не менее, это еще не решает всех возможных проблем синхронизации. Более передовые решения будут обсуждаться в этой главе позже.

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

Введение в события (Events).

События, возможно, одни из самых простых для понимания примитивов синхронизации, но я предпочел оставить рассказ о них до этого момента, поскольку их лучше всего использовать совместно с другими методами синхронизации. Есть два типа событий: события ручного сброса manual reset и автоматического auto reset. Сейчас мы рассмотрим события ручного сброса. Событие работает подобно светофору (или стоп-сигналу для читателей из США). У него есть два возможных состояния: сигнальное (аналогично зеленому светофору) и несигнализированное (аналогично красному светофору). Когда событие в сигнальном состоянии, потоки, ожидающие события, не заблокированы и продолжают выполнение. Когда состояние несигнализированное, потоки, ожидающие события, заблокированы, пока оно не перейдет в сигнальное состояние. Win32 API предоставляет набор функций для работы с событиями.

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

Моделирование событий с помощью семафоров.

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

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

Простой MREWS с использованием событий.

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

Прежде всего, простой синхронизатор рассчитывал, должны ли потоки блокироваться, в разделе критической секции процедур StartRead и StartWrite, и затем выполнял необходимые действия по блокировке за пределами критической секции. То же самое необходимо и для нашего нового синхронизатора с событиями. Чтобы это сделать, мы присвоим значение локальной переменной "Block" (помните, что локальные переменные потокобезопасны). Это делается в критической секции DataLock, чтобы гарантировать правильные результаты, а блокирующие действия выполняются за пределами критической секции, чтобы избежать тупика.

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

MREWS в Delphi.

Основная проблема с существующими синхронизаторами в том, что они не реентерабельные. Совершенно невозможно делать вложенные вызовы StartWrite, так как сразу произойдет зацикливание. Возможно делать вложенные вызовы StartRead при условии, что никакие потоки не вызывают StartWrite в середине последовательности вложенных вызовов StartRead. Если же это происходит, неизбежно зацикливание. В идеале мы хотели бы иметь возможность вложенных вызовов как действий чтения, так и записи. Если поток является активным потоком чтения, то повторные вызовы StartRead не должны иметь эффекта, если они соответствуют равному количеству вызовов EndRead. Аналогично, вложенные вызовы StartWrite должны также быть возможны, но внешняя пара вызовов StartWrite и EndWrite не должна работать.

Вторая проблема состоит в том, что показанные до сих пор синхронизаторы не разрешали атомарные операции чтение-модификация-запись. В идеальном случае должно быть возможно для единственного потока вызывать: StartRead, StartWrite, EndWrite, EndRead, что позволяет прочитать, изменить и записать значение атомарно. Другим потокам нельзя разрешать записывать в любой момент последовательности, и им нельзя читать в течение внутреннего этапа записи. Для описанных синхронизаторов вполне возможно это сделать, просто выполняя операции чтения и записи внутри пары вызовов StartWrite и EndWrite. Тем не менее, если вызовы синхронизации внедрены в разделяемый объект данных (как в примере), то может быть очень трудно обеспечить удобный интерфейс к этому объекту, позволяющему проводить операции чтение-модификация-запись, не обеспечив также отдельные вызовы синхронизации блокировки объекта для чтения или записи.

Для того, чтобы все это сделать, требуется существенно более сложная реализация, в которой для каждой операции при старте и в конце проверяется, какие именно потоки в данный момент выполняют действия чтения и записи. Фактически все это делает синхронизатор Delphi. К несчастью, лицензионные соглашения не позволяют показать здесь исходный код VCL и обсудить, как именно он работает. Тем не менее, достаточно сказать, что MREWS Delphi:


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

© Martin Harvey 2000.
 

Hosted by uCoz