다음을 통해 공유


비동기 프로그래밍 모델

Microsoft GDK(게임 개발 키트)는 Xbox One ERA 프로그래밍 모델의 일부로 구현된 비동기 패턴과 관련하여 게임 개발자로부터 받은 피드백을 해결하는 비동기 API에 대한 새로운 패턴을 구현합니다. 이 새로운 패턴을 통해 일반적인 게임 아키텍처에 통합하기 쉽게 만들고 게임 개발자들이 요청하는 높은 수준의 제어를 제공하는 것이 목표입니다. 이 항목에서는 해당 디자인 패턴을 설명하고 비동기 패턴을 구현하는 데 사용될 수 있는 라이브러리에 대한 제안을 제공합니다.

개념적 모델

Microsoft GDK(게임 개발 키트)의 비동기 프로그래밍은 2개의 주 구성 요소인 작업 및 작업 큐로 나뉩니다. 라이브러리에 더 많은 기능이 존재하는 반면 전체 개념 모델에는 이러한 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는 Microsoft GDK(게임 개발 키트)에서 Xbox One ERA 프로그래밍 모델만큼 일반적이지 않습니다. 비동기 API는 일반적으로 오래 실행되고 취소 가능합니다. 몇 가지 특정 사용 사례를 제외하고 비동기식 API에는 시간이 중요하지 않은 안전한 동기식 버전이 있습니다. 비동기 API 호출은 항상 시간이 중요하고 안전해야 합니다.

  • 알림: 알림은 사실상 주기적이며 유효 기간이 정의되어 있지 않습니다. 비동기 API와 관련되어 있지만 주기적인 성격으로 인해 개발자에게 다르게 보이고 작동됩니다. 알림 등록은 항상 시간이 중요한 안전이어야 합니다.

비동기 API 패턴

Microsoft GDK(게임 개발 키트)에서는 Microsoft 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 작업이 완료되면 호출되는 옵션 콜백 함수입니다.

Internal 필드는 시스템에서 사용되며 수정할 수 없습니다. 이 구조체에서 사용자가 설정할 수 있는 필드는 비동기 작업 중에 수정해서는 안 됩니다. 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은 호출을 취소하는 데 사용됩니다. 취소는 취소되는 작업에 따라 동기적으로 또는 비동기적으로 발생하거나 전혀 발생하지 않을 수 있습니다. 작업이 취소되면 XAsyncGetResult, XAsyncGetResultSize 또는 XAsyncGetStatus 가 E_ABORT 반환합니다. 호출을 취소하면 XAsyncBlockXAsyncCompletionRoutine 매개 변수가 표시되고 콜백을 유발합니다.

XAsyncRun은 코드를 비동기적으로 실행할 수 있는 도우미 방법입니다.

비동기 API 사용

먼저 다음 코드 예제에서 동기 API를 살펴보겠습니다.

HRESULT XGameSaveGetRemainingQuota(XGameSaveProviderHandle provider,
int64_t* remainingQuota);

이 API는 게임 저장 저장소가 얼마나 남아 있는지 확인할 수 있는 웹 서비스를 호출합니다. 비동기 지원을 추가하려면 새 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에 할당된 작업 큐에서 결정합니다.

작업 큐에는 작업 포트완료 포트라는 두 개의 "포트"가 있습니다. 각 포트에는 포트에 대기 중인 콜백을 처리하는 방법을 결정하는 디스패치 모드가 있습니다. 디스패치 모드에는 여러 가지가 있습니다.

  • 스레드 풀: 스레드 풀 큐에 대기 중인 콜백이 시스템 스레드 풀에서 실행됩니다. 스레드 풀은 호출을 병렬로 호출하며 스레드 풀 스레드를 사용할 수 있게 되면 큐에서 차례로 호출을 실행합니다.

  • 연속 스레드 풀: 콜백은 대기하며 스레드 풀에서 실행되지만 한 번에 하나씩 실행합니다.

  • 수동: 수동 큐에 대기 중인 콜백은 자동으로 디스패치되지 않습니다. 원하는 스레드에서 발송하는 것은 개발자 책임입니다.

  • 즉시: 즉시 디스패치 모드는 전혀 대기하지 않습니다. 콜백을 제출한 스레드에서 즉시 호출을 실행합니다.

기본 프로세스 작업 큐가 구성되어 작업 포트와 완료 포트 둘 다 시스템 스레드 풀을 통해 발송됩니다. 이 프로세스 작업 큐는 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은 최종 "대기" 매개 변수를 가져와 bool을 반환합니다. 이렇게 하면 호출자가 호출이 호출되는 동안 등록 취소를 처리하는 방법을 결정합니다.

비동기 라이브러리

비동기 패턴을 지원하는 일관된 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에 전달해야 합니다.

다음 작업으로 비동기 공급자 콜백이 호출됩니다.

  • BeginXAsyncBegin 중에 이 opcode로 비동기 제공자가 호출됩니다. 공급자가 이 op 코드를 구현한 경우에는 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);
}

참고 항목

비동기식 프로그래밍 설계 목적 및 개선 사항
비동기 작업 큐 디자인