태스크 시스템

태스크 시스템의 개요입니다.

Choose your operating system:

Windows

macOS

Linux

태스크 시스템(Tasks System) 은 게임플레이 코드를 비동기 실행할 수 있는 기능을 제공하는 작업 매니저입니다. 종속 작업에 대해 방향성 비사이클 그래프(directed acyclic graph)를 만들고 실행할 수 있습니다. 언리얼 엔진(UE) 의 작업 매니저인 태스크 그래프(TaskGraph) 를 향상합니다. 참고로 태스크 시스템과 태스크 그래프는 동일한 백엔드(스케줄러 및 워커 스레드)를 사용합니다.

주요 기능은 다음과 같습니다.

  • 비동기로 실행해야 하는 호출 가능 오브젝트를 제공하여 태스크를 실행합니다.

  • 태스크 완료 및 태스크 실행 결과 정보를 획득하는 동안 대기합니다.

  • 태스크 실행을 시작하기 전에 완료해야 하는 태스크인 선행 태스크를 지정합니다.

  • 태스크 내부에서 중첩된 태스크를 실행합니다. 부모 태스크는 중첩된 태스크가 모두 완료될 때까지 계속 실행됩니다.

  • 파이프로 알려진 태스크 체인을 빌드합니다.

  • 태스크 간 동기화 및 시그널링에 필요한 태스크 이벤트를 사용합니다.

모든 코드 샘플은 간소화를 위해 UE::Tasks 네임스페이스를 사용한다고 가정합니다.

실행

태스크를 실행(Launch) 하려면 태스크의 디버그 이름과 호출 가능한 ‘태스크 바디' 오브젝트를 제공해야 합니다. 예를 들면 다음과 같습니다.

    Launch(
            UE_SOURCE_LOCATION, 
            []{ UE_LOG(LogTemp, Log, TEXT("Hello Tasks!")); }
          );

이 코드는 주어진 함수를 비동기 실행하는 태스크를 실행합니다. 첫 번째 파라미터는 태스크의 디버그 이름입니다. 되도록이면 고유한 이름을 사용합니다. 태스크의 디버그 및 태스크를 실행한 코드 탐색을 지원하는 용도입니다.

UE_SOURCE_LOCATION 은 소스 파일의 포맷 파일 이름과 파일이 사용되는 행에 스트링을 생성하는 매크로입니다. 해당 예시에서는 ‘파이어 앤 포겟' 태스크를 보여줍니다. 태스크가 실행됐으니 이후에는 어떻게 돼도 신경 쓸 필요가 없다는 뜻입니다.

가끔 태스크 완료를 기다리거나 실행 결과를 받아야 할 때도 있습니다. Launch 호출로 반환된 태스크 오브젝트를 사용하는 식으로 다음 명령을 수행할 수 있습니다.

    FTask Task = Launch(UE_SOURCE_LOCATION, []{});

태스크 실행은 결과를 반환할 수 있습니다. FTaskTTask<void> 의 동의어로 일반 TTask<ResultType> 에서 분화된 것입니다. ResultType 은 태스크 바디에서 반환한 결과의 타입과 일치해야 합니다.

    TTask<bool> Task = Launch(UE_SOURCE_LOCATION, []{ return true; });

태스크는 비동기로 실행되며, 실행 스레드와 동시실행될 수도 있으므로 실행 순서는 따로 정의하지 않습니다. 하지만 태스크 우선순위를 지정해 태스크의 실행 순서에 영향을 미칠 수 있습니다. 태스크 우선순위는 ‘높음', ‘보통'(디폴트), ‘백그라운드 높음', ‘백그라운드 보통', ‘백그라운드 낮음'입니다. 우선순위가 높은 태스크가 낮은 태스크보다 먼저 실행됩니다.

    Launch(UE_SOURCE_LOCATION, []{}, ETaskPriority::High);

람다 함수는 보통 태스크 바디로 사용되지만, 호출 가능한 오브젝트도 사용될 수 있습니다.

    void Func() {}
    Launch(UE_SOURCE_LOCATION, &Func);

    struct FFunctor
    {
        void operator()() {}
    };
    Launch(UE_SOURCE_LOCATION, FFunctor{});

기술적 디테일

FTask 는 스마트 포인터처럼 실제 태스크의 핸들 역할을 합니다. 레퍼런스 카운트로 태스크의 수명을 관리합니다. 태스크 실행 시 수명과 필수 리소스를 할당합니다. 설정된 레퍼런스를 해제하려면 다음 코드를 사용해 태스크 핸들을 ‘초기화'해야 합니다.

    FTask Task = Launch(UE_SOURCE_LOCATION, []{});
    Task = {}; // release the reference

태스크 핸들을 해제해도 바로 태스크 소멸로 이어지지는 않습니다. 시스템에는 태스크 실행에 필요한 자체 레퍼런스가 있습니다. 이 레퍼런스는 태스크 완료 후 해제됩니다.

추가 정보는 실행을 참조하세요.

태스크 완료 기다리기

완료나 실행 결과를 기다리기 위해 태스크 완료 시점을 알아야 할 때가 있습니다.

태스크 명령

구현 메서드

태스크 완료 여부 확인

예시:

bool bCompleted = Task.IsCompleted();

태스크 완료 기다리기

예시:

Task.Wait();

타임아웃 있는 태스크 완료 기다리기

예시:

bool bTaskCompleted = Task.Wait(FTimespan::FromMillisecond(100));

모든 태스크 완료 기다리기

예시:

TArray<FTask> Tasks = …; 
Wait(Tasks);

태스크 실행 결과 획득하기 (호출은 태스크 완료 및 결과가 준비될 때까지 블록됨)

예시:

TTask<int> Task = Launch
(UE_SOURCE_LOCATION, []{ return 42; });
int Result = Task.GetResult();

대기는 확장성을 제한하므로 되도록 피해야 합니다. 대신 태스크 간의 종속성을 정의하고 태스크 기반 비동기 API를 설계하여 태스크 그래프를 생성하는 것을 추천합니다. 자세한 내용은 대기 GetResult() 를 참조하세요.

바쁜 대기

태스크 완료를 기다리면 현재 스레드를 차단하기 때문에 유용하지 않습니다. 대안은 바쁜 대기(Busy-waiting) 를 사용하는 것입니다. 바쁜 대기를 사용할 경우, 스레드에서는 대기 중인 태스크가 완료될 때까지 다른 작업을 실행하려고 시도합니다.

바쁜 대기는 통제된 환경에서 사용하면 유용할 수 있지만, 자체적인 제한사항이 있으니 신중히 사용해야 합니다. 바쁜 대기의 가장 큰 제한사항은 바쁜 대기 도중에는 실행중인 스케줄러가 태스크를 선택해 제어할 수 없다는 점입니다.

이러한 제한사항 때문에 스레드가 바쁜 대기 상태에 진입해 재진입 가능성이 없는 뮤텍스를 잠가버리는 동안 스케줄러가 테스크를 선택해 동일한 뮤텍스를 잠그려고 시도하는 교착 현상이 발생하거나, 필수 경로에서 상대적으로 짧은 태스크 때문에 바쁜 대기를 수행하는 동안 스케줄러가 더 오래 걸리는 태스크를 선택하여 형편없는 퍼포먼스 결과로 이어질 수 있습니다.

추가 정보는 BusyWait() 을 참조하세요.

전제조건

태스크는 다른 태스크에 종속성을 가질 수 있습니다. 태스크 A를 태스크 B가 완료되었을 때만 실행할 수 있게 설정한 경우, 태스크 B를 태스크 A의 선행 태스크(Prerequisite), 태스크 A를 태스크 B의 후속 태스크(Subsequent) 라고 합니다. 전제조건을 설정하면 여러 태스크를 대상으로 방향성 비사이클 그래프를 만들 수 있습니다.

태스크 종속성에는 워커 스레드를 블록하지 않는다는 큰 이점이 있습니다. 종속성을 사용하면 평소와 달리 태스크 실행 순서를 항상 변경할 수도 있습니다. 아래 코드는 선행-후속 태스크와 관련된 간단한 종속성을 빌드합니다.

    FTask Prerequisite = Launch(UE_SOURCE_LOCATION, []{});
    FTask Subsequent = Launch(UE_SOURCE_LOCATION, []{}, Prerequisite);

아래 코드 예시에서 Prerequisites() 는 헬퍼 함수입니다.

태스크 다이어그램 흐름 예시

    FTask A = Launch(UE_SOURCE_LOCATION, []{});
    FTask B = Launch(UE_SOURCE_LOCATION, []{}, A);
    FTask C = Launch(UE_SOURCE_LOCATION, []{}, A);
    FTask D = Launch(UE_SOURCE_LOCATION, []{}, Prerequisites(B, C));

추가 정보는 실행을 참조하세요.

중첩된 태스크

중첩된 태스크(Nested tasks) 는 선행 태스크와 비슷하지만, 선행 태스크가 실행 종속성인 데 반해 중첩된 태스크는 완료 종속성의 성격을 띱니다. 태스크 A가 실행 도중 태스크 B를 실행한다고 가정할 경우, 태스크 A는 자체 실행을 마치고 태스크 B가 완료되어야 완료될 수 있습니다. 이는 시스템에서 태스크 기반 비동기 인터페이스를 노출할 때 흔히 볼 수 있는 패턴입니다. 하지만 태스크 B는 구현의 일부이므로 이 태스크를 누출하는 일은 바람직하지 않습니다.

가장 간단한 형태의 구현은 다음과 같습니다.

    FTask TaskA = Launch(UE_SOURCE_LOCATION, 
    [] 
    { 
        FTask TaskB = Launch(UE_SOURCE_LOCATION, [] {}); 
        TaskB.Wait();
    }
    );

이는 태스크를 수행하는 기본적인 형태의 구현이지만, 태스크 A를 실행하는 워커 스레드가 태스크 B의 완료를 기다리는 동안 차단되기 때문에 비효율적입니다. 따라서 다른 태스크를 실행할 때는 사용되지 않습니다.

해결책은 중첩된 태스크를 사용하는 것입니다. 다음 예시에서 태스크 A는 부모 태스크입니다. 태스크 B는 태스크 A의 실행 내부에 중첩된 채 실행되어야 하므로 중첩된 태스크로 설정됩니다.

    FTask TaskA = Launch(UE_SOURCE_LOCATION, 
    [] 
       { 
            FTask TaskB = Launch(UE_SOURCE_LOCATION, [] {}); 
            AddNested(TaskB);
       }
    );
    TaskA.Wait(); // `TaskA` 와 `TaskB` 가 전부 완료되어야 반환

AddNested는 주어진 태스크를 현재 스레드에 의해 실행되는 태스크에 중첩됨으로 추가합니다. 태스크 내부에서 호출하지 않은 경우 어서트를 호출합니다.

추가 정보는 AddNested() 를 참조하세요.

파이프

파이프(Pipe) 는 동시실행이 아닌, 연속적으로 실행되는 태스크로 이루어진 체인입니다. 공유된 리소스는 여러 스레드에서 액세스할 수 있습니다. 기존에는 액세스를 동기화하기 위해 뮤텍스를 잠가 리소스를 ‘잠금' 으로 설정하는 방법을 사용했습니다. 하지만 이 방법을 사용하면 스레드가 차단되어 퍼포먼스가 대폭 저하되는 경우가 있었으며, 특히 리소스가 충돌할 때 퍼포먼스 저하가 심해집니다.

복잡한 리소스를 사용할 경우, 리소스에 비동기 작업을 시작할 수 있는 비동기 인터페이스와 작업의 완료 여부를 확인할 수 있는 기능, 또는 완료 알림에 구독하는 기능을 제공하는 편이 바람직합니다.

비동기 인터페이스는 구현하기 어려울 수 있습니다. 파이프는 이 구현을 간소화하기 위해 만들어졌습니다. 공유된 리소스마다 파이프를 두고자 합니다. 공유된 리소스에 대한 모든 액세스는 파이프에서 실행한 태스크 내부에서 수행합니다. 예를 들면 다음과 같습니다.

    class FThreadSafeResource
    {
    public:
        TTask<bool> Access()
        {
            return Pipe.Launch(TEXT("Access()"), [this] { return ThreadUnsafeResource.Access(); });
        }

        FTask Mutate()
        {
            return Pipe.Launch(TEXT("Mutate()"), [this] { ThreadUnsafeResource.Mutate(); });
        }
    private:
        FPipe Pipe{ TEXT("FThreadSafeResource pipe")};
        FThreadUnsafeResource ThreadUnsafeResource;
    };

    FThreadSafeResource ThreadSafeResource;
    //여러 스레드에서 동일한 인스턴스에 동시 액세스함
    bool bRes = ThreadSafeResource.Access().GetResult();
    FTask Task = ThreadSafeResource.Mutate();

FThreadSafeResource 로 태스크에 따라 퍼블릭 스레드 세이프 비동기 인터페이스를 추가할 수 있습니다. 이 인터페이스는 스레드 언세이프 리소스를 캡슐화합니다. 구현 자체는 보일러플레이트 코드로 간단하게 할 수 있습니다. 스레드 언세이프 리소스에 대한 모든 액세스는 파이프화된 태스크 내에서 이뤄집니다.

파이프화된 태스크는 순차적으로 실행되므로, 추가 동기화는 필요 없습니다. 파이프는 가벼운 오브젝트이므로 태스크의 컬렉션을 저장하지 않습니다. 심각한 퍼포먼스 저하 없이 수천 개의 파이프를 사용할 수도 있습니다.

태스크를 파이프화하려면 파이프로 실행해야 합니다.

    FPipe Pipe{ UE_SOURCE_LOCATION };
    FTask TaskA = Pipe.Launch(UE_SOURCE_LOCATION, []{});
    FTask TaskB = Pipe.Launch(UE_SOURCE_LOCATION, []{});

TaskA와 TaskB는 동시에 실행되지 않으므로, 공유된 리소스에 액세스하기 위해 서로 동기화할 필요는 없습니다. 실행 순서를 예측할 수 있을 때가 대부분이지만, 태스크의 실행 순서가 예상과 다를 수 있습니다.

파이프화된 태스크는 다른 태스크와 동일한 기능을 지원합니다. 예를 들어 종속성을 가지거나 비헤이비어 순서를 따를 수 있습니다. 종속성부터 해결한 다음 태스크를 파이프화합니다. 다시 말해 보류 중인 종속성이 있는 태스크는 파이프 실행을 차단하지 않으며, 종속성은 파이프화된 태스크의 실행 순서를 변경할 수 있습니다.

파이프는 녹색 스레드 로 간주해도 됩니다. 이 녹색 스레드는 워커 스레드에서 실행하며 ‘스레드를 건너뛸' 수 있습니다. 예를 들어, 이전 예시의 TaskA와 TaskB는 서로 다른 스레드에서 실행할 수도 있습니다.

  • 파이프 API는 스레드 세이프입니다.

  • 파이프 오브젝트는 복사 및 이동할 수 없습니다.

  • 한 태스크를 여러 파이프에서 실행할 수 없습니다.

추가 정보는 FPipe를 참조하세요.

태스크 이벤트

태스크 이벤트는 태스크 바디가 없고 실행할 수 없는 특별한 태스크 타입입니다. 이 태스크는 초기에 실행(시그널링)되지 않으며 명시적으로 트리거해야 한다는 점이 다른 테스크와의 중요한 차이입니다. 태스크 이벤트는 동기화 및 시그널링 프리미티브로서 유용합니다. 일회성 FEvent와 유사합니다. 태스크 이벤트는 다른 태스크의 선행 및 후속 태스크로 사용할 수 있습니다.

다음 표는 태스크 이벤트로 할 수 있는 작업을 보여줍니다.

태스크 이벤트 예시

구현 메서드

태스크를 실행하되 명시적으로 해제할 때까지 실행을 보류합니다.

예시:

FTaskEvent Event{ UE_SOURCE_LOCATION }; 
FTask Task = Launch(UE_SOURCE_LOCATION, []{}, Event); 
Event.Trigger();

이벤트는 태스크의 전제조건으로 사용합니다. 처음에는 이벤트가 시그널링 상태가 아니므로 완료되지 않았습니다. 태스크에 보류 중인 종속성이 있으므로, 해결되기 전까지 예약 및 실행되지 않는다는 뜻입니다. 트리거한 태스크 이벤트는 시그널링 상태로 전환됩니다.

태스크 이벤트를 조이너 태스크로 활용한다.

예시:

FTask TaskA = Launch(UE_SOURCE_LOCATION, []{});
FTask TaskB = Launch(UE_SOURCE_LOCATION, []{});
FTaskEvent Joiner{ UE_SOURCE_LOCATION };
Joiner.AddPrerequisites(Prerequisites(TaskA, TaskB));
Joiner.Trigger();
...
Joiner.Wait();

조이너는 TaskA와 TaskB에 달려있습니다. 조이너를 기다릴 때는 각각 기다리는 것이 아니라 모든 종속성을 기다립니다.

Prerequisites() 는 헬퍼 함수입니다.

태스크 실행을 도중에 멈추고 이벤트 발생을 기다립니다.

예시:

FTaskEvent Event{ UE_SOURCE_LOCATION };
FTask Task = Launch(UE_SOURCE_LOCATION, 
    [&Event]
    {
        ...
        Event.Wait();
        ...
    });
 ...
 Event.Trigger();

일반적으로 태스크 중일 때 기다리는 것은 퍼포먼스와 확장성 면에서 좋은 생각은 아닙니다. 이런 상황에서는 가능하면 선행 태스크를 다시 제작하는 방법도 고려해 보세요.

태스크를 실행하되 자동 완료 플래그를 설정하지 않습니다. 대신 편할 때 명시적으로 ‘완료' 처리합니다.

예시:

FTaskEvent Event{ UE_SOURCE_LOCATION };
FTask Task = Launch(UE_SOURCE_LOCATION, 
    [&Event]
    {
    AddNested(Event);
    });
...
Event.Trigger();

또한 FTaskEvent를 참조하세요.

디버깅 및 프로파일링

모든 태스크, 태스크 이벤트 또는 파이프에는 사용자가 만든 디버그 이름이 있습니다. 이를 통해 디버거에서 런타임 중에 구별할 수 있습니다. 내부 상태를 검사하기 위해 Visual Studio 네이티브 시각화 툴이 제공됩니다.

언리얼 인사이트(Unreal Insights) 는 태스크 및 태스크 수명 이벤트를 실행, 예약, 수행, 완료할 때 이를 시각화할 태스크 트레이스 채널을 추가합니다.

자세한 내용은 언리얼 인사이트 문서를 참조하세요.

디버깅 및 프로파일링은 현재 개발 중이며 향후 추가 개선이 있을 예정입니다.

태그