Tasks System

Tasks System の概要です。

Tasks System は、ゲームプレイのコードを非同期で実行する機能を提供するジョブ マネージャーです。依存タスクの有向非巡回グラフ (DAG) のビルドおよび実行をサポートしています。Unreal Engine で使用されていた TaskGraph を改善したものです。Tasks System と TaskGraph は、どちらも同じバックエンド (スケジューラとワーカー スレッド) を使用します。

主な機能は、以下のとおりです。

  • 非同期で実行する必要がある呼び出し可能なオブジェクトを提供することで、タスクを 起動 する。

  • タスクの完了やタスクの実行結果の取得を 待機する

  • タスクの 前提条件 (タスクの実行が開始される前に完了する必要がある他のタスク) を指定する

  • タスク内部から ネストされたタスク を起動する。親タスクは、ネストされたすべてのタスクが完了するまで完了しません。

  • パイプ とも呼ばれるタスク チェーンを構築する。

  • タスク間の同期およびシグナリングに タスク イベント を使用する。

すべてのコード サンプルでは、簡潔になるよう名前空間 UE::Tasks を使用することを想定しています。

起動する

タスクを 起動する には、タスクのデバッグ名と呼び出し可能なオブジェクトである「タスクのボディ」を入力する必要があります。次に例を示します。

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

上記のコードは、与えられた関数を非同期で実行するタスクを起動します。最初のパラメータは、タスクのデバッグ名 (一意であることが望ましい) です。このデバッグ名の目的は、タスクのデバッグを容易にし、タスクを起動したコードを見つけやすくすることです。

UE_SOURCE_LOCATION は、マクロであり、ソース ファイルのフォーマット ファイル名の文字列と使用される行を生成します。この例では、「ファイア アンド フォーゲット (起動して放置する)」タスクを示しています。これはつまり、タスクを起動すれば最終的に実行されるため、起動後にタスクに何が起きても気にする必要はない、というものです。

タスクの完了や実行結果の取得を待機しなければならない状況はよく発生します。これは、Launch 呼び出しによって返される Task オブジェクトを使用することで実行できます。

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

タスク実行は結果を返すことができます。FTaskTTask<void> のエイリアスであり、一般的な TTask<ResultType> を特殊化したものです。ResultType は、タスク本体から返される結果の型と一致する必要があります。

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

タスクは非同期で実行されますが、起動したスレッドを使用して同時実行される可能性もあるため、実行順序は定義されません。しかし、タスクの優先度を指定すると、タスクの実行順序に影響を与えることができます。タスクの優先度には、「high」、「normal」 (デフォルト)、「background high」、「background normal」、および「background low」があります。優先度の高いタスクは、優先度の低いタスクよりも先に実行されます。

    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

タスクの解放が、すぐにタスクの破棄につながるわけではありません。システムでは、タスクの実行に使用されたその参照が保持されます。この参照は、タスクの完了後に解放されます。

追加情報については、起動する を参照してください。

タスクの完了を待機する

タスクの完了状況の把握、完了の待機、実行結果の取得は、高い頻度で必要とされるものです。

タスク コマンド

実装方法

タスクが完了したかどうかをチェックする

Example:

bool bCompleted = Task.IsCompleted();

タスクの完了を待機する

Example:

Task.Wait();

タイムアウトを設定し、タスクの完了を待機する

Example:

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 を設計することで、タスク グラフを構築することをお勧めします。詳細については、 Wait`](programming-and-scripting/unreal-architecture/tasks-system/tasks-system-reference#GetResult) を参照してください。

Busy-waiting

タスク完了を待機することで発生する問題は、現在のスレッドがブロックされることです。そのため、不便になります。別のアプローチとしては、Busy-waiting を使用する方法があります。Busy-waiting を使用すると、スレッドは、待機中のタスクが完了するまで他のタスクの実行を試みます。

Busy-waiting は制御された環境においては役立つ一方で、固有の問題がいくつかあり、その使用には注意が必要です。主な問題として、Busy-waiting の期間中は、スケジューラによって選択され、実行されるタスクを制御できないということがあります。

これは、デッドロック (スレッドがビジー状態で待機していて、再入可能でないミューテックスをロックすると同時に、スケジューラによって選択されたタスクが同じミューテックスのロックを試みた場合) につながる可能性があります。または、クリティカル パスの短いタスクで Busy-waiting となった間に、スケジューラによって実行時間が長いタスクが選択され、パフォーマンスが低下する可能性があります。

追加情報については、BusyWait() を参照してください。

前提条件

タスクには、他のタスクに対する依存関係が存在する場合があります。タスク B の完了後にのみタスク A を実行できる場合、タスク B はタスク A の 前提条件 、タスク A はタスク B の 後続 と呼ばれます。これにより、タスクの有向非巡回グラフ (DAG) を構築できます。

タスクの依存関係を利用する主な利点は、ワーカー スレッドがブロックされないことです。さらに、依存関係を利用すると、通常は保証されることがない、タスクの実行順序を強制することができます。単純な Prerequisite と Subsequent の依存関係を構築するコードを、以下に示します。

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

以下のコード例において、 [`Prerequisites()`]() はヘルパー関数です。

task-diagram-flow-example

    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));

追加情報については、起動する を参照してください。

ネストされたタスク

ネストされたタスク は前提条件に似ていますが、前提条件が実行に関する依存関係であるのに対し、ネストされたタスクは、完了に関する依存関係です。タスク A が実行中にタスク B を起動し、タスク A の実行終了、およびタスク B の完了後にのみタスク A を完了できる場合について考えてみましょう。これは、システムでタスクベースの非同期インターフェースが公開される場合の一般的なパターンです。しかし、タスク B は実装の一部であるため、このタスクをリークしてしまうのは望ましくありません。

最も単純な実装は、次のようになります。

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

これはタスクを実行する基本的な実装ですが、タスク A を実行しているワーカー スレッドはタスク B の完了を待機してブロックされるため、他のタスクの実行に使用されることがなく非効率的です。

この解決法となるのが、ネストされたタスクの利用です。この例では、タスク B の実行はタスク A の実行の内部にネストされているため、タスク A が親タスクで、タスク B はネストされたタスクです。

    FTask TaskA = Launch(UE_SOURCE_LOCATION, 
    [] 
       { 
            FTask TaskB = Launch(UE_SOURCE_LOCATION, [] {}); 
            AddNested(TaskB);
       }
    );
    TaskA.Wait(); // returns only when both `TaskA` and `TaskB` are completed

AddNested は、与えられたタスクを現在のスレッドで実行中のタスクにネストして追加します。タスク内部から呼び出されない場合、アサーションを行います。

詳細については、AddNested() を参照してください。

パイプ

パイプ は、連続して実行される (同時実行ではない) タスクのチェーンです。複数のスレッドからアクセスされる共有リソースについて考えてみましょう。アクセスを同期する古典的なアプローチは、ミューテックスをロックすることでリソースを「ロック」する方法です。この方法ではスレッドがブロックされるため、特にリソースの競合の発生時など、多くの場合で大幅なパフォーマンス低下をもたらします。

複雑なリソースに対しては、リソースを処理するための非同期操作を開始できる非同期インターフェースと、操作が完了したか (または完了通知をサブスクライブしているか) をチェックする機能を用意することが望ましいでしょう。

非同期インターフェースの実装は、多くの場合、簡単なタスクではありません。パイプは、これを合理化するために設計されました。その目的は、共有リソースごとにパイプを設定することです。共有リソースへのすべてのアクセスは、パイプによって起動されるタスクの内部で実行されます。次に例を示します。次に例を示します。

    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;
    //access the same instance concurrently from multiple threads
    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 はスレッドセーフです。

  • Pipe オブジェクトはコピーも移動もできません。

  • 複数のパイプでタスクを起動することはできません。

追加情報については、 「FPipe」 を参照してください。

タスク イベント

タスク イベントは特殊なタスクのタイプであり、タスク ボディがなく実行することができません。大きな相違点は、タスク イベントは最初に起動 (シグナル) されるものではなく、明示的にトリガーする必要がある、ということです。タスク イベントは、同期およびシグナルを実行するプリミティブとして役立ちます。タスク イベントは、1 回限りの 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 は、タスクのトレース チャンネルを追加し、タスクおよびその存続期間のイベントのビジュアライゼーションを可能にします (タスクの起動時、スケジュール時、実行時、完了時など)。

詳細については、「Unreal Insights のドキュメント」を参照してください。

デバッグとプロファイリングは、開発が積極的に行われている領域であり、今後さらに改善が行われる予定です。

タグ
Unreal Engine のドキュメントを改善するために協力をお願いします!どのような改善を望んでいるかご意見をお聞かせください。
調査に参加する
キャンセル