Понимание потоковых моделей в COM при программировании на Delphi - Доступ к объектам COM множества потоков в раздельных подразделениях
ОГЛАВЛЕНИЕ
Правило #7:
Для обеспечения корректного доступа к объектам COM множества потоков в РАЗДЕЛЬНЫХ подразделениях, указатель на интерфейс к этому объекту должен маршалироваться (транслироваться) из подразделения, в котором этот объект живет, в подразделение, в котором должен производиться доступ.
Конечно, если доступ к объекту производится из своего собственного подразделения, то нет нужды ни в каком маршалинге, хотя это и не повредит. Причина этого заключается в том, как мы уже говорили ранее, что подразделение имеет хорошо определенный набор правил, как следует предоставлять доступ к объектам, живущим в нем. Для того, чтобы доступ к объекту потоками из других подразделений осуществлялся должным образом, Вы должны, когда понадобится доступ к объекту из другого подразделения, явно объявить COM, что правила доступа к этому объекту установлены корректно. Если Вы забываете производить маршалинг через подразделения при доступе к объектам COM и пытаетесь манипулировать простым указателем на интерфейс, COM выдаст Вам ошибку с кодом RPC_E_WRONG_THREAD ($8001010E), означающую "Приложение вызвало интерфейс, маршалированный для другого потока" ("The application called an interface that was marshaled for a different thread").
Когда Вы маршалируете указатель на интерфейс из подразделения источника в целевое подразделение, Вы экспортируете указатель из подразделения источника и затем импортируете тот же указатель в целевое подразделение. Такой процесс экспорта-импорта приводит к тому, что целевое подразделение получает нечто, называемое заместителем или прокси (proxy) интерфейсным указателем на оригинальный указатель на интерфейс. Для всех целей прокси ведет себя точно так же, как и оригинальный указатель на интерфейс, т.е. Вы можете вызывать методы через прокси так же, как это производится и через настоящий указатель на интерфейс. Единственная разница, которую Вам потребуется знать, заключается в том, что вызовы через прокси делаются медленнее (иногда существенно медленнее), чем вызовы, производимые через настоящий указатель не интерфейс. Экспорт и импорт указателя на интерфейс в действительности не столь сложен. COM предлагает две простых функции API для маршалинга указателей на интерфейс: CoMarshalInterThreadInterfaceInStream - для экспорта/маршалинга и CoGetInterfaceAndReleaseStream - для импорта/демаршалинга. Демаршалинг Вам необходимо производить потому, что процесс маршалинга записывает указатель на интерфейс в поток (stream) (вместе с другой информацией, однозначно иденцифицирующей размещение объекта COM, на который ссылается указатель), а, следовательно, Вам придется изымать его оттуда (демаршалировать) в допустимый прокси указатель на интерфейс. Синтаксис CoMarshalInterThreadInterfaceInStream следующий:
function CoMarshalInterThreadInterfaceInStream (const iid: TIID; unk: IUnknown; out stm: IStream): HResult; stdcall;
IID идентификатор интерфейса (ID), который Вы хотите маршалировать. Unk – это указатель на интерфейс IUnknown объекта, чей интерфейс Вы хотите маршалировать.
Stm - это переменная IStream, в котрой будет содержаться указатель на маршалируемый интерфейс, Вы не должны запрашивать какую-либо память для Stm, так как COM сделает это за Вас.
Синтаксис CoGetInterfaceAndReleaseStream следующий :
function CoGetInterfaceAndReleaseStream (stm: IStream; const iid: TIID; out pv): HResult; stdcall;
Stm - это переменная IStream , которая используется Вами при маршалинге указателя на интерфейс; COM автоматически освобождает память Stm после завершения вызова. IID идентификатор интерфейса (ID), который Вы хотите маршалировать. Pv - переменная, в которой хранится прокси указатель интерфейса до успешного завершения демаршалинга.
Следующий фрагмент иллюстрирует, как маршалируется указатель на IDispatch из потока 1 в поток 2 различных STA :
var
pStream : pointer;
В потоке 1:
procedure DoMarshal;
var
pObject1 : IDispatch;
begin
pObject1 := CreateOleObject ('Server.Object1');
CoMarshalInterThreadInterfaceInStream (IDispatch,
pObject1 as IUnknown, IStream (pStream));
end;
В потоке 2:
procedure DoUnmarshal;
var
pObject1 : IDispatch;
begin
CoGetInterfaceAndReleaseStream (IStream (pStream), IDispatch, pObject1);
pObject1.DoSomething;
end;
Отметьте, что в маршалинге важна последовательность производимых действий: сначала указатель на интерфейс должен быть маршалирован в потоковой переменной, и только после этого второй поток может демаршалировать его. Я также объявил pStream как указатель вместо IStream и делаю явное преобразование типов IStream (pStream), так как мы хотим предотвратить автоматический подсчет ссылок в Delphi, из-за которого у нас могли бы возникнуть проблемы - функции маршалинга Win32 API неявно создают и освобождают указатель на IStream (pStream). Теперь, когда мы приколотили основные идеи модели STA, мы готовы двигаться дальше к следующей ступени понимания: как клиенты COM и сервера взаимодействуют между собой во владениях модели STA. Этот шаг приводит к теме, возможно, являющейся одной из самых важных (и наиболее неподдающейся пониманию) тем в потоковой модели COM. Поэтому я буду предполагать, что Вы замедлили чтение на этом месте. Постарайтесь восстановить в памяти полную картину прочитанного и, если необходимо, перечитайте еще раз.
Внешний (EXE) сервер, поддерживающий модель STA, обычно создает несколько потоков STA для своих объектов. Простейшей реализацией сервера STA является создание раздельных потоков STA для каждого экземпляра каждого объекта, создающихся средствами этого сервера. Другими словами, если клиент 1 создает Server.Object1, а клиент 2 создает Server.Object2, оба с помощью одного сервера STA, то сервер может просто создать два потока STA: первый, в котором будет жить объект Object1, и второй, в котором будет жить Object2. Таким образом, если клиент 1 и клиент 2 одновременно вызывают методы объектов Object1 и Object2 соответственно, то сервер в действительности будет обслуживать одновременно оба вызова к обоим объектам в двух потоках STA. Простейшим способом для сервера создать по потоку STA на каждый экземпляр является запуск некоего процесса, в котором COM попросил бы сервер создать новый экземпляр объекта. Запустив такой процесс, сервер может затем породить новый поток STA, имея такой поток – создать экземпляр во время исполнения его метода Execute, а затем вернуть указатель на этот экземпляр COM (а, следовательно, и клиенту). Для объектов автоматизации Delphi этот процесс может быть воспроизведен в методе TAutoObjectFactory.CreateInstance, использующемся при реализации IClassFactory.CreateInstance. Следующий псевдокод иллюстрирует этот процесс:
type
TSTAAutoObjectFactory = class (TAutoObjectFactory, IClassFactory)
function CreateInstance;
end;
TSTAThread = class (TThread)
procedure Execute; override;
end;
function TSTAAutoObjectFactory.CreateInstance;
begin
Создает и порождает новый экземпляр TSTAThread;
Заставляет поток STA создать затребованный объект
Ждет, пока поток STA успешно создаст объект
Возвращает созданный экземпляр в качестве результата этого метода
end;
procedure TSTAThread.Execute;
begin
// Вход в STA
CoInitializeEx (NIL, COINIT_APARTMENTTHREADED);
Создает экземпляр, затребованный TSTAAutoObjectFactory;
Сигнализирует потоку TSTAAutoObjectFactory.CreateInstance,
что экземпляр теперь доступен
Вход в цикл сообщений STA
// Выход из STA
CoUninitialize;
end;
Этот процесс теоретически очень прост, но что Вы могли еще не разглядеть, так это то, что каждый поток STA должен знать, как и когда необходимо прекращать существование всех уже несуществующих объектов COM, живущие в нем, т.е. уже не имеют ссылок к ним от клиентов. Причина этого очевидна: потоки потребляют системные ресурсы и, следовательно, должны быть приняты все меры для того, чтобы каждый поток в приложении завершался бы должным образом, если в нем больше нет нужды. Под "завершался должным образом" я подразумеваю, что Вы должны использовать вызов TerminateThread Win32 API для завершения потоков и выполнить все проверки, убеждаясь, что метод Execute Вашего наследника TThread полностью завершился, так как в противном случае часть использовавшихся ресурсов окажется не освобожденной. Одним из способов решения этой проблемы является организация счетчика всех объектов на поток STA, на самом деле на подразделение. Кто-то также должен отслеживать состояние этого счетчика при создании и ликвидации объектов в этом подразделении. При этом, как только счетчик станет равным нулю, мы можем безопасно прекращать все потоки, живущие в данном подразделении. Вот псевдокод для этого процесса:
type
TSTAAutoObject = class (TAutoObject)
procedure Initialize; override;
destructor Destroy; override;
end;
procedure TSTAAutoObject.Initialize;
begin
inherited;
Увеличение счетчика объектов для STA, в котором живет экземпляр;
end;
destructor TSTAAutoObject.Destroy;
begin
inherited;
Уменьшение счетчика объектов для STA, в котором живет экземпляр;
Если счетчик объектов STA = 0
то сигнализировать потоку STA о необходимости завершения;
end;
Для серверов STA создание нового потока STA для экземпляра не всегда может быть лучшим выбором. Как я уже говорил ранее потоки потребляют некоторые системные ресурсы, и если, например, клиенты могут создавать одновременно тысячи экземпляров объектов (а, стало быть, и тысячи потоков STA), то, соответственно, производительность компьютера, на котором работает Ваш сервер, существенно "провалится". Одним из способов гарантировать, что такое не произойдет с сервером STA, является определение каким-то образом максимального числа потоков STA, а затем, когда этот максимум превышен, начать повторное использование существующих потоков STA для обслуживания потребностей новых объектов. Такая технология называется "пул потоков STA" (STA thread pooling). При этом устанавливается предопределенное максимальное количество потоков STA и, когда количество созданных потоков STA превышает это число, новые экземпляры создаются путем использования уже существующих потоков STA из пула. Этот процесс иллюстрируется следующим псевдокодом:
type
TSTAAutoObjectFactory = class (TAutoObjectFactory, IClassFactory)
function CreateInstance;
end;
function TSTAAutoObjectFactory.CreateInstance;
begin
if (Количество потоков STA в сервере < макс. допустимого числа потоков STA)
then
Создать и породить новый экземпляр TSTAThread
else
Выбрать произвольный поток STA из пула существующих;
Здесь поток STA создает затребованный объект
Ждет, пока поток STA успешно создаст объект
Возвращает созданный экземпляр в качестве результата этого метода
end;
Для клиента STA, взаимодействующего с внешним сервером STA, все намного проще. Все, что нужно делать клиенту, это создавать объекты в любых, созданных клиентом потоках STA, а сервер STA будет заботиться о деталях того, как создавать или размещать потоки STA, в которых в действительности живут объекты. Необходимо заметить важную деталь: когда клиент создает объект из сервера STA, в действительности имеются два подразделения, один на сервере, где собственно находится объект, и второй - в клиенте, где размещаются другие клиентские STA, "ощущаемые" как объекты. В действительности клиентский STA содержит прокси, маршалирующий кросс-процесс от сервера к клиентскому приложению. Если клиентский поток STA создает объект из внешнего сервера STA и передает указатель на этот объект (очевидно в режиме маршалинга) второму клиентскому потоку STA, COM будет уверен, что второй STA будет использовать объект по правилам подразделения внешнего сервера, откуда обычно создается этот объект. Если Вы сейчас находитесь в замешательстве, то имеется простой технический прием, который очень просто расскажет Вам, в каком подразделении живет этот объект. Посмотрите на объект, живущий в подразделении с той точки зрения, откуда пришел оригинальный указатель на интерфейс. Так, если в своем клиентском приложении Вы создаете объект в потоке 1 STA и затем используете этот объект из потока 2 STA, то говорят, что объект размещается в STA, в котором живет поток 1, так как оригинальный указатель на интерфейс к этому объекту получаем из этого подразделения. Но, если этот серверный объект размещается во внешнем сервере, то говорят, что объект размещается в STA внешнего сервера, где он располагался первоначально. Всегда помните это технический прием с указателем на интерфейс, так как Вам придется пользоваться им каждый раз, когда у Вас не будет уверенности, в каком из подразделений живет объект.
Мы до сих пор говорили в основном о внешних серверах, давайте теперь поговорим о внутренних серверах (DLL). Обычно STA внутренних серверов не создают явно потоков STA для своих объектов. Причина этого в том, что DLL фактически является просто пассивной библиотекой кода (и данных), отображенной в адресное пространство Вашего клиентского приложения. Поэтому внутренний сервер в действительности эквивалентен любому другому модулю Вашего приложения, однажды отображенным в адресное пространство приложения и, следовательно, все STA, создаваемые клиентом есть то же самое, что и STA, видимые из внутреннего (DLL) сервера. Другими словами, внутреннему серверу вовсе нет нужды активно создавать подразделения и потоки для того, чтобы обслуживать клиента. Что, однако, необходимо, так это (см. Правило #1) объяснить COM, какую потоковую модель он поддерживает или какие типы подразделений его объекты могут обслуживать. STA внутреннего сервера сообщает COM свою потоковую модель путем использования входов системного реестра (registry) для CLSID, регистрируемых для их компонентных классов CoClasses. Точнее, объект во внутреннем сервере, способный размещаться в STA должен добавить строковый параметр "ThreadingModel=Apartment" к параметру HKCR\CLSID\<CoClass Class Id>\InprocServer32. Таким образом внутренний сервер, содержащий Object1, поддерживающий STA, должен иметь следующие входы реестра:
[HKCR\CLSID\<Object1 Class Id>\InprocServer32](Default)=Server.dll
[HKCR\CLSID\<Object1 Class Id>\InprocServer32]ThreadingModel=Apartment
Что же делает строка "ThreadingModel=Apartment"? Все просто! Указывая ThreadingModel как Apartment сообщаем COM, что когда COM (или клиент) создает Object1, COM может и будет безопасно создавать Object1 непосредственно в том STA, в которое поступил запрос клиента на создание. Это означает, что если клиент создает два потока STA, каждый из которых использует Object1, COM будет счастливо создавать каждый экземпляр так, чтобы он жил в клиентском STA, затребовавшем его создание. Отметьте различия с внешним сервером: внешний сервер явно создает потоки STA, в которых будут жить объекты, в то время как внутренний сервер полагается на COM при определении, в каком STA (или в каком подразделении) необходимо создать объект. Поэтому в действительности внутреннему серверу необходимо иметь строковый параметр ThreadingModel в системном реестре, так как при этой схеме отсутствуют структуры, ответственные за создание потоков STA для их объектов. Следовательно, они должны сказать кому-то, кто отвечает за это (в нашем случае COM), как и где эти объекты могут безопасно жить. Для иллюстрации значимости параметра ThreadingModel, давайте взглянем, что делает Delphi для Ваших внутренних серверов.
Если Вы попробуете посмотреть параметр InprocServer32 для Вашего внутреннего сервера, сделанного на Delphi, Вы увидите, что параметр ThreadingModel отсутствует (у Delphi 3ThreadingModel отсутствует в COM VCL, в Delphi 4 эта возможность включена).
Что это значит? Ну, одно, что можно сказать уверенно, это то, что он не поддерживает потоковую модель STA, однако можно указать "ThreadingModel=Apartment". Так что же случится, если Ваше клиентское приложение создает два потока STA, каждый из которых создает экземпляр, скажем, объекта Object1, который не имеет определенного параметра ThreadingModel? Опять все просто! Отсутствие строкового параметра ThreadingModel означает, что Ваш внутренний сервер поддерживает только однопотоковый режим работы для своих объектов. Другими словами, если имеются два клиентских потока STA, каждый из которых пытается создать экземпляр объекта Object1, COM будет видеть, что Object1 может поддерживать только однопотоковый режим работы и не будет создавать каждый экземпляр непосредственно в каждом клиентском потоке. Что COM будет делать, так это создавать оба экземпляра в одном STA (один STA является техническим определением и реализацией однопотокового режима, в котором имеется только одно подразделение и только один поток в этом одном подразделении) и затем маршалировать (вспомните маршалинг...) указатель на каждый экземпляр из этого STA к STA клиентов, затребовавшим создание этого объекта.
Теперь возникает вопрос: COM создает новый STA для содержания обих (и всех прочих) экземпляров или COM только использует один из существующих STA в Вашем клиенте и этот STA используется везде, где необходимо создавать объекты? В этом случае, так как клиент работает на основе STA, COM будет использовать один из STA Вашего клиента, причем это будет главный STA, для доступа ко всем объектам внутреннего сервера. STA, который "зацепит" COM для этой цели - это первый STA, который создает клиентское приложение. Первый STA, создаваемый Вашим клиентским приложением называется главным STA.
И последнее, что я бы хотел отметить в связи со строковым параметром ThreadingModel. Строковый параметр ThreadingModel - это только способ, с помощью которого внутренний сервер говорит COM, что он может безопасно поддерживать конкретную потоковую модель. Это означает, что ни при каких обстоятельствах нельзя играть (или даже думать об этом), изменяя параметр ThreadingModel на какое-либо другое значение, до тех пор, пока Вы не будете совершенно уверены, что Ваши объекты могут безопасно обслуживать тот режим, который Вы указали. Поверьте мне, что если Вы поступаете так и думаете, что Ваш внутренний сервер продолжает прекрасно работать, то Вы будете сильно огорчены, услышав, что у Ваших клиентов Ваше приложение начало сбоить, выдавая случайные сообщения о нарушениях защиты памяти, а у Вас не будет никаких мыслей по этому поводу. Другими словами, если Delphi (Delphi 3) не помещает параметр ThreadingModel в системный реестр, то на это имеются причины, и Вы должны сначала определить, что это за причины, прежде, чем Вы начнете изменять параметр ThreadingModel на "Apartment."
Из последнего абзаца Вы могли заметить, что каждый клиентский поток STA получает заместителя (proxy), если сервер является однопотоковым. Когда поток подразделения получает заместителя указателя на интерфейс к объекту, это означает две вещи: первое, любой доступ, производимый к этому объекту, замедляется с использованием заместителей (proxy) в сравнении с прямым доступом и, второе, если этот заместитель предназначен для объекта, живущего в STA, то есть большие шансы того, что производительность этого объекта будет очень плохой, если в том же самом STA живут еще несколько объектов. Причина этого в том, что вызовы в этом единственном STA выстраиваются последовательно, и если, скажем, Вы имеете 50 клиентских потоков STA, и они одновременно обращаются к объектам, живущим в одном серверном STA (в случае DLL, 49 клиентов в действительности пользуются заместителями и только один имеет прямой указатель, потому что он является главным STA клиента), COM будет вмешиваться и обслуживать этим единственным потоком STA все вызовы всех потоков один за одним последовательно. Именно в этом кроется причина, почему модель STA, разрешающая Вам создавать много потоков STA, была введена в качестве средства повысить производительность однопотоковых приложений COM.
Теперь, когда мы твердо усвоили, что такое STA, давайте перейдем к следующей части, в которой мы узнаем, как модель MTA продвигает нас дальше к новым возможностям потоков для увеличения производительности наших объектов COM. Модель многопотокового подразделения (the Multithreaded Apartment Model - MTA) Если Вы полностью разобрались в модели STA, то можно быть уверенным, что изучение модели MTA будет просто кусочком торта. Мы говорили ранее, что модель MTA, подобно модели STA, также позволяет разрабатывать многопотоковые приложения COM. Единственно, в чем заключается основная разница между моделью MTA и другими моделями, это то, что модель MTA разработана для получения максимальной производительности Ваших приложений COM.
Я должен предупредить Вас, что модель MTA предоставляет Вам максимальную производительность в обмен на большую ответственность со стороны программиста в том, что объекты в действительности могут работать в архитектуре MTA. Следовательно, как и в случае с STA, я не буду, повторяю, не буду рекомендовать разрабатывать приложения COM, поддерживающие модель MTA, до тех пор, пока у Вас не появится совершенно точное понимание того, как работает модель MTA. В соответствии с этим, я имею в виду, что если Вы думаете, что это крутая штука и просто переключаете Ваш работающий сервер STA в режим MTA (простой заменой вызовов CoInitializeEx или изменением строкового параметра ThreadingModel) в надежде, что это будет лучше, то лучше приготовьтесь потерять какое-то время на основательную повторную отладку своего сервера.
Первое, что Вам необходимо знать, это то, как поток входит в MTA. Поток, запрашивающий вход в MTA или его инициализацию, использует тот же вызов CoInitializeEx API, но теперь нам необходимо использовать константу COINIT_MULTITHREADED вместо константы COINIT_APARTMENTTHREADED, использовавшейся для STA. Следующий вызов позволяет потоку войти в MTA: CoInitializeEx (NIL, COINIT_MULTITHREADED); Из Правила #2 мы уже знаем, что если нам необходимо создать несколько потоков, использующих MTA, то каждый из этих потоков должен вызвать CoInitializeEx (NIL, COINIT_MULTITHREADED). Архитектура MTA спроектирована таким образом, что может быть только один MTA на процесс, и, если несколько потоков входят в MTA, все они живут в этом самом единственном MTA. Почему так ?