WINDOWS/C++ - WinRT

C++/WinRT를 통한 동시성 및 비동기 작업

게임 개발 2024. 5. 23. 23:50

C++/WinRT를 통한 동시성 및 비동기 작업

 

이 게시글의 내용

  • 비동기 작업 및 Windows 런타임 "비동기" 함수
  • 호출 스레드 차단
  • 코루틴 작성
  • Windows 런타임 형식을 비동기식으로 반환

 

해당 게시글의 내용 중에서 중요하게 볼 것

 

이 항목에서는 코루틴 및 co_await 의 개념을 소개하며,

UI 및 비 UI 애플리케이션 모두에서 사용하는 것이 좋다.

간단히 하기 위해서는 이 소개 항목의 코드 예제에서는 대부분

Windows 콘솔 애플리케이션 (C++/WinRT)프로젝트를 보여준다.

이 항목의 뒷부분에 나오는 코드 예제에서는 코루틴을 사용하지만,

편의상 콘솔 애플리케이션 예제에서 종료 직전에 차단 get함수 호출도 사용하므로

출력 인쇄를 마치기 전에 애플리케이션이 종료되지 않는다.

UI 스레드에서 이 작업 (차단 get 함수 호출)을 수행하지 않는다.

대신 co_await 문을 사용한다.

UI 애플리케이션에서 사용하는 기술은 고급 동시성 및 비동기 문서를 참고한

게시글에서 설명하겠다.

 

비동기 작업 및 Windows 런타임 "비동기"함수

 

완료하는 데 50밀리초 이상 걸릴 가능성이 높은 Windows 런타임 API는

비동기 함수(이름이 "Async"로 끝나는 함수)로 구현된다.

비동기 함수의 구현은 다른 스레드에서 작업을 시작하고,

비동기 작업을 나타내는 개체와 함께 즉시 반환한다.

비동기 작업이 완료되면, 반환된 개체의 작업의 결과 값이 포함된다.

Windows::Foundation Windows 런타임 네임스페이스에는

네 가지 유형의 비동기 작업 개체가 포함된다.

 

  • IAsynAction
  • IAsyncActionWithProgress<TProgress>
  • IAsyncOperation<TResult>
  • IAsyncOperationWithProgress<TResult, TProgress>

각 비동기 작업 유형은

winrt::Windows::Foundation C++/WinRT 네임스페이스 해당 유형에 프로젝션된다.

C++/WinRT에는 내부 await 어댑터 구조체도 포함되어 있다.

직접 사용하지 않지만, 해당 구조체 덕분에 co_await 문을 작성하여

이러한 비동기 작업 유형 중 하나를 반환하는 함수의 결과를 협조적으로 기다릴 수 있다.

또한 이러한 유형을 반환하는 고유한 코루틴을 작성할 수 있다.

 

비동기 Windows 함수의 예로

IAsyncOperationWithProgress<TResult, TProgress> 형식의

비동기 작업 개체를 반환하는 

SyndicationClient::RetrieveFeedAsync가 있다.

 

C++/WinRT를 사용하여 이러한 API를 호출하는 몇 가지 방법을

차단 방법과 비차단 방법 순으로 살펴보겠다.

기본적인 아이디어를 설명하기 위해 다음 몇 가지 코드 예제에서는

Windows 콘솔 애플리케이션(C++/WinRT)프로젝트를 사용한다.

UI 애플리케이션에서 사용하는 기술은 고급 동시성 및 비동기 문서에서 설명하겠다.

 

 

호출 스레드 차단

 

아래 코드 예제는 RetrieveFeedAsync에서 비동기 작업 개체를 받은 후

해당 개체에서 get을 호출하여 비동기 작업 결과가 제공될 때까지 호출 스레드를 차단한다.

 

이 예제를 복사하여 Windows 콘솔 애플리케이션(C++/WinRT)

프로젝트의 주 소스 코드 파일에 직접 붙여넣는 경우

먼저 프로젝트 속성에서 미리 컴파일된 헤더 사용 안함을 설정해야한다.

//main.cpp

#include <winrt/windows.foundation.h>
#include <winrt/windows.web.syndication.h>

using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::Syndication;

void ProcessFeed()
{
	Uri rssFeedUri{ L"https:://blogs.windows.com/feed" };
	SyndicationClient syndicationClient;
	SyndicationFeed syndicationFeed{ syndicationClient.RetrieveFeedAsync(rssFeedUri).get() };
	// use syndicationFeed.
}

void main()
{

	winrt::init_apartment();
	ProcessFeed();

	return;
}

 

get을 호출하면 편리하게 코딩할 수 있으며

어떤 이유로든 코루틴을 사용하지 않으려는 콘솔 앱이나

백그라운드 스레드에 적합하다.

하지만 동시 또는 비동기가 아니므로 UI 스레드에는 적합하지 않으며,

둘 중 하나에서 사용하려고 하면 최적화되지 않은 빌드에서 어설션이 발생한다.

따라서 OS 스레드 정체로 인해

다른 유용한 작업을 수행하지 못하는 경우를 방지하려면 다른 기술이 필요하다.

이때 사용하는 것이 코루틴이다.

 

 

코루틴 작성을 들어가기 전에,

비동기 프로그래밍과 코루틴의 중요성에 대해 알아보자.

 

비동기 프로그래밍과 코루틴 중요성의 작성

 

소프트웨어 개발에서 비동기 프로그래밍은 사용자 경험의 향상,

자원의 효율적 사용 등을 가능하게 하여 점점 더 중요해지고 있다.

특히, I/O 작업, 네트워크 요청 처리 등에서

비동기 프로그래밍은 필수적인 기술로 자리잡았다.

 

최근 개발자 사이에서 주목받고 있는 코루틴(Coroutine)은

이러한 비동기 프로그래밍을 효율적으로 처리할 수 있는 프로그래밍 모델이다.

코루틴이 효율적으로 처리할 수 있는 이유는

코루틴은 전통적인 멀티스레딩의 복잡성을 줄이고,

코드의 가독성을 높이면서도 뛰어난 비동기 처리 성능을 제공한다.

 

이 글에서는 코루틴이 어떤 기술이며,

프로그래밍에 어떤 혁신을 가져울 수 있는지에 대해 살펴보자.

 

코루틴의 기본 개념과 작동 원리

 

코루틴은 루틴(Routine)이나 서브루틴(Subroutine)을 일반화한 개념으로,

실행을 중단했다가 필요한 시점에 다시 재개할 수 있는 함수이다.

이러한 특성 때문에 코루틴은 '경량 스레드'라고도 불린다.

 

루틴은 명령과 같은 기능을 한다.

즉, 명령들의 순서있는 집합의 기능을 하며,

여기서 명령은 기계의 동작(behavior)을 추상화 한 것이다.

또한 기계 상태가 전이(Transition)되는 것도 말한다.

서브루틴은 호출/종결할 수 있는 루틴이다.

 

코루틴의 가장 큰 특징은

비동기 작업을 순차적인 코드의 흐름으로 표현할 수 있다는 점이다.

이를 통해 콜백 지옥(Callback Hell)과 같은 문제를 피하면서도

비동기 코드의 가독성을 크게 향상시킬 수 있다.

 

코루틴의 작동 원리는 실행 중인 코루틴을 중단(suspend)시킨 후,

시스템이나 다른 코루틴의 작업이 완료되면

해당 코루틴을 다시 재개(resume)하는 방식으로 진행된다.

이 과정에서 개발자는 복잡한 스레드 관리나 동기화 작업 없이도

쉽게 비동기 프로그래밍을 구현할 수 있다.

 

 

 

코루틴을 지원하는 프로그래밍 언어와 활용 사례

 

코루틴을 지원하는 프로그래밍 언어로는 코틀린, 파이썬,자바스크립트 등이 있다.

특히, 코루틴에서 코루틴을 일급 시민으로 취급하며,

언어 차원에서 깊이 통합된 코루틴 지원을 제공한다.

 

이러한 코루틴을 프로세스와 스레드에 대입해보면,

 

프로세스는 즉 os(ghrdms vm)에서 프로그램을 실행하는 방법이다.

즉 루틴들의 집합체이며,

 

스레드는 프로세스 내에서 제어 흐름을 추상화한 것으로

즉 프로세서(cpu)이다.

 

그럼 코루틴은 뭘까?

프로세스가 호출되고 종결되고 중단되고 재개되는 과정이다.

즉 개념적으로는 코루틴은 스레드와 무관하다.

 

루틴이 상태를 가진다?

 

루틴이 상태를 가진다는 것은

상태 state == 메모리memory를 뜻한다.

함수 프레임에 루틴의 상태를 저장한 메모리 개체가 쌓인다는 뜻이다.

그렇다면 이렇게 쌓인 메모리 개체는

호출 스택(함수 프레임을 관리하는 방법 중 하나)을 통해 push(호출)되고 pop(반환)된다.

이러한 구조는 서브루틴에 매우 적합하다.

(C 언어의 모든 함수는 서브루틴이다.)

 

C++ Coroutine은 어떻게 정의되는가?

 

  • co_await
    컴파일러가 보는 코드를 다룬다.
    현재 프레임을 저장하고 프레임을 전달받는 함수를 다룰 때 주로 사용한다.
    coroutine_handle<P>를 같이 사용한다.
    co_await 연산자는 아래 함수들을 필요한다.
    ◦ await_ready 
    ◦ await_suspend 
    ◦ await_resume
    co_await을 사용하여
    컴파일러는 해당라인(line)에 중단점(Suspend Point)을 생성한다.
    프로그래머는 조건에 맞게 코루틴의 제어흐름을 중단할수있다.
  • co_yield
  • co_return
    co_return의 인자가 없다면 return_void함수를 사용한다.
    그게 아니라면 Promise 타입을 정의해야 한다.
  • for co_await

 

 

 

코루틴 작성

 

C++/WinRT는 C++ 코루틴을 프로그래밍 모델에 통합하여 결과를 협조적으로 기다릴 수 있는

자연스러운 방법을 제공한다.

코루틴을 작성하여 고유한 Windows 런타임 비동기 작업을 생성할 수 있다.

 

코루틴은 앞서 말했듯이 일시 중단했다가 재개할 수 있는 함수이다.

일사 중단되고 컨트롤을 호출자에 의해 다시 시작된다.

 

코루틴을 다른 코틴에 집계할 수 있으며,

또는 get을 호출하여 차단하고 완료될 때까지 기다린 다음,

결과가 있을 경우 가져올 수 있다.

또는 Windows 런타임을 지원하는 다른 프로그래밍 언어에 전달할 수 있다.

 

델리게이트를 사용하여 비동기 작업의 완료 및 또는 

진행률 이벤트를 처리할 수 있다.

자세한 내용은 추후 비동기 작업을 위한 대리자 형식에서 다루겠다.

 

마이크로 소프트 예제 코드에서는

main을 종료하기 직전에 차단 get 함수 호출을 계속 사용한다.

그러나 이는 출력 인쇄를 마치기 전에 애플리케이션이 종료되지 않도록 하기 위한 것이다.

 

 

Windows 런타임 형식을 비동기식으로 반환

 

Windows 런타임 형식을 비동기 방식으로 반환하는 경우

IAsyncOperation<TResult> 또는

IAsyncOperationWithProgress<TResult,TProgerss>를 반환해야 한다.

 

마소 또는 타사 런타임 클래스는 Windows 런타임 함수로 전달하거나

전달 받을 수 있는 형식(int 또는 winrt::hstring)을 한정한다.

Windows 런타임이 아닌 형식에 이러한 비동기 작업 유형 중 하나를 사용하려고하면

컴파일러가 "T는 WinRT형식이어야한다." 라는 오류를 해결하는데 도움된다.

 

코루틴에 co_await문이 없는 경우,

코루틴이 되려면 co_return 또는 co_yield 문이 하나 이상 있어야 한다.

코루틴이 비동기성을 도입하지 않아 컨텍스트를 차단하거나

전환하지 않고 값을 반환할 수 있는 경우도 있다.

다음은 값을 캐시하여 두 번째 이상 호출 시 해당 작업을 수행하는 예제이다.

 

winrt::hstring m_cache;

IAsyncOperation<winrt::hstring> ReadAsync()
{
    if (m_cache.empty())
    {
        // Asynchronously download and cache the string.
    }
    co_return m_cache;
}

 

 

Windows 런타임이 아닌 형식을 비동기식으로 변환

 

Windows 런타임 형식이 '아닌' 형식을 비동기 방식으로 변환하는 경우 

PPL(병렬 패턴 라이브러리) concurrency::task를 반환해야 한다.

std::future보다 성능이 뛰어나고 향후 호환성도 우수한 concurrency::task를 사용하는 것이 좋다.

 

<pplawait.h> 를 포함하면,
concurrency::task를 코루틴 형슥으로 사용할 수 있다.

 

 

아래는 예제코드이다.

// main.cpp
#include <iostream>
#include <ppltasks.h>
#include <winrt/Windows.Foundation.Collections.h>
#include <winrt/Windows.Web.Syndication.h>
//출저 마소 공식문서


using namespace winrt;
using namespace Windows::Foundation;
using namespace Windows::Web::Syndication;

concurrency::task<std::wstring> RetrieveFirstTitleAsync()
{
    return concurrency::create_task([]
        {
            Uri rssFeedUri{ L"https://blogs.windows.com/feed" };
            SyndicationClient syndicationClient;
            SyndicationFeed syndicationFeed{ syndicationClient.RetrieveFeedAsync(rssFeedUri).get() };
            return std::wstring{ syndicationFeed.Items().GetAt(0).Title().Text() };
        });
}

int main()
{
    winrt::init_apartment();

    auto firstTitleOp{ RetrieveFirstTitleAsync() };
    // Do other work here.
    std::wcout << firstTitleOp.get() << std::endl;
}

 

 

매개 변수 전달

 

 

동기 함수의 경우 기본적으로 const& 매개 변수를 사용해야 한다.

그러면 참조 계산을 포함하며

연동된 증가 및 감소를 의미하는 복사본 오버헤드를 방지할 수 있다.

 

// Synchronous function.
void DoWork(Param const& value);

 

하지만 코루틴에 참조 매개 변수를 전달하는 경우 문제가 발생할 수 있다.

 

// NOT the recommended way to pass a value to a coroutine!
IASyncAction DoWorkAsync(Param const& value)
{
    // While it's ok to access value here...

    co_await DoOtherWorkAsync(); // (this is the first suspension point)...

    // ...accessing value here carries no guarantees of safety.
}

 

 

코루틴에서 실행은 첫 번째 일시 중단 지점까지 동기화된다.

이 경우 컨트롤이 호출자에 반환되고 프레임이 범위를 벗어난다.

코루틴이 다시 시작될때까지 참조 매개 변수가 참조하는 소스 값이 변경되었을 수 있다.

코루틴의 관점에서 참조 매개 변수의 수명은 제어되지 않는다는 것이다.

따라서 위 예제에서 co_await까지는 '값'에 엑세스해도 안전하지만

이후에는 안전하지 않다.

 

호출자에 의해 값이 소멸되는 이벤트에서 그 이후 코루틴 내에

해당 값에 액세스하려고 하면 메모리가 손상된다.

함수가 일시 중단되었다가 다시 시작된 후 '값'을 사용하려고 시도할 위험이 있는 경우

DoOtherWorkAsync에 '값'을 안전하게 전달할 수도 없다.

 

일시 중단했다가 다시 시작한 후 매개 변수를 안전하게 사용하려면

코루틴이 기본적으로 값으로 전달을 사용하여 값으로 캡처함으로써

수명 문제를 방지해야 한다.

이 지침을 따르지 않아도 안전하다고 확신할 경우는 흔치 않다.

 

// Coroutine
IASyncAction DoWorkAsync(Param value); // not const&

 

 

값으로 전달하기 위해서는 저비용으로 인수를 이동 또는 복사할 수 있어야 하며,

이는 일반적으로 스마트 포인터에서 흔한 경우이다.

 

값을 이동하려는 경우가 아니면,

const 값으로 전달하는 것이 좋다는 주장도 가능하다.

복사본을 만드는 소스 값에는 영향을 미치지 않지만

의도를 보다 명확하게 하며, 실수로 복사본을 수정하는 경우 도움이 된다.

 

// coroutine with strictly unnecessary const (but arguably good practice).
IASyncAction DoWorkAsync(Param const value);

 

 

표준 벡터를 비동기 호출 수신자에 전달하는 방법은

마이크로 소프트의 표준 배열 및 벡터를 참고하길 바란다.

 

코루틴의 서명은 변경할 수 없지만 구현은 변경할 수 있는 경우에는

첫 번쨰 co_await 전에 로컬 복사본을 만들 수 있다.

 

IASyncAction DoWorkAsync(Param const& value)
{
    auto safe_value = value;
    // It's ok to access both safe_value and value here.

    co_await DoOtherWorkAsync();

    // It's ok to access only safe_value here (not value).
}

 

 

Param 복사에 비용이 많이 들면 첫 번째 co_await 전에 필요한 구성 요소를 추출한다.

 

IASyncAction DoWorkAsync(Param const& value)
{
    auto safe_data = value.data;
    // It's ok to access safe_data, value.data, and value here.

    co_await DoOtherWorkAsync();

    // It's ok to access only safe_data here (not value.data, nor value).
}

 

 

사실 동기 비동기를 제대로 이해하려면 

논리설계와 전자전기회로에 대해서 알아야한다.

즉 컴퓨터 구조에 대해서 제대로 알아야 

컴퓨터 위에서 돌아가는 C++을 제대로 이해할 수 있는 것이다.