UE5で新しく実装された非同期処理APIシステムにTask Systemと呼ばれるものが追加されています。これは日本のゲーム業界で呼ばれる一般的なタスクシステムと呼ばれるものとは全く違い、C++で簡単に汎用的な非同期処理を実現するためのジョブマネージャーです。
UE4から汎用的な非同期処理を実現するためにはTaskGraphというシステムが存在していましたが、TaskGraphはプログラミング初心者が利用するには非常にハードルが高い内容となっており、非同期処理の専門家でないと扱いが難しいものでした。Task Systemは複雑化していた非同期処理のコードを簡潔に記載できるようになるだけではなく、依存関係の処理や同期が必要な共有リソースへのアクセス、更にはPipeと呼ばれるタスクを連続して実行するタスクチェーンの仕組みが同時に用意されています。
非同期処理の扱いは難しいものがありますが、UE5で追加されたTaskSystemの導入で大幅にお手軽になっています。最も簡単な非同期処理は以下のコード量で書けてしまいます。あまりに簡単過ぎて拍子抜けするコード量です。
Launch(TEXT("Task"), []{});
公式サイトにもドキュメントにサンプルコードと解説が用意されているので、じっくり知りたい方はそちらを読んでみてください。
docs.unrealengine.com
docs.unrealengine.com
今回のコードはGitHub上にサンプルプロジェクトとして配布しておりますので、コードだけでも眺めてもらうと参考になると思いますので、よろしければどうぞ。
ではここからは早速UE5で追加されたTask Systemの中身についてを確認していきましょう。
Task Systemを使うには
ここからはコードを混じえながら説明をしていきます。Task Systemのコードは使うだけならとても簡単となっており、1行を追加するだけです。
#include "Tasks/Task.h" using namespace UE::Tasks;
Task.hのヘッダーをインクルードするだけですぐに利用ができます。またusing namespaceは必須ではありませんが、Task SystemはUneal Engineとしては珍しくUE::Tasksという名前空間上に実装されており、cppファイル内にusing namespaceを追加しておくことでここから先のコードをそのままコピーして使うことができるようになります。
タスクの起動
まずはTask Systemでタスクを起動させましょう。
// タスクをラムダ関数で起動 Launch(TEXT("Task Launch lambda"), []{} ); // タスクを関数ポインターで起動 void Func() {} Launch(TEXT("Task Launch Function Pointer"), &Func); // タスクを関数オブジェクト(ファンクター)で起動 struct FFunctor { void operator()() {} }; Launch(TEXT("Task Launch Functor"), FFunctor{});
タスクを起動するためには、Launch関数を呼び出すことで可能となります。第一引数にはタスクを認識しやすくするための文字列を入力し、第二引数には非同期で処理される本体であるタスクのボディ部分を入力します。第三引数以降も存在しますが、必須ではありません。
タスクのボディには、ラムダ関数、関数ポインター、関数オブジェクトの3つで指定が可能です。お勧めはその場でコードが記載できるラムダ関数ですが、使いまわしたい場合や状態を持たせたい場合には他の2つも有用な場面があると思います。
さて、ここまでが基本的なタスクの使い方となります。ここから応用的な使い方を説明していきます。
タスクのWait
まずはタスクを起動後に、タスクが完了するまで待機するWait処理についてです。
// タスク起動後、タスクが完了するまでスレッドをブロック FTask Task = Launch(TEXT("Task Wait"), &CalcFunc); Task.Wait();
ここではCalcFuncという関数を指定し、その関数を非同期に処理します。非同期処理が完了するまではWait関数がスレッドをブロックし、処理が完全に停止します。重い処理をWaitしてしまうと長時間スレッドが停止してしまうので、オブジェクトのリソース同期が必要なタイミングで利用しましょう。
タスクのBusyWait
次はタスクをBusyWaitする処理です。BusyWaitとは、基本的はWaitと同様にスレッドをブロックしてタスクを実行しますが、同様に実行されている他のタスクが存在している場合には、他のタスクを処理して効率よく処理することができます。しかしタスクの順番を正確に制御できなかったりするので注意が必要です。
// 先にタスクAを起動する FTask TaskA = Launch(TEXT("TaskA Busy Wait"), [] { FPlatformProcess::Sleep(1.0f); UE_LOG(LogTemp, Log, TEXT("TaskA End")); } ); // タスクBはタスクAより先に完了する FTask TaskB = Launch(TEXT("TaskB Busy Wait"), [] { FPlatformProcess::Sleep(0.5f); UE_LOG(LogTemp, Log, TEXT("TaskB End")); } ); // タスクAをBusyWaitすると、処理が完了するまで待機する // その間もタスクBは処理されるので、先にタスクBが完了し、先に進む TaskA.BusyWait();
上記コードではSleep処理を入れることで、タスクAはタスクBよりも処理に時間がかかりますが、タスクAをBusyWaitすることでその間もタスクBを同時に処理することができます。タスクAが完了することでブロックが解除されて、先へと進むことになります。
タスクの結果を受け取る
次はタスクの結果を受け取る方法についてです。タスクの結果はTTask型のテンプレート引数で受け取る型を決めることが出来ます。受け取った結果をGetResult関数で取得可能ですので、あとはその結果を自由に利用することが可能です。
// タスクでbool値を返す TTask<bool> BoolTask = Launch(TEXT("Task Return Bool"), [] { return true; } ); // GetRusultで中身を確認する bool bResult = BoolTask.GetResult(); UE_LOG(LogTemp, Log, TEXT("BoolTask Result = %s"), bResult ? TEXT("true") : TEXT("false"));
タスク同士で依存する前提条件
次はタスクに依存関係をもたせて前提条件(Prerequisites)を持たせて実行する方法です。タスクを実行する際に前提条件のタスクが完了しない限りタスクを実行しないということが可能です。これはコードをみる方が理解しやすいと思います。
// タスクAを起動 FTask TaskA = Launch(TEXT("Task Prereqs TaskA"), [] { FPlatformProcess::Sleep(1.0f); UE_LOG(LogTemp, Log, TEXT("TaskA End")); } ); // タスクBとタスクCはタスクAが完了するまでは起動しない FTask TaskB = Launch(TEXT("Task Prereqs TaskB"), [] { FPlatformProcess::Sleep(0.2f); UE_LOG(LogTemp, Log, TEXT("TaskB End")); }, TaskA ); FTask TaskC = Launch(TEXT("Task Prereqs TaskC"), [] { FPlatformProcess::Sleep(0.5f); UE_LOG(LogTemp, Log, TEXT("TaskC End")); }, TaskA ); // タスクDはタスクBとタスクCが完了するまでは起動しない FTask TaskD = Launch(TEXT("Task Prereqs TaskD"), [] { UE_LOG(LogTemp, Log, TEXT("TaskD End")); }, Prerequisites(TaskB, TaskC) ); TaskD.Wait(); UE_LOG(LogTemp, Log, TEXT("Task Prerequisites End"));
上記コードはコメントにある通り、タスクBとタスクCはタスクAが完了するまで起動せず、タスクDはタスクBとタスクCが完了するまで起動しないようになっています。最終的にタスクDをWaitすることで必要なタスクを完了するまで先に進まないというコードになっています。非同期タスクのスケジューリングを非常に簡潔に行えるようになっており、とても強力なツールということがわかります。
ネスト(入れ子)するタスク
次はタスク自体がネストしているタスクについてです。ネストするとタスクは本来正常に終了することができなくなりますが、AddNested関数を利用することで正常にネストが処理できるようになります。
// 親タスクAを起動後タスクA内でタスクBを起動し、2つのタスクが完了するまでブロックする FTask TaskA = Launch(TEXT("Task Nasted Outer"), [] { FTask TaskB = Launch(TEXT("Task Nasted Inner"), &CalcFunc); AddNested(TaskB); } ); TaskA.Wait(); UE_LOG(LogTemp, Log, TEXT("Task Nasted End"));
ネストするタスクというのは非常に扱いが難しいかもしれませんが、上手く使うことで自分で自分を呼び出すタスクというものを作ることも可能になりますので、ケースによっては非常に有効な処理となります。
タスクのパイプ
次はタスクのパイプ(Pipe)についてです。パイプというのはUnreal Engine独自の非同期処理システムです。わかりやすく言えば、タスクを数珠繋ぎにして順番に実行するチェーン処理を行うことができるものです。タスクは呼び出し順に実行されますが、確実に順番通りに実行される保証はないので、注意が必要です。
以下のコードでは1つのパイプでタスクAとタスクBを起動し、タスクAの処理が完了するまではタスクBが起動しないようになっています。
FPipe Pipe{ TEXT("Pipe") }; // PipeでタスクAを起動 FTask TaskA = Pipe.Launch(TEXT("Task Pipe TaskA"), [] { FPlatformProcess::Sleep(1.0f); UE_LOG(LogTemp, Log, TEXT("TaskA End")); } ); // PipeでタスクBを起動するが、タスクAが完了するまでは開始しない FTask TaskB = Pipe.Launch(TEXT("Task Pipe TaskB"), [] { UE_LOG(LogTemp, Log, TEXT("TaskB End")); } ); // タスクBが終わるまで待ち、AとBのタスクが順番に完了する TaskB.Wait(); UE_LOG(LogTemp, Log, TEXT("Task Pipe End"));
パイプはタスクを順番に処理できるため、パイプごとのタスクのリソースアクセスはスレッドセーフとなります。パイプ自体が軽量なため、気軽に共有リソースへ安全にアクセスするための仕組みとしても利用可能です。また他のタスクと同様に前提条件(Prerequisites)などの仕組みも併用可能なため、より正確な実行順制御も可能です。
タスクをイベントのトリガーで実行
最後はイベント(TaskEvent)と呼ばれる仕組みで実行します。イベントを使うことで、自由なタイミングでトリガーし、タスクを実行することができます。一旦タスクのスケジュールだけしておき、必要なタイミングでトリガーすることで処理させることができるようになります。
FTaskEvent Event{ TEXT("Event") }; // TaskEventをLaunchで引数の最後に渡す Launch(TEXT("Task Event"), [] { UE_LOG(LogTemp, Log, TEXT("TaskEvent Completed")); }, Event ); // イベントとして登録されているタスクをトリガーして実行する Event.Trigger();
イベントは前提条件(Prerequisites)やネストも利用可能で、まとめてタスクをスケジュールしておいて一気にトリガーするといったことが可能となります。うまくタスクを積んでおいて一気に処理させたい場合に便利でしょう。
まとめ
UE5で実装されたTask Systemは非常にマイナーな機能のように扱われていますが、C++においてここまでお手軽に非同期処理可能なAPIはなかなか見たことがありません。そして前提条件やネスト、パイプなどによりゲームで扱いやすいようにカスタマイズされているので非常に扱いやすいんじゃないかと思います。今回は紹介しておりませんが、タスクはプライオリティ(優先度)の設定も可能で、優先的に処理したいタスクをまとめて処理するといったことも可能ですので、色々試してみてください。
今回のコードは全てGitHubにアップロードしているプロジェクトに上げておりますので、興味がある方はぜひコードを実際に実行してみてください。