UniTask – Unityでasync/awaitを最高のパフォーマンスで実現するライブラリ


Cy#の河合です。今回、『UniTask』という新しいUnity用の非同期処理ライブラリを公開しました。

[GitHub – Cysharp/UniTask]

新規公開、ではありますが、実は新しいわけではなく、元々UniRxの機能として公開していたものを、分離したものとなります。併せてUniRxも更新していて、お互いに依存が一切ない独立したものになっています。

概要に関しては、以前に公開した以下のスライドで詳しく述べていますが、改めてまとめてみます。

async/awaitまでのC#/Unityの非同期処理

一般に、非同期処理はコールバックで完了後のメソッドを呼び出す形で実装できます。Unityも例外ではなく多用されていますが、

  • 複雑な処理でネストが多重になる
  • その際、内側の例外は外側には伝搬されない
  • 処理順序がコードから見えなくなる

といったような、いわゆるコールバック地獄に陥ります。代わりに、Unityではyield return(ジェネレーター)で実現しているコルーチンで非同期処理を行うことができます。このUnityにおけるコルーチンでは非同期処理がシーケンシャルに書けるという利点がある一方、

  • 起動におけるMonoBehaviourとの結合
  • 戻り値の伝搬
  • 例外の伝搬
  • 複数コルーチンのコントロール(直列/並列処理)

といった欠点があります。そこで互いを補うため、長くなる複雑な処理をコルーチンで書きつつ、値の伝搬をコールバックで行い、なるべくネストは深くならないようにする、といったような対応を取ってきたと思います。

UniRxは、そうした非同期処理の複雑さを緩和するために導入できます。ただし、

  • 機能が強力すぎるため、パズルのような複雑さを招いてしまいがち
  • IObervableだけではイベント(長さ∞)か非同期(長さ1)なのか判別できない
  • 結局、手続き的に書けないため、記述のわかりやすさでは劣る

といった副作用もあり、銀の弾丸とはなり得ません。

これら「コールバック・コルーチン・Rx」の欠点を解決するasync/awaitは、言語機能として「同期処理のように非同期処理を書ける」ように設計されていて、Rxとの使い分けとして以下のようなマトリクスが書けます。

image (6)

同期の単一戻り値の処理は、ただのメソッド呼び出しですが、非同期の単一戻り値は、それにawaitを書くだけです。そして同期の複数戻り値の処理をforeachでやるように、イベント処理をRxでやることで、自然な使い分けができるでしょう。

C# 8.0からはAsyncEnumerableというものが導入されて、非同期foreachが可能になります。Rxとの違いは、AsyncEnumerableがPull型の非同期シーケンス処理で、IObservableがPush型の非同期シーケンス処理ということになります。

async/awaitにより、

  • 手続き的に書けて、ネストは消える
  • 戻り値も例外処理も同期処理のように自然に扱える

まさに理想的な記述ができるようになりました。ただし、何の副作用もない処理は存在しないように、async/awaitの欠点として

  • 非同期型を呼び出すメソッドは全てTask(またはそれに準ずる型)になる

という、「非同期汚染」と呼ばれる状態にはなり得ます。

UniTaskとUnity

Unityは既に最新バージョンのC#をサポートし、async/awaitを十分使える状態にありますが、フレームワーク側での対応が一切なされていないため、そのままでは実用化はできません。UniTaskではコルーチンで行えたことを全て行えるようにすることを目標に、多数の拡張を実装しています。

// Unityの非同期オブジェクトをそのまま待てる
var asset = await Resources.LoadAsync<TextAsset>("foo");

// .ConfigureAwaitでプログレスのコールバックを仕込んだりも可能
await SceneManager.LoadSceneAsync("scene2").ConfigureAwait(Progress.Create<float>(x => Debug.Log(x)));

// 100フレーム待つなどフレームベースの待機
await UniTask.DelayFrame(100); 

// WaitForSeconds/WaitForSecondsRealtimeの置き換え
await UniTask.Delay(TimeSpan.FromSeconds(10), ignoreTimeScale: false);

// yield return WaitForEndOfFrameの置き換え、yield return null, yield return WaitForFixedUpdateの置き換えもYieldで可能
await UniTask.Yield(PlayerLoopTiming.PostLateUpdate);

// IEnumeratorなコルーチンも待てる
await FooCoroutineEnumerator();

// yield return WaitUntilのようなことも可能
await UniTask.WaitUntil( () => isActive == false);

// これより下の処理をスレッドプールで実行させるというマルチスレッド化
await UniTask.SwitchToThreadPool();

// こんなようなUnityWebRequestの非同期Get
async UniTask<string> GetTextAsync(UnityWebRequest req)
{
    var op = await req.SendWebRequest();
    return op.downloadHandler.text;
}

var task1 = GetTextAsync(UnityWebRequest.Get("https://google.com"));
var task2 = GetTextAsync(UnityWebRequest.Get("https://bing.com"));
var task3 = GetTextAsync(UnityWebRequest.Get("https://yahoo.com"));

// 並列実行して待機、みたいなのも簡単に書ける。そして戻り値も簡単に受け取れる。
var (google, bing, yahoo) = await UniTask.WhenAll(task1, task2, task3);

// タイムアウトも簡単にハンドリング
await GetTextAsync(UnityWebRequest.Get("https://unity.com")).Timeout(TimeSpan.FromMilliseconds(300));

UniTaskをインストールしたら、すぐに思いついたところをasync/await化することができるように構築しています。

UniTask vs Task

標準のC#ではasync/awaitの戻り値としてTask<T>が使えますが、UniTaskではパフォーマンスとUnityの親和性を高めるために、汎用性が重視された標準のTaskは使わずに、Unity専用に軽量化したUniTask<T>を用いています。これはC# 7.0から追加されたAsyncMethodBuilderの実装により実現しました。

  • ExecutionContext/SynchronizationContextを使わない(オーバーヘッド低減)
  • 値型のため内部が同期的に完了する場合はゼロアロケーション
  • PlayerLoopを拡張した独自のUnityコルーチン風のメソッド郡
  • 独自のトラッキングウィンドウ拡張によりリーク防止

image (5)

これらにより、UniTaskなしでasync/awaitを使う場合に比べて遥かに多くの利点を提供しています。

まとめ

既に正式版のリリースされているUnity 2018.3以降は、標準でC# 7.0をサポートしたこともあり、制約なくasync/awaitを使っていくことができます。しかし、まだきっちり使いこなせているところは少ないのではないでしょうか。UniTaskが、本格導入のための手助けになってくれれば幸いです。

また、Cy#では導入にあたってのサポート、コンサルティングもお請けできますので、ご興味がありましたら[Cy# – お問い合わせフォーム]よりコンタクトください。