异步编程模型

Microsoft 游戏开发工具包 (GDK) 实现异步 API 的新模式:解决我们收到的有关作为 Xbox One ERA 编程模型一部分实现的异步模式的游戏开发人员的反馈。 我们的目标是:通过这种新模式大大简化集成到典型游戏体系结构的过程,同时给与游戏开发者他们所要求的高度控制。 本主题介绍了该设计模式,并为可用于实现异步模式的库提供了建议。

概念性模型

Microsoft 游戏开发工具包 (GDK) 中的异步编程分为 2 个主要组件: 任务和任务队列。 虽然库中有更多的功能,但整个概念模型利用了这两个主要组件。

任务是一组可启动、检查其状态、可能取消、完成并返回其完成信息的异步工作。 对于 Microsoft 游戏开发工具包 (GDK) 模型,任务由两个实体组成: 工作回调和完成回调。 这允许更多的控制,如完全并行处理或并行工作与单线程完成相结合。

任务队列是一个容器,用于排队工作和完成回调,以便以后执行。 任务队列中有两个内部队列(称为“端口”)分别处理工作和完成回调。 这些称为“工作端口”和“完成端口”。

图 1. 任务和任务队列的图表
任务和任务队列的图表。

在创建时,任务队列的每个端口配置不同,以创建不同的回调执行行为。 例如,工作端口可以配置为异步,完成端口可以配置为在主线程上串行运行。 可以设置手动设置,以启用对执行行为的完全控制。 以下内容介绍了端口配置模式。

启动异步任务时,回调不会立即排队到任务队列中。 异步提供程序处理状态更改,以确保在完成回调排入队列和调度之前将工作排入队列和进行调度。

任务队列不直接处理线程本身。 相反,它依赖外部调用来调度其端口。 外部调用确定线程和并发行为。 任务队列本身是完全线程安全的。

图 2. 端口被调度到多个线程
显示正在调度到多个主题的端口的图像。

确实如此! 任务的回调排队到任务队列的工作和完成端口上,并且任务队列具有以某种方式调度的回调。 API 包含一整套功能,用于管理任务队列、检查回调状态、跟踪工作数据、创建自定义任务处理等。

Microsoft 游戏开发工具包 (GDK) 异步 API 调用始终在内部实现工作回调,完成回调始终是可选的。 对于 Microsoft 游戏开发工具包 (GDK) 异步调用以外的使用情况,必须提供工作回调。

要求

游戏开发者已列出以下 API 调用要求。

  1. 同步调用优于异步调用
  2. 提供异步轮询
  3. 提供异步回调
  4. 提供针对异步工作可执行的线程的控制
  5. 提供针对可执行完成回调的线程的控制

API 类型

Microsoft 游戏开发工具包 (GDK) 应尽量使其 API 设计极为简单明了。 游戏开发者在微调其代码以最大限度地利用硬件方面堪称专家。 我们为他们提供尽可能多的控制。 API 实现细分为以下类型。

  • 时间敏感安全:时间敏感安全 API 是一种可以对时间敏感线程调用的 API。 请注意,尽管这通常意味着 API 不太重要或速度很快,但关键的概念在于 API 的性能特征为一致。 它们始终同步,从不需要具有异步版本。 这些 API 应记录为时间敏感安全。

  • 非时间敏感安全:从呈现线程调用这些 API 是不安全的。 它们的性能特征可能会有很大不同。 大多数 API 都属于此类。

  • 异步:这些 API 在本质上是异步的,如 Web 服务调用。 它们使用本主题中介绍的异步模式。 异步 API 在 Microsoft 游戏开发工具包 (GDK) 中并不像它们在 Xbox One ERA 编程模型中那样常见 — 异步 API 通常运行时间长且可取消。 除了一些特定的用例,异步 API 将具有非时间关键安全同步版本。 调用异步 API 应始终是时间关键安全的。

  • 通知:通知本质上是定期的,并且没有定义的终结。 它们与异步 API 相关,但因其具有定期性质,所以对开发者而言,它们的外观和操作都会有所不同。 注册通知应始终是时间关键安全的。

异步 API 模式

Microsoft 游戏开发工具包 (GDK) 介绍一种通用异步 API 模式,Microsoft 游戏开发工具包 (GDK) 组件可使用该模式提供一致异步支持。 该模式的核心部分是一种类似于 OVERLAPPED 的结构,称为 XAsyncBlock

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 可用于取消调用。 取消操作取决于被取消的操作,可能同步、异步或根本不进行。 如果取消一项操作,XAsyncGetResultXAsyncGetResultSizeXAsyncGetStatus 返回 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(因为只有在返回 E_PENDING 中没有值时,此 API 才是异步的)。 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 的任务队列决定。

任务队列中有两个“端口”:工作端口完成端口。 每个端口都有一个调度模式,用来确定如何处理在端口排队的回调。 调度模式有几下几种。

  • 线程池:在线程池队列排队的回调在系统线程池上执行。 线程池并行调取调用,在线程池线程可用时依次从队列中提取要执行的调用。

  • 序列化线程池:回调在队列中排队并在线程池上运行,但是一次只运行一个。

  • 手动:在手动队列排队的回调不会自动调度。 开发者负责将它们调度到所需的任何线程。

  • 立即:立即调度模式根本不用排队。 它会立即在提交回调的线程上执行调用。

有一个默认进程任务队列,该队列经过配置,可以通过系统线程池同时调度工作端口和完成端口。 如果没有在 XAsyncBlock 中传递队列参数,则使用此进程任务队列。 游戏也可以禁用进程任务队列,要求将某个队列传递到 XAsyncBlock 中。

我们期望许多开发者能够选择手动调度模式来完全控制异步工作和完成回调的执行时间和位置。

有关任务队列的详细信息,请参阅异步任务队列设计

通知

通知可能没有终结,所以可以多次调用。 通知应支持异步调用的一部分要求。

  1. 异步轮询
  2. 异步回调
  3. 控制在哪个线程上执行回调

通知使用任务队列来允许开发者控制回调线程,但请不要使用异步块 — 按照设计,通知更像是 RegisterUnregister 方法的标准事件。

  • 采用任何调用特定参数、任务队列、可选的无效上下文和强类型的回调指针的 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。 如果你可以使用操作系统异步基元执行该工作,并且设置这些基元速度快得足以保持时间关键安全,则这是首选方法。

  3. 如果需要从工作人员线程回调调用其他异步工作,可以从该工作线程返回 E_PENDING。 您还可以从工作线程内部调用 XAsyncSchedule 以便重新安排其他工作。

  4. 完成所有工作后,调用 XAsyncComplete

  5. XAsyncGetResult 周围提供强类型包装器以返回结果。

  6. 如果您的异步调用没有数据有效负载,则应在 XAsyncGetStatus 周围提供强类型包装器,并向 XAsyncComplete 传递 0 作为所需的缓冲区大小。

将使用以下操作调用异步提供程序回调。

  • Begin:在 XAsyncBegin 期间使用此操作代码调用异步提供程序。 如果提供程序实现了此操作代码,则应通过调用 XAsyncSchedule 或通过外部方式来开始其异步任务。 在 XAsyncBegin 调用链中同步调用此回调,因此它应该从不会进行阻止。

  • DoWork 在调用 XAsyncSchedule 以便使用任务队列安排异步工作的情况下调用。 提供程序功能执行它所需执行的所有工作。 完成后,它使用结果代码和数据有效负载大小(如果调用不具有数据有效负载,则该大小可能为 0)调用 XAsyncComplete。 如果需要执行更多异步工作,提供程序可以安排该工作并应返回 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);
}

另请参阅

异步编程设计目标和改进
异步任务队列设计