非同期プログラミング モデル

Microsoft Game Development Kit (GDK) は、非同期 API の新しいパターンを実装しています。これは、Xbox One ERA プログラミング モデルの一部として実装されている非同期パターンに関するゲーム デベロッパーの皆様からのフィードバックにお応えしたものです。 その目標は、この新しいパターンを一般的なゲームのアーキテクチャに簡単に統合できるようにし、ゲーム デベロッパーが望む高度な制御を提供することあります。 このトピックでは、その設計パターンについて説明し、非同期パターンの実装に使用できるライブラリに対する提案を示します。

概念モデル

Microsoft Game 開発キット (GDK) の非同期プログラミングは、タスクとタスク キューという 2 つの主要なコンポーネントに分かれています。 ライブラリにはさらに多くの機能がありますが、概念モデル全体でこの 2 つの主要なコンポーネントを活用します。

タスクは、開始、状態のチェック、取り消し、完了が可能であり、完了情報を返す非同期処理の単一のセットです。 Microsoft Game 開発キット (GDK) モデルのタスクは、処理コールバックと完了コールバックの 2 つの主要部分から構成されています。 これにより、完全な並列処理やシングルスレッドの完了を組み合わせた並列処理などの制御が強化されます。

タスク キューは、後から実行するために処理コールバックと完了コールバックの両方をエンキューするコンテナーです。 タスク キューにはポートと呼ばれる 2 つの内部キューがあり、処理コールバックと完了コールバックを個別に処理します。 これらは、処理ポートおよび完了ポートと呼ばれます。

図 1. タスクとタスク キューの図
タスクとタスク キューの図。

タスク キューの各ポートが作成時に異なる方法で構成され、異なるコールバック実行動作が作成されます。 たとえば、処理ポートを非同期に構成し、完了ポートをメイン スレッドで逐次実行するように構成できます。 手動設定により、実行動作を完全に制御できます。 ポート構成モードについては、以下で説明します

非同期タスクが開始された場合、コールバックはタスク キューにすぐにはエンキューされません。 非同期プロバイダーは、完了コールバックがエンキューおよびディスパッチされる前に、処理がエンキューおよびディスパッチされるように状態変更を処理します。

タスク キューはスレッドを直接処理しません。 代わりに、ポートのディスパッチを外部呼び出しに依存します。 外部呼び出しによって、スレッド処理および同時実行動作が決定されます。 タスク キュー自体は完全にスレッドセーフです。

図 2. 複数スレッドにディスパッチされているポート
ポートが複数のスレッドにディスパッチされていることを示す画像。

これは本質的にです。 タスクのコールバックはタスク キューの処理ポートと完了ポートにエンキューされます。そのタスク キューには、何らかの方法でディスパッチされたこれらのコールバックが格納されます。 API には、タスク キューを管理する、コールバックのステータスをチェックする、作業データを追跡する、カスタム タスク処理を作成するなどの一連の機能が含まれています。

Microsoft Game 開発キット (GDK) 非同期 API 呼び出しは常に内部で処理コールバックを実装し、完了コールバックは常にオプションです。 Microsoft Game 開発キット (GDK) の非同期呼び出し以外の使用については、処理コールバックを指定する必要があります。

要件

ゲーム デベロッパーからは、API の呼び出しに関して次のような要件が示されました。

  1. 非同期呼び出しより同期呼び出しを優先する。
  2. ポーリングを備えた非同期呼び出しを提供する。
  3. コールバックを備えた非同期呼び出しを提供する。
  4. 非同期処理が実行されるスレッドを制御できるようにする。
  5. 完了コールバックが実行されるスレッドを制御できるようにする。

API の種類

Microsoft Game Development Kit (GDK) では、API の設計を非常に単純にすることを目指す必要があります。 ゲーム デベロッパーは、ハードウェアを最大限に使用するようコードを微調整するエキスパートです。 そのようなデベロッパーには可能な限りのコントロールを提供します。 API の実装を次の種類に分けます。

  • 時間依存セーフ: 時間依存セーフ API は、時間依存スレッドで呼び出すことができる API です。 これは通常、API が小さいまたは非常に高速であることを意味しますが、主要なコンセプトは API のパフォーマンス特性に一貫性を持たせることである点に留意してください。 これらは常に同期であり、非同期バージョンを用意する必要はありません。 ドキュメントでは、これらの API を時間依存セーフと記載する必要があります。

  • 非時間依存セーフ: これらの API をレンダー スレッドから呼び出すのは安全ではありません。 パフォーマンス特性が大きく変動する可能性があります。 ほとんどの API は、このカテゴリに分類されます。

  • 非同期: Web サービス呼び出しなど、これらの API は本質的に非同期です。 これらは、このトピックで説明する非同期パターンを使用します。 Microsoft Game Development Kit (GDK) での非同期 API は、Xbox One ERA プログラミング モデルでの非同期 API ほど一般的ではありません。非同期 API は、通常、実行時間が長く、取り消すことができます。 いくつかの特定のユース ケースを除き、非同期 API には非タイム クリティカル セーフ バージョンがあります。 非同期 API の呼び出しは常に、タイム クリティカル セーフである必要があります。

  • 通知: 通知は本質的に定期的であり、定義されている終了はありません。 通知は非同期 API に関連していますが、その定期的な性質のため、デベロッパーにとって、見た目と動作が異なる場合もあります。 通知に対する登録は常に、タイム クリティカル セーフである必要があります。

非同期 API パターン

Microsoft Game Development Kit (GDK) では、Microsoft Game Development Kit (GDK) コンポーネントが一貫した非同期サポートを提供するために使用できる汎用非同期 API パターンが導入されています。 核になるのは、XAsyncBlock と呼ばれる OVERLAPPED に似た構造体です。

typedef void CALLBACK XAsyncCompletionRoutine(struct XAsyncBlock* asyncBlock);

struct XAsyncBlock
{
    XTaskQueueHandle queue;
    void* context;
    XAsyncCompletionRoutine* callback;
    unsigned char internal[sizeof(void*) * 4];
};

XAsyncBlock は呼び出し元が提供する構造体です。 呼び出し元は、次の表に示すように、この構造体の省略可能なフィールドに値を入力します。

フィールド 説明
queue どのスレッドで非同期呼び出しを実行するかを制御できるタスク キュー ハンドル。 このパラメーターが null の場合は、プロセス タスク キューが使用されます。 プロセス タスク キューが null に設定されている場合、呼び出しが E_NO_TASK_QUEUE で失敗することがあります。
context コールバック関数に渡されるオプションのコンテキスト ポインター。
callback 操作が完了すると呼び出されるオプションのコールバック関数。

内部フィールドはシステムによって使用されるので、変更しないようにする必要があります。 この構造体のユーザー設定可能フィールドは、非同期操作中に変更しないようにする必要があります。 非同期操作が有効な間、XAsyncBlock をメモリ内に維持する必要があります。 XAsyncBlock が動的に割り当てられている場合、完了のコールバックは削除可能になる最初のタイミングになります。

XAsyncBlock のほか、以下に示す少数の ヘルパー API があります。

STDAPI XAsyncGetStatus(XAsyncBlock* asyncBlock, bool wait);

STDAPI XAsyncGetResultSize(XAsyncBlock* asyncBlock, size_t* bufferSize);

STDAPI_(void) XAsyncCancel(XAsyncBlock* asyncBlock);

typedef HRESULT CALLBACK XAsyncWork(XAsyncBlock* asyncBlock);

STDAPI XAsyncRun(XAsyncBlock* asyncBlock, XAsyncWork* work);

XAsyncGetStatus は、非同期呼び出しのステータスを返します。 呼び出しを開始した時点で、このステータスは E_PENDING になっています。 呼び出しが完了すると、S_OK または特定のエラーに変化します。 呼び出しが取り消されると、E_ABORT を返します。

XAsyncGetResultSize では、呼び出しの結果を取得するために必要なバッファー サイズが返されます。 結果をフェッチする実際の API は、各非同期呼び出しに合わせて調整されます。

XAsyncCancel を使用すると、呼び出しを取り消すことができます。 取り消しがどのように実行されるかは、取り消す操作によって異なり、同期で実行されるもの、非同期で実行されるもの、またはまったく実行されないものがあります。 操作を取り消すと、XAsyncGetResultXAsyncGetResultSize、または XAsyncGetStatus で E_ABORT が返されます。 取り消された呼び出しによって XAsyncBlockXAsyncCompletionRoutine パラメーターが通知され、コールバックが呼び出されます。

XAsyncRun は、任意のコードを非同期で実行できるヘルパー メソッドです。

非同期 API の使用法

まず、次のコード例の同期 API を確認します。

HRESULT XGameSaveGetRemainingQuota(XGameSaveProviderHandle provider,
int64_t* remainingQuota);

この API では、残っているゲーム セーブ ストレージの量を確認する Web サービスが呼び出されます。 非同期のサポートを追加するには、新しい API のペアを宣言します。

HRESULT XGameSaveGetRemainingQuotaAsync(XGameSaveProviderHandle
provider, XAsyncBlock* async);

HRESULT XGameSaveGetRemainingQuotaResult(XAsyncBlock* async,
int64_t* remainingQuota);

XGameSaveGetRemainingQuotaAsync は、非同期呼び出しが開始されている場合は S_OK を返します (この API は非同期のみなので、E_PENDING で返す値はありません)。 XGameSaveGetRemainingQuotaResult は、呼び出しが完了するまで E_PENDING を返します。

その実例を以下に挙げます。

// providerHandle is a previously obtained XGameSaveProviderHandle.

XAsyncBlock* b = new XAsyncBlock;
ZeroMemory(b, sizeof(XAsyncBlock));
b->context = this;
b->queue = queue;
b->callback = [](XAsyncBlock* async)
{
    int64_t remainingQuota;
    if(SUCCEEDED(XGameSaveGetRemainingQuotaResult(async, &remainingQuota)))
    {
        printf("Remaining quota: %irn", remainingQuota);
    }
    delete async;
};
XGameSaveGetRemainingQuotaAsync(providerHandle, b);

XAsyncBlocks はすべて、(以下で説明するように) タスク キューを必要とし、非同期呼び出しを実行する場所と方法を制御します。 タスク キューを指定しない場合、プロセス全体のタスク キューが使用されます。

非同期呼び出しの有効期間中、XAsyncBlock はメモリ内に存在している必要があることに注意してください。 この例では、動的に割り当てられて、完了コールバックで削除されます。 グローバル変数またはメンバー変数として保存することもできます。 複数の非同期呼び出しに対して一度に同じ XAsyncBlock を使用した場合、どのような動作が発生するかは不定です。

XGameSaveGetRemainingQuotaResult は、非同期呼び出しのサイクルを完了します。 新しい呼び出しにブロックを使用できるように、非同期ブロック内の内部データを解放します。 それ以降に XGameSaveGetRemainingQuotaResult を呼び出すと、それは失敗します。 XGameSaveGetRemainingQuotaAsyncXGameSaveGetRemainingQuotaResult は、非同期ブロックの内部でもペアになっています。非同期呼び出しに対応する結果取得用の API が存在しないとエラーが発生します。

非同期呼び出しにデータ ペイロードがない場合は、HRESULT のステータスのみが重要となり、以下に示すように非同期ブロックのみを受け取る Result メソッドを定義します。

HRESULT QueryUpdateStatusAsyncResult(_Inout_ XAsyncBlock* block);

処理のディスパッチの制御

前の呼び出しで非同期処理を実行したのはどのスレッドですか。 完了コールバックを呼び出したのはどのスレッドですか。 それは、XAsyncBlock に割り当てられているタスク キューによって決まります。

タスク キューには、処理ポート完了ポートの 2 つの "ポート" があります。 各ポートには、ポートのキューに置かれたコールバックの処理方法を決定するディスパッチ モードがあります。 ディスパッチ モードにはいくつかの種類があります。

  • スレッド プール: スレッド プール キューに置かれたコールバックは、システム スレッド プールで実行されます。 スレッド プールは、スレッド プールのスレッドが利用可能になると、実行する呼び出しをキューから順番に受け取り、複数の呼び出しを並行で実行します。

  • シリアル化したスレッドプール: コールバックはキューに置かれ、スレッドプールで一度に 1 つずつ実行されます。

  • 手動: 手動キューに置かれたコールバックは、自動的にはディスパッチされません。 デベロッパー側で、目的のスレッドにそれらをディスパッチする必要があります。

  • 即時: 即時ディスパッチ モードでは、コールバックはキューに入りません。 コールバックの送信元スレッドで呼び出しが直ちに実行されます。

処理ポートと完了ポートの両方がシステム スレッド プールを介してディスパッチされるように構成されている、既定のプロセス タスク キューがあります。 このプロセス タスク キューは、XAsyncBlock へキュー パラメーターが渡されない場合に使用されます。 ゲームでは、プロセス タスク キューを無効にして、XAsyncBlock にキューが渡される必要があるようにすることもできます。

多くのデベロッパーが、非同期の処理と完了のコールバックを実行するタイミングと場所を全面的に制御するために、手動ディスパッチ モードを選択することが想定されます。

タスクキューの詳細については「非同期タスクキューの設計」を参照してください。

通知

通知には終了がない場合があり、何度も呼び出される場合があります。 通知では、非同期呼び出しの要件のサブセットをサポートする必要があります。

  1. ポーリングを備えた非同期呼び出し
  2. コールバックを備えた非同期呼び出し
  3. どのスレッドでコールバックを実行するかの制御

通知では、デベロッパーがコールバック スレッドを制御できるようにタスク キューが使用されますが、それ以外では非同期ブロックは使用されません。つまり、通知は、Register メソッドと Unregister メソッドを持つ標準的なイベントと同じように設計されています。

  • 呼び出し固有のあらゆるパラメーター、タスク キュー、オプションの void コンテキスト、および厳密に型指定されたコールバック ポインターを受け取る Register メソッド。 最後のパラメーターは、トークンを返す出力パラメーターです。

  • 呼び出し固有の任意のコンテキストとトークンを受け取る Unregister メソッド。

  • ポーリングは、通知コールバックに関係のない別のメソッドを追加することで使用できます。

Windows メッセージをフェッチする次の例を検討します。

struct XTaskQueueRegistrationToken;

typedef void MessageAvailableCallback(void* context, const MSG* msg);

HRESULT RegisterMessageAvailable(
    XTaskQueueHandle queue,
    void* context,
    MessageAvailableCallback* callback,
    XTaskQueueRegistrationToken * token);

bool UnregisterMessageAvailable(XTaskQueueRegistrationToken token, bool
wait);

// Usage.
XTaskQueueRegistrationToken token;
RegisterMessageAvailable(queue, nullptr, [](void*, const MSG* msg)
{
    printf("Message: %drn", msg->message);
}, &token);

この例の UnregisterMessageAvailable は最後の "wait" パラメーターを受け取って、ブール値を返すことに注意してください。 これにより、呼び出し元は呼び出しが行われている間に登録解除を処理する方法を決定できます。

非同期ライブラリ

非同期パターンをサポートする一貫した API を容易に作成できるように、API の "非同期プラミング" の実装に使用できるライブラリが用意されています。 そのライブラリの API は次のようになります。

enum class XAsyncOp : uint32_t
{
    Begin,
    DoWork,
    GetResult,
    Cancel,
    Cleanup
};

struct XAsyncProviderData
{
    XAsyncBlock* async;  
    size_t bufferSize;  
    void* buffer;  
    void* context;
};

typedef HRESULT CALLBACK XAsyncProvider(
_In_ XAsyncOp op,
_Inout_ XAsyncProviderData* data);

STDAPI XAsyncBegin (
_Inout_ XAsyncBlock* asyncBlock,
_In_opt_ void* context,
_In_opt_ void* identity,
_In_opt_ const char* identityName,
_In_ XAsyncProvider* provider);

STDAPI XAsyncSchedule(
_Inout_ XAsyncBlock* asyncBlock,
_In_ uint32_t delayInMs);

STDAPI_(void) XAsyncComplete(
_Inout_ XAsyncBlock* asyncBlock,
_In_ HRESULT result,
_In_ size_t requiredBufferSize);

STDAPI XAsyncGetResult(
_Inout_ XAsyncBlock* asyncBlock,
_In_opt_ void* identity,
_In_ size_t bufferSize,
_Out_writes_bytes_opt_(bufferSize) void* buffer,
_Out_opt_ size_t* bufferUsed);

この API では、API が呼び出された理由を示す操作値と組み合わされた、単一のコールバックが使用されます。 呼び出しの進行とともにデータが設定される単一のデータ構造もあります。 この API を使用するには以下の手順に従います。

  1. 呼び出し元から渡された非同期ブロックを使用して XAsyncBegin を呼び出し、実装を提供するコールバックを用意します。

  2. 呼び出しに対して非同期操作を実行します。 ワーカー スレッドで処理を実行する必要がある場合は、XAsyncSchedule を呼び出します。 OS の非同期プリミティブを使用して処理を実行し、タイム クリティカル セーフを保持するうえで十分な速さでプリミティブを設定できる場合は、これが好ましい方法です。

  3. ワーカー スレッドのコールバックから他の非同期処理を呼び出す必要がある場合は、ワーカーから E_PENDING を返します。 ワーカーの内部から XAsyncSchedule を呼び出して、追加の処理を再スケジュールすることもできます。

  4. すべての処理が完了したら、XAsyncComplete を呼び出します。

  5. 厳密に型指定されたラッパーで XAsyncGetResult を囲み、結果を返します。

  6. 非同期呼び出しにデータ ペイロードがない場合は、厳密に型指定したラッパーで XAsyncGetStatus を囲み、必要なバッファー サイズとして 0 を XAsyncComplete に渡す必要があります。

非同期プロバイダー コールバックは、次の操作で呼び出されます。

  • 非同期プロバイダーの開始は、XAsyncBegin中、このオペコードで呼び出されます。 このオペコードがプロバイダーに実装されている場合は、XAsyncSchedule を呼び出すか、外部的な手段を通して、非同期タスクを開始する必要があります。 このコールバックは XAsyncBegin の呼び出しチェーンで同期的に呼び出されるので、ブロックされないようにする必要があります。

  • DoWork タスク キューを使用して非同期処理をスケジュールするために XAsyncSchedule を呼び出したときに呼び出されます。 プロバイダー関数では、そこで実行する必要がある処理が実行されます。 その処理が完了すると、結果コードとデータ ペイロードのサイズを指定して XAsyncComplete が呼び出されます。呼び出しからデータ ペイロードが返されない場合、サイズは 0 でもまいません。 別の非同期処理を実行する必要がある場合、プロバイダーはその処理をスケジュールできますが、E_PENDING を返す必要があります。

  • GetResult 呼び出しの結果をフェッチするために呼び出されます。 呼び出し完了の間にデータ サイズが XAsyncComplete に渡されるため、ここでは引数をチェックする必要はありません。すべてのバッファーとバッファー サイズはライブラリによって検証されています。

  • Cancel 非同期呼び出しを取り消すと呼び出されます。 非同期呼び出しがキャンセル可能な場合は、その呼び出しを取り消し、E_ABORT を結果コードとして XAsyncComplete が呼び出されます。

  • Cleanup 呼び出しが完全に終了し、プロバイダーが動的メモリをすべて削除できるようになると呼び出されます。

非同期プロバイダーでは、必要な操作だけを実装するだけで済みます。 たとえば、クリーンアップ動作を持たないキャンセル不可能な非同期 IO には GetResult のみを実装します。

階乗を非同期的に実装する FactorialAsync メソッドの例を次に示します。

UINT64 Factorial(UINT64 value)
{
    UINT64 result = 1;

    while (value != 0)
    {       
        result *= value;
        value--;
    }

    return result;
}

HRESULT FactorialAsync(UINT64 value, XAsyncBlock* async)
{
    struct CallData
    {
        UINT64 value;
        UINT64 result;
    };

    CallData* data = new CallData();
    data->value = value;
    data->result = 1;

    HRESULT hr = XAsyncBegin (async, data, FactorialAsync, __FUNCTION__, []
        (XAsyncOp op, XAsyncProviderData* data)
    {
        CallData* d = (CallData*)data->context;

        switch (op)
        {
        case XAsyncOp::Begin:
            return XAsyncSchedule(data->async, 0);

        case XAsyncOp::Cleanup:
            delete d;
            break;

        case XAsyncOp::GetResult:
            CopyMemory(data->buffer, &d->result, sizeof(UINT64));
            break;
 
        case XAsyncOp::DoWork:
            data->result = Factorial(data.Value);
            XAsyncComplete(data->async, S_OK, sizeof(UINT64));
            break;
        }

        return S_OK;
    });

    return hr;
}

HRESULT FactorialAsyncResult(XAsyncBlock* async, UINT64* result)
{
    return XAsyncGetResult(async, FactorialAsync, sizeof(UINT64), result);
}

関連項目

非同期プログラミングの設計の目標と改善点
非同期タスク キューの設計