Сообщений 2    Оценка 120        Оценить  
Система Orphus

Асинхронные вызовы COM-компонентов в Windows 2000

Автор: Джефф Просайс (Jeff Prosise)
Источник: «Технология Клиент-Сервер»
Асинхронные клиенты
Выполнение неблокирующих вызовов
Проверка завершения вызова
Оповещения о завершении вызова
Отмена неблокирующего вызова.
SieveClient
Асинхронный сервер
Реализация call-объекта
SieveServer
Отмена блокирующего вызова
Кросс-платформные соглашения
Запреты и ограничения.

В Windows 2000 реализована первая версия COM, поддерживающая асинхронные вызовы методов. Это позволяет клиентам производить неблокирующие вызовы COM-объектов, а объектам – обрабатывать входящие, не блокируя очереди. COM-клиенты получают значительную выгоду от асинхронных вызовов методов, поскольку могут продолжать работу в период ожидания ответов на сделанные ими внешние вызовы. Объекты выигрывают, так как могут выстроить входящие вызовы в очередь и обслуживать их в пуле потоков. Наши приложения-примеры (SieveClient и SieveServer) показывают, как воспользоваться этой технологией на стороне клиента и сервера в распределенных приложениях COM.

С выходом Windows 2000 разработчики получили возможность использовать ее преимущества в своих приложениях. Для COM-разработчиков одна из наиболее интересных новинок – это возможность использования асинхронных вызовов методов, появившаяся в результате переработки инфраструктуры COM в Windows 2000. Клиенты, использующие асинхронные вызовы методов, не ждут окончания выполнения вызванного ими метода, а могут заниматься своими делами. Объекты, использующие асинхронные вызовы, больше не связаны необходимостью обработки поступающих вызовов по мере поступления, и могут выстраивать их в очередь и обрабатывать, когда заблагорассудится. Асинхронные вызовы методов не являются панацеей, и пригодятся не каждому COM-клиенту или серверу, но это большое благо для клиентов, которым нужно работать, ожидая ответа на сделанные вызовы, и для серверов, желающих максимально эффективно обрабатывать параллельные запросы.

Эта версия COM создавалась так, чтобы максимально упростить асинхронные вызовы. Большая часть черной работы производится в сгенерированных MIDL заглушках (stub) и заместителях (proxy), и в каналах, связывающих их воедино. Но дьявол, как обычно, прячется в деталях – это очень часто случается в COM. Например, очень просто написать клиента, производящего неблокирующий вызов метода COM-объекта. Однако если этот клиент хочет получить оповещение при окончании работы метода, он должен реализовать call-объект, агрегирующий call-объект, предоставляемый COM. Сервер, обрабатывающий запросы асинхронно, должен реализовать локальный (в этой статье под локальным будет иметься в виду внутрипроцессный (in-proc) объект, не путайте его с локальным объектом в понимании COM, загружаемым на той же машине, но в другом процессе – прим.ред.) call-объект собственноручно и позволить этому объекту быть агрегированным COM. Когда доходит до реализации клиента или сервера, работающего с асинхронными вызовами, четкое разъяснение правил и примеры кода ценятся дороже золота.

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

Асинхронные клиенты

Ключевой элемент поддержки асинхронных вызовов методов в COM – это новая версия MIDL, поддерживающая новый атрибут интерфейса в IDL, [async_uuid]. Увидев в определении интерфейса этот атрибут, MIDL генерирует и синхронную, и асинхронную версии интерфейса, а так же proxy и заглушки, поддерживающие асинхронные вызовы методов.

Предположим, что вы определяете интерфейс с названием ISieve, включающий метод CountPrimes, и что этот метод определен следующим образом:

HRESULT CountPrimes ([in] unsigned long lMax,
                     [out] unsigned long* plResult);

Будем считать, что клиент может вызвать CountPrimes для расчета количества простых чисел между 2 и lMax, потолком, задаваемым клиентом. Расчет простых чисел – операция, интенсивно использующая процессор, если постараться, она может занять много времени, так что ISieve – идеальный кандидат на роль асинхронного интерфейса.

IDL-определение интерфейса ISieve приведено в Листинге1. Из-за атрибута [async_uuid] MIDL будет генерировать заглушки и proxy, поддерживающие неблокирующие вызовы методов. Вдобавок, MIDL определит и синхронную версию интерфейса с названием ISieve (interface ID=IID_ISieve), и асинхронную с именем AsyncISieve (interface ID=IID_AsyncISieve). В интерфейс AsyncISieve войдет не один метод CountPrimes, а два. Один будет называться Begin_CountPrimes, и содержать все [in]-параметры CountPrimes:

HRESULT Begin_CountPrimes ([in] unsigned long lMax);

Другой же будет называться Finish_CountPrimes, и содержать все [out]-параметры CountPrimes:

HRESULT Finish_CountPrimes ([out] unsigned long* plResult);

Поскольку MIDL генерирует синхронные и асинхронные версии интерфейса, клиенты могут по-прежнему вызывать блокирующую версию CountPrimes, запрашивая у объекта указатель на интерфейс ISieve и вызывая ISieve::CountPrimes. Но теперь у них есть и возможность вызова неблокирующей версии CountPrimes, вызовом AsyncISieve::Begin_CountPrimes с последующим вызовом AsyncISieve:: Finish_CountPrimes. Если интерфейс содержит дополнительные методы, Begin и Finish-версии этих методов будут также доступны. Заметьте, что если метод содержит [in,out] –параметры, эти параметры появятся и в Begin-, и в Finish-версии этого метода. Точнее, [in,out]–параметры появятся как [in]-параметры в Begin-методе, и как [out]-параметры в Finish-версии.

Листинг 1. IDL-определение интерфейса ISieve.

[
    object,
    uuid(3A3EE73E-6C2F-41D7-B839-95D6FD999082),
    async_uuid(CA1F5D93-82E5-4266-944A-7C45828C9CB7),
    helpstring("ISieve Interface"),
    pointer_default(unique)
]
interface ISieve : IUnknown
{
    [helpstring("method CountPrimes")]
    HRESULT CountPrimes([in] unsigned long lMax,
                        [out] unsigned long* plResult);
};

Объект, реализующий интерфейс ISieve, реализует CountPrimes, но COM реализует Begin_CountPrimes и Finish_CountPrimes. Точнее, эти методы и интерфейс, к которому они принадлежат, реализуются call-объектом, создаваемым proxy-менеджером COM. Call-объект нужен для управления асинхронными вызовами методов, исходящими от COM-клиента. После создания call-объекта клиент может инициировать неблокирующий вызов CountPrimes, запросив у call-объекта указатель на интерфейс AsyncISieve и вызвав AsyncISieve::Begin_CountPrimes. Один экземпляр call-объекта поддерживает один исходящий вызов в один момент времени, так что для двух или более параллельных неблокирующих вызовов требуется создать отдельный call-объект для каждого вызова. После завершения вызываемого метода вполне возможно производить следующие неблокирующие вызовы через этот же call-объект. Если вы хотите делать множественные неблокирующие вызовы, но не настаиваете на параллельности, создайте один call-объект, и используйте его снова и снова.


Рис.1. Архитектура клиентской части неблокирующих вызовов

Рис.1 показывает архитектуру клиентской части неблокирующих вызовов. Proxy-менеджер создает и управляет proxy, используемыми для вызовов удаленных объектов. Кроме этого, он реализует ICallFactory. Для получения указателя на ICallFactory proxy-менеджера клиент просто запрашивает этот указатель через любой другой. Затем клиент вызывает ICallFactory::CreateCall. В этом вызове proxy-менеджер создает call-объект, который, в свою очередь, создает и агрегирует внутренний call-объект, реализующий AsyncISieve. Хотя это и не показано на рис.1, proxy интерфейса ISieve собственноручно реализует интерфейс ICallFactory, который call-объект proxy-менеджера вызывает для создания внутреннего call-объекта. Чтобы все это заработало, интерфейс ISieve должен быть определен с атрибутом [async_uuid]. Call-объект proxy-менеджера также реализует ISynchronize и ICancelMethodCalls. Назначение этих интерфейсов мы рассмотрим чуть ниже.

Выполнение неблокирующих вызовов

В приведенной на рис.1 архитектуре процедура исполнения неблокирующего вызова метода CountPrimes вкратце выглядит так:

В Листинге 2 показана эта процедура, обработка ошибок пропущена для ясности. Клиент создает Sieve-объект и получает указатель на интерфейс ISieve. Затем он запрашивает указатель на интерфейс ICallFactory и вызывает ICallFactory::CreateCall, чтобы создать call-объект, реализующий AsyncISieve. Наконец, он инициирует неблокирующий вызов, вызывая Begin_CountPrimes, и завершает его Finish_CountPrimes. В промежутке между вызовами Begin_CountPrimes и Finish_CountPrimes он совершенно свободен и может отправляться по своим делам. Это так, поскольку хотя вызов по-прежнему находится в канале, вызов Begin_CountPrimes возвращает управление немедленно. Успешный код возврата от Begin_CountPrimes означает только, что вызов был успешно инициирован. Даже если метод объекта, негодуя, немедленно возвращает код ошибки, этот код не будет возвращен Begin_CountPrimes. Он появится только в HRESULT, возвращенном Finish_CountPrimes.

Что же скрыто от глаз при вызове Begin_CountPrimes? Во-первых, call-объект передает вызов в канал. Если вызов направляется на другую машину, канал выполняет асинхронный вызов RPC и предоставляет RPC-подсистеме адрес функции обратного вызова (callback). Как только вызов RPC отправлен, вызов Begin_CountPrimes возвращает управление основному потоку. Когда завершается асинхронный вызов RPC, вызывается функция обратного вызова. Эта функция сигнализирует call-объекту о завершении вызова. Когда клиент вызывает Finish_CountPrimes, call-объект лезет в канал и вытаскивает оттуда возвращенные выходные параметры.

Помните – каждой твари должно быть по паре. Сказали А, говорите Б. Если вызываете Begin, вызывайте и Finish. Не говоря обо всем прочем, при вызове Begin COM отводит память для хранения выходных параметров. Если не вызвать Finish, эта память не будет освобождена до уничтожения call-объекта.

Проверка завершения вызова

Finish возвращается немедленно и возвращает выходные параметры вызова, если к этому моменту деятельность вызванного метода завершена. Если же это не так, то Finish блокирует работу вызывающего потока до тех пор, пока вызываемый метод не закончит свою работу. Вызов Finish_CountPrimes непосредственно после возврата Begin_CountPrimes похож на вызов блокирующего метода.

Листинг 2. Выполнение неблокирующего вызова

// Create a sieve object.
//
ISieve* pSieve;
CoCreateInstance (CLSID_Sieve, NULL, CLSCTX_SERVER,
                  IID_ISieve, (void**) &pSieve);

// Create a call object.
//
ICallFactory* pCallFactory;
pSieve->QueryInterface (IID_ICallFactory, (void**) &pCallFactory);

AsyncISieve* pAsyncSieve;
pCallFactory->CreateCall (IID_AsyncISieve, NULL, IID_AsyncISieve,
                          (IUnknown**) &pAsyncSieve);
pCallFactory->Release ();

// Initiate an asynchronous call.
//
pAsyncSieve->Begin_CountPrimes (lMax);
•••
// Finish the call.
//
unsigned long lCount;
pAsyncSieve->Finish_CountPrimes (&lCount);
pAsyncSieve->Release ();

Чтобы не тратить времени попусту и не висеть в бездействии, перед вызовом Finish_CountPrimes, клиент мог бы проверить состояние вызова. Это можно сделать, вызвав ISynchronize::Wait call-объекта, через который был инициирован вызов. Вызов ISynchronize::Wait аналогичен вызову WaitForMultipleObjects при блокировке по событию. Wait вызывает новую API-функцию Windows 2000 CoWaitForMultipleHandles, которая блокирует основной поток. Делает это она по-разному в зависимости от того, принадлежит ли поток однопоточному (STA) или многопоточному apartment (MTA). В случае STA производится чистая сериализация вызовов через модальный цикл, а в случае MTA вызывается Win32-функция WaitForMultipleObjects (стандартная Win32-функция синхронизации потоков). Поскольку Wait принимает значение таймаута, его вызов блокирует поток только на то время, которое вы назначите. Более того, значение возврата Wait позволяет определить состояние вызова. Возвращаемое значение RPC_S_CALLPENDING означает, что вызов еще не окончен (и что в данный момент вызов Finish приведет к блокировке); S_OK означает, что вызов завершен.

Листинг 3 показывает, как определить статус внешнего вызова, обработка ошибок снова пропущена. Предполагается, что pAsyncSieve содержит указатель на интерфейс AsyncISieve call-объекта. Этот указатель используется для запроса указателя ISynchronize у call-объекта, который, в свою очередь, используется для вызова ISynchronize::Wait. Второй 0 в списке параметров Wait означает, что Wait должен быть возвращен немедленно, даже если работа метода не окончена. Если хочется, в этом параметре можно передать значение таймаута (в миллисекундах) и подождать возврата вызова это время.

Листинг 3. Проверка завершения неблокирующего вызова.

// Get a pointer to the call object’s ISynchronize interface.
//
ISynchronize* pSynchronize;
pAsyncSieve->QueryInterface (IID_ISynchronize, (void**) &pSynchronize);

//
// Check the call status.
//
HRESULT hr = pSynchronize->Wait (0, 0);
pSynchronize->Release ();

if (hr == RPC_S_CALLPENDING) {
    // Call has not returned
}
else if (SUCCEEDED (hr)) {
    // Call has returned
}

Оповещения о завершении вызова

Периодический тестовый опрос call-объекта для определения времени завершения работы метода приемлем для некоторых клиентов, но для других предпочтительнее избежать опроса. К счастью, COM предоставляет механизм, позволяющий вызывающей стороне (абоненту) получать асинхронные оповещения о моменте возврата неблокирующего вызова метода.

Когда неблокирующий вызов возвращается из канала, proxy оповещает call-объект, вызывая его метод ISynchronize::Signal. Скажем, абонент предоставляет собственный call-объект (внешний call-объект), агрегирующий call-объект proxy-менеджера (внутренний call-объект). Call-объект proxy-менеджера, будучи агрегированным клиентским call-объектом, переадресует ему вызовы QueryInterface. Внешний (клиентский) call-объект при запросе указателя на интерфейс ISynchronize возвращает указатель на собственную реализацию интерфейса ISynchronize, и именно его метод Signal (внешнего – а не внутреннего – call-объекта) будет вызван при оповещении об окончании работы метода. Это в точности та архитектура, что изображена на рис. 2.


Рис. 2. Агрегирование call-объекта

Агрегация осуществляется передачей ICallFactory::CreateCall управляющего unknown (controlling unknown) – указателя на интерфейс IUnknown внешнего (клиентского) объекта – в качестве второго параметра этой функции. Реализация метода ISynchronize::Signal клиентского объекта использует функцию обратного вызова, сообщение Windows или какой-нибудь другой механизм для оповещения абонента о завершении вызова. Чтобы избежать реализации методов Wait и Reset в ISynchronize, внешний call-объект обычно делегирует обработку этих методов внутреннему call-объекту. При запросе указателей на интерфейсы, отличные от ISynchronize, внешний call-объект слепо передает эти вызовы QueryInterface внутреннему объекту, делая доступными интерфейсы ICallFactory и ICancelMethodCalls агрегированного call-объекта.

Если к этому моменту вы еще не бросили читать, и ум у вас не зашел за разум ото всех этих объяснений, встаньте, выпейте чашку кофе (чая) и морально подготовьтесь к дальнейшему. Сейчас мы вам покажем call-объект, написанный на ATL. Он агрегирует call-объект proxy-менеджера и оповещает (используя события Windows) клиентов о завершении работы неблокирующего вызова метода.

Отмена неблокирующего вызова.

Теперь, когда вы выпили свой кофе (чай), перейдем к самому интересному. Инициировав неблокирующий вызов метода, клиент не может управлять временем выполнения метода. Однако в некоторых ситуациях клиент может захотеть отменить вызов, если он не завершился в течение обозначенного периода времени. Поэтому call-объекты реализуют интерфейс с названием ICancelMethodCalls. Клиент, ожидающий завершения посланного вызова, может отменить вызов, запросив указатель на ICancelMethodCalls у call-объекта, и вызвав ICancelMethodCalls::Cancel:

pCancelMethodCalls->Cancel(0);

Одинокий параметр, переданный Cancel, определяет число секунд (не миллисекунд), которое Cancel будет ожидать завершения работы метода. По истечению времени таймаута Cancel оставляет надежду на благополучный исход и возвращает управление вызывающему потоку. Если значение таймаута равно 0, управление возвращается немедленно. Для лучшей очистки клиенту следует вызвать Finish после вызова Cancel:

pCancelMethodCalls->Cancel (0); 
unsigned long ulCount; 
pAsyncSieve->Finish_CountPrimes (&ulCount);

Клиент должен игнорировать выходные параметры метода Finish после вызова Cancel, если вызова метода был отменен.

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

Cancel возвращает S_OK, что означает успешную отмену вызова. Cancel возвращает RPC_E_CALL_COMPLETE, если вызов завершается раньше, чем истекает таймаут или уже завершен. Finish возвращает HRESULT-значение вызываемого метода, если вызов завершается раньше вызова Cancel, в противном случае - "0x8007171A (The remote procedure call was canceled)".

Что делает сервер для отслеживания запросов об отмене? Сначала он вызывает CoGetCallContext для получения указателя ICancelMethodCalls, ссылающегося на серверный call-объект. Затем он вызывает ICancelMethodCalls::TestCancel, чтобы выяснить – а не просили ли отменить действие? TestCancel возвращает RPC_S_CALLPENDING, если вызов не отменен, и RPC_E_CALL_CANCELED в случае отмены. Чем чаще сервер вызывает TestCancel, тем чаще он откликается на требования отмены. При обнаружении такого требования в обязанности сервера входит выполнение всех необходимых действий по очистке и как можно быстрее завершает работу метода.

Листинг 4. Метод CountPrimes с поддержкой отмены

STDMETHODIMP CSieve::CountPrimes(unsigned long lMax,
    unsigned long *plResult)
{
    ICancelMethodCalls* pCancelMethodCalls = NULL;
    CoGetCallContext (IID_ICancelMethodCalls,
                      (void**) &pCancelMethodCalls);

    ATLTRY (PBYTE pBuffer = new BYTE[lMax + 1]);

    if (pBuffer == NULL) {
        if (pCancelMethodCalls != NULL)
            pCancelMethodCalls->Release ();
            return E_OUTOFMEMORY;
    }

    FillMemory (pBuffer, lMax + 1, 1);

    unsigned long lLimit = 2;
    while (lLimit * lLimit < lMax)
        lLimit++;

    BOOL bContinue = TRUE;
    for (unsigned long i=2; i<=lLimit && bContinue; i++) {
        if (pBuffer[i]) {
            for (unsigned long k=i + i; k<=lMax && bContinue; k+=i) {
                pBuffer[k] = 0;
                //
                // Exit now if the client has canceled the call.
                //
                if (pCancelMethodCalls != NULL) {
                    HRESULT hr = pCancelMethodCalls->TestCancel ();
                    if (hr == RPC_E_CALL_CANCELED)
                        bContinue = FALSE;
                }
            }
        }
    }

    unsigned long lCount = 0;
    if (bContinue) {
        for (i=2; i<=lMax; i++)
            if (pBuffer[i])
                lCount++;
    }

    *plResult = lCount;
    delete[] pBuffer;

        if (pCancelMethodCalls != NULL)
            pCancelMethodCalls->Release ();

    return bContinue? S_OK : RPC_E_CALL_CANCELED;
}

Листинг 5. Метод CountPrimes без поддержки отмены

STDMETHODIMP CSieve::CountPrimes(unsigned long lMax,
    unsigned long *plResult)
{
    ATLTRY (PBYTE pBuffer = new BYTE[lMax + 1]);

    if (pBuffer == NULL)
        return E_OUTOFMEMORY;

    FillMemory (pBuffer, lMax + 1, 1);

    unsigned long lLimit = 2;
    while (lLimit * lLimit < lMax)
        lLimit++;

    for (unsigned long i=2; i<=lLimit; i++) {
        if (pBuffer[i]) {
            for (unsigned long k=i + i; k<=lMax; k+=i)
                pBuffer[k] = 0;
        }
    }

    unsigned long lCount = 0;
    for (i=2; i<=lMax; i++)
        if (pBuffer[i])
            lCount++;

    *plResult = lCount;
    delete[] pBuffer;
    return S_OK;
}

Листинги 4 и 5 содержат две реализации ISieve::CountPrimes – одна откликается на вызов отмены (Листинг 4), другая – нет (Листинг 5). Обе считают простые числа, используя алгоритм решета Эратосфена. Единственная разница между этими реализациями в том, что представленная в Листинге 4 периодически вызывает TestCancel, пытаясь определить, не было ли требования об отмене. Если было, CountPrimes прерывает вложенный for-цикл где придется, и завершает свою работу.

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

SieveClient

Приложение SieveClient (рис.3) – MFC COM-клиент, демонстрирующий вышеописанную технику иницииации, завершения, отмены и проверки состояния неблокирующих вызовов методов. Чтобы увидеть SieveClient в действии, запустите его и введите число (скажем, 10К000К000 или 20К000К000) в поле в правом верхнем углу окна. Затем нажмите Begin, чтобы заставить SieveClient рассчитать количество простых чисел между 2 и введенным вами числом. SieveClient использует для расчетов объект sieve (решето). Он вызывает этот объект одним из трех способов в зависимости от выбора значения Call Type (см. рис.3)


Рис.3. SieveClient в действии

Если выбрать Synchronous, SieveClient выполнит блокирующий вызов ISieve::CountPrimes объекта Sieve. Можете в этом удостовериться, попытавшись подвинуть окно SieveClient в процессе работы метода серверного объекта.

Выбор Asynchronous заставляет SieveClient выполнить неблокирующий вызов с помощью создания call-объекта и вызова AsyncISieve::Begin_CountPrimes. Заметьте, что SieveClient откликается на ваши действия в ожидании возврата вызова. Для завершения вызова и вывода результатов нажмите кнопку Finish, вызывающую AsyncISieve::Finish_CountPrimes. Если к моменту нажатия работа еще не окончена, Finish_CountPrimes блокируется, а вместе с ним и весь пользовательский интерфейс. Перед вызовом Finish_ CountPrimes можно выяснить, закончен ли вызов, с помощью кнопки Get Call Status, вызывающей ISynchronize::Wait. В этот момнт можно также отменить вызов кнопкой Cancel, вызывающей метод ICancelMethodCalls::Cancel call-объекта.

Третья возможность – выбрать "Asynchronous with automatic notification". В этом случае call-объект, созданный proxy, агрегируется с собственным call-объектом клиента. Агрегированный call-объект, назовем его «оповещающий call-объект», оповещает SieveClient о завершении вызова, посылая Windows-сообщение. SieveClient откликается вызовом Finish_CountPrimes и выводом результатов. Код, отсылающий сообщение, находится в реализации ISynchronize::Signal оповещающего call-объекта.

PostMessage (m_hWnd, m_nMessageID, 0, 0); 

Как же оповещающий call-объект получает дескриптор окна, переданный PostMessage? Он реализует собственный интерфейс ICallObjectInit с единственным методом Initialize. Внутри Initialize оповещающий call-объект запоминает дескриптор окна и агрегирует предоставленный системой call-объект, вызывая CreateCall через указатель интерфейса ICallFactory, предоставленный клиентом. После завершения Initialize клиент вызывает у оповещающего call-объекта AsyncISieve::Begin_CountPrimes.

Весьма важный код содержится в функции CSieveClientDlg:: DoAsyncCallWithNotification приложения SieveClient в SieveClientDlg.cpp и в реализации ICallObjectInit:: Initialize оповещающего call-объекта, находящегося в CallNotifyObject.cpp. Оповещающий call-объект находится в отдельном проекте CallObjectServer.

Оповещающий call-объект – это локальный COM-объект, реализованный с помощью ATL. Следующие выражения

COM_INTERFACE_ENTRY(ISynchronize) 
COM_INTERFACE_ENTRY_AGGREGATE_BLIND(m_spUnkInner.p)

в его карте интерфейсов возвращают указатель на собственный интерфейс ISynchronize оповещающего call-объекта в ответ на вызовы QueryInterface, и переправляют остальные вызовы QueryInterface агрегированному call-объекту. Внутренне оповещающий call-объект кэширует указатель на интерфейс ISynchronize внутреннего объекта и использует его для вызова методов ISynchronize внутреннего объекта.

Для экспериментов с SieveClient вам придется сперва создать проекты, содержащие объект Sieve и оповещающий call-объект. Sieve находится в проекте с названием SieveServer. Оба проекта при компиляции регистрируют содержащиеся в них COM-объекты и необходимые proxy/stub DLL (proxy автоматически не компилируется и не регистрируется, поэтому вам нужно вставить его компиляцию и регистрацию в основной проект или при создании проекта указать опцию Merge Proxy/Stub). SieveServer – асинхронный сервер, хотя SieveClient на это наплевать. Клиенты могут выполнять неблокирующие вызовы независимо от синхронной или асинхронной работы вызываемых объектов.

Асинхронный сервер

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


Рис. 4. Отслеживание запросов отмены

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

Использование пула потоков конечного размера вместо создания нового потока для каждого вызова повышает эффективность обработки, особенно в случае значительного превышения числом одновременных вызовов числа доступных CPU.

Как вы помните, единственное требование к объекту, который должен обрабатывать вызовы методов асинхронно, состоит в том, что он должен реализовать ICallFactory и откликаться на вызовы ICallFactory::CreateCall созданием серверного call-объекта, реализующего как асинхронный интерфейс, идентифицированный в списке параметров CreateCall, так и ISynchronize. Сall-объект, сопровождающий асинхронный объект Sieve, должен, например, реализовать AsyncISieve и ISynchronize. Наличие ICallFactory говорит заглушке транслировать вызовы метода CountPrimes объекта Sieve в вызовы Begin_ CountPrimes и Finish_CountPrimes call-объекта. Заглушка создает call-объект при первом же вызове, запрашивая у объекта Sieve указатель на интерфейс ICallFactory, и затем вызывая ICallFactory::CreateCall.

Поскольку поставляемый сервером call-объект реализует методы Begin и Finish асинхронного интерфейса, реализатор объекта следит за тем, что делается внутри этих методов. Метод Begin передает вызов в другой поток, ставя его в очередь на обработку в одном из потоков пула или создавая новый поток, и возвращает управление в вызывающий поток. Когда поток, обрабатывающий вызов, сделает свое дело, он сигнализирует системе о завершении обработки, вызывая ISynchronize::Signal call-объекта. Это подсказывает системе вызвать Finish, после чего исходный вызов метода, сделанный клиентом, наконец-то завершается. Со своей стороны, метод Finish всего-навсего возвращает выходные параметры, сгенерированные обрабатывавшим вызов потоком, а также HRESULT, сообщающий об успехе или неудаче вызова.

Реализация call-объекта

Главная ваша задача при разработке асинхронного сервера – реализация серверного call-объекта. В приведенном ниже примере для представления call-объекта используется private ATL COM-класс. Он разработан исключительно для локального использования, не имеет ассоциированного с ним CoClass’a и не поддерживает внешней активации. Основная работа заключается в реализации методов Begin и Finish асинхронного интерфейса и в функциях потока, используемых для обработки вызовов. Однако еще нужно реализовать ISynchronize. К счастью, система предоставляет реализацию ISynchronize, которую вполне можно позаимствовать, агрегировав объект с CLSID равным CLSID_ManualResetEvent и делегировав агрегату вызовы QueryInterface, запрашивающие указатели на ISynchronize. В ATL вы можете сделать это в один шаг, включив в код:

CComPtr<IUnknown> m_spUnkInner;

в декларацию класса и код:

COM_INTERFACE_ENTRY_AUTOAGGREGATE (IID_ISynchronize, 
                                   m_spUnkInner.p, 
                                   CLSID_ManualResetEvent)

в карту интерфейсов (interface map).

Нужно помнить, что создаваемый call-объект должен быть агрегирующимся. Когда заглушка создает ваш call-объект, она агрегирует его с предоставленным системой call-объектом, чей IUnknown содержится во втором параметре CreateCall. Цель агрегации – позволить COM поместить несколько своих интерфейсов в call-объект, включая интерфейс ICancelMethodCalls, используемый сервером для отслеживания запросов на отмену действий (см. рис.4). Заметьте, что внешний объект также реализует ISynchronize. Ваша реализация ISynchronize используется, только если ваш call-объект не агрегирован COM. Это случится только в том случае, если поток, вызывающий ICallFactory::CreateCall находится в том же апартаменте, что и объект, являющийся целью вызова.

Обычно именно заглушка вызывает методы Begin и Finish серверного call-объекта. Но поскольку локальный клиент может прямо вызывать эти методы, следует встроить в методы Begin и Finish следующие защитные меры. Во-первых, call-объекты поддерживают только один вызов одновременно, заставьте Begin отвергнуть вызов, возвращая RPC_S_CALLPENDING, если его вызывают рекурсивно. Это реализуется создания флага, который можно проверять для определения наличия обрабатываемого вызова. Во-вторых, заставьте Finish отвергать вызовы, не предваряемые вызовами Begin. Правильный HRESULT в этом случае будет RPC_E_CALL_COMPLETE. Наконец, заставьте Finish вызывать ISynchronize::Wait call-объекта для полной уверенности в том, что Finish не вернет ложных результатов, будучи вызванным до окончания обработки вызова потоком.

Есть еще одна деталь реализации, которую нужно помнить. Если сбой происходит в методе Begin или в обрабатывающем вызов потоке, и требуется возвратить вызывающей стороне HRESULT, говорящий, почему вызов не удался, нужно возвращать HRESULT из метода Finish. Один из способов для этого – хранить HRESULT, возвращаемый методом Finish, в call-объекте и сделать его доступным методу Begin и потоку, обрабатывающему вызов.

SieveServer

Код примеров к этой статье включает SieveServer, ATL COM-сервер, хранящий объект Sieve, асинхронно обрабатывающий вызовы методов. Исходный код объекта Sieve находится в Sieve.h и Sieve.cpp. Необычно в нем то, что реализация ISieve::CountPrimes возвращает E_NOTIMPL. Метод CountPrimes может быть вызван только в случае неудачного вызова ICallFactory::CreateCall заглушкой. Это приведет к использованию заглушкой синхронного вызова как запасного варианта.

Для поддержки асинхронной обработки на стороне сервера объект Sieve реализует ICallFactory. Заглушка вызывает метод ICallFactory::CreateCall объекта для создания серверного call-объекта. Сall-объект создается и агрегируется следующими строками в CreateCall:

CComPolyObject<CServerCallObject>* pCallObject = NULL; 
HRESULT hr = CComPolyObject<CServerCallObject> ::CreateInstance (pUnk,&pCallObject);

CServerCallObject – класс ATL, реализующий call-объект. Обертывание его в класс ATL CComPolyObject делает образующийся COM-объект агрегируемым. CComPolyObject::CreateInstance создает экземпляр объекта с использованием надлежащей семантики ATL.

Исходный код CServerCallObject находится в ServerCallObject.h и ServerCallObject.cpp. Его метод Begin_CountPrimes использует новую функцию Windows 2000 QueueUserWorkItem для отсылки рабочего потока и передачи ему указателя на this. Рабочий поток использует этот указатель для получения от call-объекта входного параметра IMax. Затем он выполняет расчет простых чисел и сообщает заглушке о завершении вызова. Заглушка отвечает вызовом Finish_CountPrimes, который возвращает абоненту выходные параметры. Если в Begin_CountPrimes происходит сбой, HRESULT, сообщающий об ошибке, копируется в m_hResultFinish call-объекта и возвращается через Finish_CountPrimes. Вся эта чехарда затеяна, чтобы абонент узнал о сбое в любой части обрабатывающего вызов метода механизма.

QueueUserWorkItem – часть нового API, создающего пул потоков и способного значительно упростить занудную работу по созданию такого пула для асинхронных COM-серверов. Подробнее о пулах потоков можно прочесть в статье Джеффри Рихтера (Jeffrey Richter) "New Windows 2000 Pooling Functions Greatly Simplify Thread Management" в апрельском номере MSJ за 1999 год.

Один из достойных обсуждения аспектов этой архитектуры состоит в том, как потоки, в которые направляются вызовы, получают указатели на интерфейс ISynchronize. SieveServer – основанный на MTA COM-сервер, а значит, объекты, создаваемые им, работают в MTA текущего процесса. (Нет смысла помещать объект, асинхронно обрабатывающий вызовы методов, в STA, так как STA-объекты сериализуются раньше, чем доходят до заглушки.) ThreadFunc является функцией потока и исполняется в рабочем потоке, управление которому передается с помощью QueueUserWorkItem. Поскольку эта функция вызывает CoInitializeEx с параметром COINIT_MULTITHREADED, рабочий поток также работает в МТА. Так как call-объекты и их рабочие потоки разделяют апартамент, Begin_CountPrimes не производит маршалинга указателей на интерфейс ISynchronize в их рабочие потоки. Вместо этого потоки просто вызывают QueryInterface через указатель на this, предоставленный им Begin_CountPrimes.

Почему это важно? Интерфейс ISynchronize внешнего call-объекта не является нейтральным к апартаментам, из чего следует, что если вы по каким причинам написали метод Begin, направляющий вызовы в потоки в других апартаментах, Begin должен осуществлять маршалинг указателя на интерфейс ISynchronize при передаче этим потоков. Чтобы проверить это, на время замените

CoInitializeEx (NULL, COINIT_MULTITHREADED);

в ThreadFunc на

CoInitializeEx (NULL, COINIT_APARTMENTTHREADED);

перекомпилируйте сервер и используйте SieveClient для выполнения асинхронного вызова. Приготовьтесь к долгому ожиданию, поскольку вызов никогда не вернется.

Объект Sieve не отслеживает требований отмены, так что в случае отмены вызова клиентом поток, обрабатывающий вызов на сервере, продолжит работу в блаженном неведении о том, что вызов отменен и вся его работа никому не нужна. Это можно исправить, изменив поточную функцию и заставив ее время от времени вызывать ICancelMethodCalls::TestCancel и осуществлять ранний выход, если TestCancel возвращает RPC_ E_CALL_CANCELED. Вместо получения указателя на интерфейс ICancelMethodCalls с помощью вызова CoGetCallContext заставьте поточную функцию получать указатель вызовом QueryInterface на call-объекте. Маршалинг указателя не нужен, если потоки call-объекта и его helper’а разделяют один апартамент. Но в случае разных апартаментов, нужен маршалинг указателя, чтобы переправить указатель в сохранности через границы апартаментов.

Отмена блокирующего вызова

Вы уже знаете, как отменить асинхронный вызов с клиентской стороны, и как отвечать на требования отмены с серверной стороны. Но знаете ли вы, что COM в Windows 2000 позволяет отменять и синхронные (блокирующие) вызовы?

Поток, ожидающий завершения блокирующего вызова, не может отменить вызов самостоятельно именно потому, что он заблокирован в ожидании завершения вызова. Но другой поток может отменить этот вызов от имени вызывающего потока. Для начала другой поток вызывает CoGetCancelObject и запрашивает указатель на интерфейс ICancelMethodCalls. Затем он вызывает функцию ICancelMethodCalls::Cancel следующим образом:

ICancelMethodCalls* pCancelMethodCalls; 
CoGetCancelObject (nThreadID, IID_ICancelMethodCalls, 
                   (void**) &pCancelMethodCalls); 

pCancelMethodCalls->Cancel(0); 
pCancelMethodCalls->Release();

Заметьте, что CoGetCancelObject требует ID потока. Этот ID идентифицирует поток, сделавший блокирующий вызов.

Знайте, что возможность отмены синхронных вызовов по умолчанию отключена и должна быть включена для кааждого потока в отдельности. Поток включает возможность отмены вызовом CoEnableCallCancellation. Будучи задействованной для данного потока, отмена вызовов может быть снова отключена вызовом CoDisableCallCancellation. Обе функции следует вызывать из потока, делающего синхронные вызовы COM-объекта. Поток не может вызывать их от имени другого потока. Включение этой возможности может заметно снизить производительность синхронных вызовов методов, поэтому не следует включать ее без надобности.

Кросс-платформные соглашения

Асинхронные вызовы методов – новинка Windows 2000 и требуют для работы Windows 2000. Тем не менее, COM-клиенты, работающие под Windows 2000 могут асинхронно вызывать COM-серверы на машинах с Windows NT 4.0. COM-серверы, работающие под Windows 2000, могут асинхронно обрабатывать вызовы методов от клиентов Windows NT 4.0. Секрет в использовании двух разных proxy/stub DLL.

Для неблокирующих вызовов Windows NT-машины под с машины под Windows 2000 вы можете скомпилировать две версии proxy/stub DLL: асинхронную версию, в которой интерфейсы помечены атрибутом [async_uuid], и синхронную, скомпилированную без [async_uuid]. Установите асинхронную proxy/stub на машине с Windows 2000, и синхронную на машине с Windows NT 4.0. Теперь клиенты Windows 2000 могут делать неблокирующие вызовы методов серверов Windows NT 4.0.

В обратном сценарии, когда клиенты Windows NT 4.0 вызывают серверы Windows 2000, можно задействовать асинхронную обработку на серверной стороне, опять-таки скомпилировав две версии proxy/stub DLL. В этот раз установите синхронную версию на клиентской машине (под Windows NT), а асинхронную – на серверной (W2K). Готово! Вызовы будут блокировать клиента, но сервер сможет обрабатывать их асинхронно.

Запреты и ограничения.

Прежде чем ринуться все это воплощать, узнайте о следующих ограничениях.

Во-первых, как уже говорилось, call-объекты поддерживают один исходящий вызов за один раз. Чтобы выполнить два или более вызовов, используйте несколько call-объектов. Знайте, что если два или более вызовов размещены одновременно, порядок их поступления на сервер не есть порядок, в котором они сделаны. Например, если клиент вызывает Begin_Foo, а потом Begin_Bar, метод Foo может быть вызван до метода Bar, а может и нет.

Во-вторых, асинхронные вызовы методов зависят от proxy/stub DLL, компилированных с атрибутами [async_uuid] и поэтому не могут использоваться с маршаллингом на основе библиотек типов. Асинхронные вызовы несовместимы и с интерфейсами IDispatch или дуальными интерфейсами. MIDL отвергнет атрибут [async_uuid] в приложении к IDispatch интерфейсу, или любому другому, выведенному из IDispatch. Другими словами, используйте только базирующиеся на IUnknown интерфейсы и забудьте о библиотеках типов. Единственная ситуация, где не нужна собственная proxy/stub DLL – это когда клиент создает локальный компонент в том же апартаменте, реализующий ICallFactory. Даже без proxy/stub DLL этот клиент сможет использовать ICallFactory::CreateCall для создания call-объекта и затем выполнять асинхронные вызовы, вызывая методы Begin и Finish call-объекта. Отмена вызова в этом сценарии работать не будет, если только серверный call-объект не реализует ICancelMethodCalls.

Наконец, COM не поддерживает асинхронных вызовов компонентов, включенных в COM+ или MTS-приложения. Это можно обойти, вызывая асинхронно компоненты-wrapper’ы, которые затем переправят вызовы другим компонентам.

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


Впервые статья была опубликована в журнале <Технология Клиент-Сервер>.
Эту и множество других статей по программированию, разработке БД, многоуровневым технологиям (COM, CORBA, .Net, J2EE) и CASE-средствам вы можете найти на сайте www.optim.su и на страницах журнала.
    Сообщений 2    Оценка 120        Оценить