Поставим COM на поток
|
Автор: Денис Мигачев
Всего существует 10 видов программистов. Первый — те, которые знают двоичную систему исчисления, и второй — которые ее не знают.
|
В качестве лирического отступления — маленький экскурс в историю. На самой заре развития компьютерной техники, когда компьютеры были дорогими и работали о-о-очень медленно по сегодняшним меркам, о потоках как таковых не было и речи. Компьютеры тогда старались всегда загрузить под завязку. Машины могли обрабатывать потоки выполнения приложений только в порядке живой очереди — так, как их заносил в машину программист. Это было крайне неудобно, так как приходилось тратить массу времени на поиск и исправление ошибок, если они возникали. Впоследствии машины стали совершеннее, быстрее и меньше. Также были созданы операционные системы (UNIX), которые могли работать уже с несколькими задачами. В них все задачи загружались в ОЗУ, и начиналось их выполнение. При достаточно быстром переключении процессора с задачи на задачу у пользователя создавалось впечатление одновременной работы с несколькими процессами. Тогда же и появился термин вытесняющая многозадачность, который и описывал такой способ обработки информации и взаимодействие потоков в памяти.
Класс TThread. Но если операционная система поддерживает вытесняющую многозадачность, почему бы не пойти дальше и не заставить несколько потоков выполняться в рамках одного приложения? Такой режим работы приложения называют многопоточным, и тогда говорят, что каждое приложение выполняется в отдельном потоке. Но в русском языке "поток" — это и поток выполнения, и поток данных. В этой же статье будет рассказано о потоках выполнения.
Хотя программист может создать какое угодно количество потоков, не рекомендуется использовать их более 16 — это относительное ограничение накладывается однопроцессорными платформами. Так как переключение между потоками занимает определенное время, то время их общего выполнения заметно увеличивается при использовании большого количества потоков.
В среде Delphi существует абстрактный класс TThread, от которого можно создать потомка и перекрыть абстрактный метод Execute, как показано на примере:
type
TNewThread = class(TThread)
protected
procedure Execute; override;
end;
...
procedure TNewThread.Execute;
begin
...
end;
Код, содержащийся внутри метода Execute, выполняется в отдельном потоке. Для его запуска необходимо создать экземпляр класса TNewThread. При запуске любого приложения автоматически создается главный поток. В нем осуществляется прием сообщений операционной системы, прорисовка графики, обработка событий, если их обработка не выделена в отдельный поток. Вызов метода Execute означает, что для него создается фоновый поток, код которого будет выполняться параллельно с кодом главного потока, периодически прерывая его. Стоит отметить, что такая схема выполняется, когда на машине стоит один процессор или больше, но они все заняты. В идеальном же случае фоновый поток не прерывает главного, а выполняется параллельно на другом процессоре. Фоновый поток можно или перевести в состояние ожидания методом Suspend, и тогда процессор не будет выделять ему времени, поток будет находиться как бы в состоянии "заморозки", или снова запустить его методом Resume. Можно также сразу перевести поток в состояние ожидания еще при создании, передав в качестве параметра конструктору Create логическую переменную CreateSuspended. Если значение этой переменной — истина (true), то конструктор полностью выполняется, но поток после этого находится в ожидании. Для его запуска надо вызвать метод Resume. Если передается значение-ложь (false), то код метода Execute начинает отрабатываться сразу же после завершения работы конструктора. В случае передачи ложного значения нельзя инициализировать свойства класса. После исполнения кода конструктора код, реализованный в методе Execute, выполняется в отдельном потоке. Следовательно, код в этом потоке может быть выполнен раньше, чем свойства класса будут проинициализированы.
В примере ниже как раз показан фрагмент такого потенциально опасного кода:
type
TNewThread = class(TThread)
protected
procedure Execute; override;
end;
...
procedure TNewThread.Execute;
var
i: real;
begin
...
i := sqrt(j - 1);
...
end;
procedure TFrmMain.StartButtonClick(Sender: TObject);
var
j: single;
begin
with TNewThread.Create(false) do
j := 5;
end;
end;
На первый взгляд, приведенный код не содержит ошибки. Однако если оператор i:= sqrt(j-1); метода Execute выполнится раньше оператора j:= 5; из метода StartButtonClick, то значение переменной j будет равно 0. В этой ситуации будет возбуждено исключение. Поиск и устранение подобных ошибок трудны тем, что такая ошибка может проявляться периодически, а то и очень редко. Чтобы ошибки не возникало, необходимо переписать последнюю процедуру вот так:
procedure TFrmMain.StartButtonClick(Sender: TObject);
begin
with TNewThread.Create(true) do
begin
j := 5;
Resume;
end;
end;
В этом примере уже сначала создается экземпляр класса TNewThread в режиме ожидания, затем присваивается начальное значение переменной и только после этого вызывается метод Resume;, который начинает выполнение кода. Это единственно правильный способ инициализации данных, так как поток не запускается до завершения процесса присваивания значений переменным.
Как уже говорилось, порожденный в приложении поток выполняется параллельно с главным потоком программы. Существует такое свойство как Priority, определяющее, какую долю времени должна выделять операционная система на обработку потока по сравнению с главным потоком. Он может принимать семь предопределенных значений: tpIdle, tpLowest, tpLower, tpNormal, tpHigher, tpHighest, tpTimeCritical. По умолчанию выставляется четвертый параметр — tpNormal, что означает одинаковое выделение времени на обслуживание двух потоков. Поток с приоритетом tpIdle практически не влияет на выполнение главного потока, хотя процессор и выделяет время на его выполнение. Также не стоит бросаться и в другую крайность — tpHighest или tpTimeCritical. При первом приоритете у дочернего потока главный практически не будет выполняться, не будет успевать прорисовываться графика на экране. И если вы в своей программе показываете значение какого-либо счетчика на форме, то, скорее всего, при выполнении фонового потока с таким приоритетом значение счетчика на форме не будет обновляться до завершения работы потока. Второй приоритет имеют многие функции ядра операционной системы, выполняющиеся асинхронно. При его применении может случиться так, что еще до завершения выполнения метода может быть вызван следующий оператор из созданного приложения. Во избежание краха программы использовать этот приоритет крайне нежелательно. В основном процессы с приоритетом реального времени находят свое применение в некоторых играх, использующих очень быстрый вывод изображения на экран.
В завершение темы о приоритетах выполнения потоков хочу предостеречь от попыток синхронизации с их помощью. Для этой задачи существуют специальные сигнальные объекты. Им будет уделено внимание чуть ниже.
При завершении работы потока, если он вам больше не нужен в программе, вызывается метод Terminate. Он устанавливает свойство только для чтения Terminated в true. Однако это не вызывает прерывания исполнения кода метода Execute. Проверка этого свойства ложится на плечи программиста. Во время написания кода метода Execute он должен периодически проверять значение Terminated. И если оно истинно, то самостоятельно прерывать выполнение метода максимально быстро. При этом, конечно, освободив системные ресурсы, если они были зарезервированы для потока. Стоит упомянуть свойство FreeOnTerminate класса TThread, которое определяет, будет ли вызван деструктор после выполнения метода Execute (свойство установлено в true) или нет (свойство установлено в false). В последнем случае необходимо явно в программном коде вызывать деструктор.
И, наконец, из фоновых потоков не должны вызываться исключения. Приложение не сможет их корректно обработать, так как они возбуждены вне главного потока. Поэтому следует весь код в методе Execute заключать в рамки блока перехвата исключения try...except. Ситуация осложняется еще и тем, что отладчик Delphi их корректно обрабатывает, и в режиме отладки их нельзя обнаружить.
Синхронизация. Все, наверное, пробовали запускать на исполнение имеющийся в поставке Delphi 6 пример Threads, в котором визуально показывается разница в скорости трех сортировок. Наверняка каждый находил процедуру с использованием метода Synchronize(). Этот метод в качестве параметра принимает адрес другого метода, который должен быть объявлен как процедура и не должен содержать параметров. Чтобы лучше понять, как этот метод работает, можно его закомментировать и посмотреть, как будет работать приложение без него. После нажатия на кнопку Start Sorting попробуйте поводить мышью над изменяющимися колонками полосок. Результатом этого будет появление "артефактов", из-за которых полученная картинка не будет соответствовать действительному положению сортируемых элементов. Кроме того, если запускать это приложение не из операционной системы, а из Delphi 6, периодически будет появляться исключение EIllegalOperation со следующим сообщением — "Canvas does not allow drawing" ("холст не может допустить прорисовку"). Объясняется это тем, что потоки при выполнении прерывают друг друга в не самые подходящие для этого моменты. Если несколько потоков используют одни и те же переменные, то во избежание модификации значений переменных другим потоком надо применять синхронизацию. В нашем примере несколько потоков пытаются одновременно прорисовать графику, которая выводится во время их работы. Кроме того, еще и главный поток обрабатывает движение мыши над формой. А происходит это так. Указатель мыши смещается в новое положение. Система запоминает участок экрана, на котором следует произвести прорисовку указателя мыши. Какой-либо из потоков прерывает выполнение главного потока и прорисовывает на форме новый графический вид сортировки. Выводится изображение указателя мыши. При этом может быть нарушено изображение, выведенное до этого потоком, который прервал выполнение главного потока приложения. В результате этого и появляется дефект.
Так как вышеприведенный пример является сильно упрощенным, ведь обращение к графической памяти — процесс намного более сложный, приведу еще один. Рассмотрим условный буфер в памяти, куда потоки заносят даты, с которыми они работают. Допустим, что один поток записывает туда дату "23 октября 2002". Второй после него пытается считать ее, начинает считывание и считывает "23 окт", после чего третий поток прерывает его и вносит свои изменения — "23 января 2002". После этого снова доступ к буферу получает второй поток, но теперь уже он считывает полную абракадабру — "23 октаря 2002". Если теперь занести данное значение в базу данных, то возникнут трудности с последующей интерпретацией этой строки. Использование не постоянного буфера, а переменной типа string не избавляет от ошибок, могут происходить нарушения в защите памяти. При изменении длины строки система перераспределяет памяти и при выполнении вышеописанного процесса может произойти обращение к области памяти, которая уже не содержит данных.
Таким образом, если два или более потока обращаются к какой-либо общей переменной, требуется написание специального кода, смысл которого состоит в том, что, если какой-либо поток работает с общей переменной, то другие потоки не имеют права прервать его выполнение до окончания работы с ней. Такой код обеспечивает синхронизацию доступа к данным. Синхронизация может быть не нужна в том случае, если потоки выполняют только чтение данных. Но если хоть один из потоков изменяет данные, тогда требуется синхронизация, причем как при записи данных, так и при их чтении. Подобное разделение доступа осуществляет метод Synchronize класса TThread при синхронизации главного потока и фонового. Ясно, что этот метод нельзя использовать при синхронизации доступа к данным из двух потоков. Для этого требуется знание специальных объектов — мьютексов, семафоров, критических секций, сообщений.
Но существует еще один вид синхронизации — синхронизация процессов. Синхронизацию такого типа бывает необходимо производить, когда для завершения работы одного потока необходимо получить данные, полученные в результате вычислений в другом потоке. Это является стандартной ситуацией при проектировании распределенных приложений, когда расчеты производятся на нескольких компьютерах. Для синхронизации процессов используются те же способы, что и для синхронизации данных. Если процессы выполняются в разных адресных пространствах, то для синхронизации нельзя использовать критические секции. Как я уже упоминал выше, один процесс не может получить указатель на переменную в адресном пространстве другого процесса.
Специально для этого в классе TThread определен метод WaitFor. Вызов этого метода означает прекращение выполнения главного потока и ожидание завершения работы фонового потока.
Но существует одно серьезное ограничение. Ввиду того, что в операционную систему Windows 2000 внесены некоторые изменения, да и в код VCL вкралась ошибка, вызов деструктора можно произвести до завершения работы кода метода WaitFor. Это приводит к возникновению EOSException. Поэтому, если необходимо вызывать в приложении метод WaitFor, свойство FreeOnTerminate должно иметь значение false.
Передача интерфейсов и параметров. Операционная система Windows является многопоточной средой. Применительно к COM это означает, что клиент и сервер могут оказаться в разных потоках или разных процессах приложения. А к серверу может обращаться множество клиентов, причем в разное время. В COM эта проблема решена с помощью технологии апартаментов (apartments), в которых и выполняются COM-серверы и COM-клиенты. Апартаменты бывают многопоточные и однопоточные. В однопоточной архитектуре организуется очередь из вызовов методов, и каждый их них выполняется только после того, как обработаны все предшествующие вызовы. В этом случае программисту нет необходимости заботиться о синхронизации методов и доступа к полям класса, реализующего объект. Ведь одновременно может выполняться только один метод. Многопоточный апартамент не связан ограничениями, налагаемыми синхронизацией, — он ее просто не реализует. СОМ автоматически ведет пул потоков внутри многопоточного апартамента и при вызове со стороны клиента находит свободный поток и в нем вызывает метод требуемого объекта. То есть теперь вызов методов для другого потока происходит сразу, без ожидания завершения работы первого потока. СОМ-сервер, работающий в многопоточном апартаменте, более доступен для клиентов и более быстр, чем однопоточный. Но такой сервер очень сложен и трудоемок в разработке, так как требует синхронизации даже данных локальных объектов — они тоже не защищены от одновременного доступа.
Клиент и СОМ-сервер могут запускаться в одном или в разных апартаментах, которые расположены в разных процессах или даже на разных компьютерах. Встает вопрос — как же клиент может вызвать методы сервера, если они находятся (в общем случае, конечно же) в другом адресном пространстве? Эту работу берет на себя COM. Для доступа к серверу в другом апартаменте клиент должен запросить у COM-сервера создание в своем апартаменте представителя, реализующего запрошенный интерфейс. Такой представитель в терминах COM называется proxy (прокси) и представляет собой объект, экспортирующий запрашиваемый интерфейс. Одновременно СОМ создает в апартаменте сервера stub (заглушку), принимающую вызовы от прокси и транслирующую их в вызовы сервера. Таким образом, клиент в своем апартаменте может рассматривать прокси как сервер и работать с ним так, как будто сервер создан в его апартаменте. В то же время сервер может рассматривать стаб-заглушку как клиента, работающего в его апартаменте. Всю работу по организации взаимодействия между стабом-заглушкой и прокси берет на себя СОМ. При вызове со стороны клиента прокси получает от него параметры, упаковывает их в особую внутреннюю структуру и передает в апартамент сервера. Стаб получает параметры, распаковывает их и производит вызов необходимого метода сервера. Таким же образом осуществляется передача параметров в обратном направлении. Эти процессы называются marshalling (маршалинг) и unmarshalling соответственно. При этом физически апартаменты клиента и сервера могут находиться где угодно и даже иметь разные модели потоков. Такой вызов означает определенные накладные расходы по сравнению с вызовом сервера в "своем" апартаменте, однако это единственный способ обеспечить корректность работы любых клиентов и серверов. Еще проблемы может вызвать создание прокси, так как серверов, как правило, меньше, чем клиентов. Для корректного создания прокси в клиентском апартаменте COM требуется знать устройство сервера. А иначе как будут упакованы и переданы данные? Узнать больше о сервере можно тремя способами. Реализовать на сервере интерфейс IMarshall и, при необходимости, прокси в виде DLL, которая будет загружена на клиенте. Сделать сервер совместимым с технологией OLE Automation. В этом случае прокси создается СОМ самостоятельно с использованием описания из библиотеки типов. Но тогда в интерфейсе допустимы только совместимые с OLE Automation типы данных. В этом случае для поддержки маршалинга, совместимого с OLE Automation, необходимо, чтобы сервер был унаследован от класса TTypedComObject. Кроме этого, все методы интерфейса должны быть объявлены как процедуры, удовлетворяющие соглашению о вызове safecall. Возможно объявление методов как функций, удовлетворяющих соглашению о вызове safecall и возвращающих значение типа HResult. Если вы создаете интерфейс, унаследованный от IUnknown, когда все методы объявляются по умолчанию как stdcall, необходимо на странице Type Library диалога Environment Options установить галочку в поле All v-tables interfaces в группе Safecall function mapping.
И вообще, если вы создали СОМ-сервер, ориентированный на использование различными клиентами, а не только в рамках конкретного проекта, не рекомендуется делать серверы без поддержки маршалинга данных. В этом случае гарантировать его нахождение в одном апартаменте с клиентом невозможно. Если же все-таки необходимость именно в таком сервере присутствует, следует вести документацию и тщательно описать в ней все спецификации по настройке и работе.
Также можно описать интерфейс на языке IDL (Interface Definition Language) и при помощи компилятора MIDL от Microsoft сгенерировать динамическую библиотеку, в которой реализованы прокси и стаб-заглушка.
Инициализация СОМ. Каким же образом клиенты и серверы СОМ могут создавать апартаменты в соответствии со своими требованиями? Для этого они должны соблюдать одно правило — каждый поток, который желает использовать СОМ, должен создать апартамент путем вызова функции CoInitializeEx. Она объявлена в модуле ActiveX.pas следующим образом.
const
COINIT_MULTITHREADED = 0;
COINIT_APARTMENTTHREADED = 2;
function CoInitializeEx(pvReserved: pointer; coInit:
Longint): HResult; stdcall;
Параметр pvReserved зарезервирован для будущего использования и должен быть всегда равен nil, а параметр coInit определяет модель потоков создаваемого апартамента. Он может принимать значения COINIT_APARTMENTTHREADED и COINIT_MULTITHREADED. В первом случае для потока создается однопоточный апартамент, причем каждый поток может иметь, а может и не иметь свой апартамент. Во втором случае, если в текущем процессе еще не создан многопоточный апартамент, он создается. Если же многопоточный апартамент уже создан другим потоком, текущий поток подключается к готовому апартаменту. Другими словами, каждый процесс может иметь только один многопоточный апартамент.
Вышеописанная функция возвращает значение S_OK в случае успешного создания апартамента.
Каждому вызову CoInitializeEx должен соответствовать вызов CoUninitialize. То есть, если вы работаете с СОМ в приложении, функция CoInitializeEx должна быть вызвана до первого вызова функций СОМ, а CoUninitialize должна быть вызвана по завершении работы приложения. По завершении работы с СОМ (или перед завершением работы) поток должен уничтожить апартамент путем вызова процедуры CoUninitialize. Библиотека VCL выполняет автоматическую инициализацию СОМ при использовании модуля ComObj, и по умолчанию создается однопоточный апартамент. Если вам в приложении необходима другая модель потоков, следует внести исправления в файл проекта *.dpr, установив флаг инициализации СОМ до оператора Application.Initialize:
program Project1;
uses
Forms, ComObj, ActiveX,
Unit1 in 'Unit1.pas' {Form1};
{$R *.RES}
begin
CoInitFlags := COINIT_MULTITHREADED;
Application.Initialize;
Application.CreateForm(TForm1, Form1);
Application.Run;
end;
Если же СОМ используется непосредственно в потоке, то эти функции должны вызываться в методе Execute:
procedure TNewThread.Execute;
begin
CoInitializeEx(nil, COINIT_MULTITHREADED);
...
CoUninitialize;
end;
Совсем другое дело, если СОМ-сервер реализован в DLL. Сервер, расположенный в DLL, не может сам проинициализировать требуемую ему модель потоков. Вместо этого сервер при регистрации прописывает в реестре параметр ThreadingModel, который и указывает, в какой модели потоков может выполняться данный сервер. При создании сервера СОМ анализирует значение этого параметра и при необходимости создает для сервера апартамент с требуемой моделью потоков (рис.1).
Параметр ThreadingModel может принимать три значения.
- Apartment — сервер может работать только в однопоточном апартаменте. Если он из него же и создается, то будет создан в апартаменте вызывающего потока. Если же сервер создается из многопоточного апартамента, то СОМ автоматически создаст для него однопоточный апартамент и прокси в апартаменте клиента.
- Free — такой сервер работоспособен только в многопоточном апартаменте. Если он создается из такого апартамента, то будет создан в апартаменте вызывающего его потока. Если такой сервер вызывается из однопоточного апартамента, СОМ автоматически создаст для него многопоточный апартамент и прокси в апартаменте клиента.
- Both — такой сервер может работать в обеих моделях потоков. При этом объект всегда создается в вызывающем его апартаменте.
Но это те значения, которые мы имеем у ранее созданных серверов. А где разработчику серверов возможно установить модель потоков? При создании СОМ-сервера средствами Delphi 6 или C++ Builder 6 его модель потоков задается в мастере COM Object Wizard (рис.2).
Нет необходимости объяснять в этом мастере значения каждого из полей, так как они неплохо описаны даже в справочной системе самой Delphi 6. А вот подробно описать значения, доступные в раскрывающемся списке Threading Model и не упомянутые выше, стоит.
- Single — модель одного потока. То есть потоков нет как таковых. Обычно эта модель используется для внутренних серверов.
- Both — модель смешанных потоков. Для EXE-сервера будет создан многопоточный апартамент, для DLL-сервера — параметр ThreadingModel со значением Both.
- Neutral — модель нейтральных потоков. Для EXE-сервера создается многопоточный апартамент, для DLL-сервера — параметр ThreadingModel со значением Neutral.
На последней модели потоков стоит остановиться и пояснить ее. Отличие модели нейтральных потоков от модели свободных потоков заключается в том, что, если какой-либо из клиентов вызвал один из методов интерфейса, другой клиент не сможет вызвать тот же метод, он будет ожидать завершения вызова первым клиентом. Также модель нейтральных потоков упрощает синхронизацию доступа к полям объектов. Когда с любым полем объекта работает только один метод интерфейса, синхронизация не требуется вообще. При работе с глобальными переменными синхронизация, тем не менее, необходима.
К сожалению, выбор определенного значения из раскрывающегося списка никак не влияет на корректность работы создаваемого сервера в выбранной модели потоков. Поэтому программист сам должен думать, какие потоки могут быть созданы в приложении и как их синхронизировать.
|