UniTask v2 – Unityのためのゼロアロケーションasync/awaitと非同期LINQ


Cy#の河合です。去年、UniTask – Unityでasync/awaitを最高のパフォーマンスで実現するライブラリという形で紹介させていただきましたが、今回全てのコードを書き換えた新バージョンをリリースしました。

GitHub – Cysharp/UniTask

UniTask v2では、コードの徹底的な書き換えにより、ほぼ全てがゼロアロケーション化しました(技術的詳細は後ほど述べます)。これによりパフォーマンスの大幅な向上を果たしているほか、新たに非同期シーケンスと、それに対応する非同期LINQが組み込まれました。その他、DOTweenやAddressableなどの外部アセットに対するawait標準対応も組み込まれ、より利便性が高まっています。

v2の前に、まず、async/await はC# 5.0から搭載されている機能で、従来コールバックの連鎖やコルーチンで処理していた非同期コードを、同期コードで書くように、戻り値も例外処理も自然に扱えるようになります。コールバックのみで処理する場合、複雑な処理でネストが多重になること、その際に内側の例外が外側に伝搬されないためエラー処理が難しくなることなどがよく知られています。

FooAsync(x =>
{
    BarAsync(x, y =>
    {
        BazAsync(y, z =>
        {
        });
    });
});

Unityの場合はyield return(ジェネレーター)で実現しているコルーチンで非同期処理を行うことができるため、ある程度はネストを平らにすることができますが、文法上の制約で戻り値を扱うことができないため、合わせてデリゲートを渡したりなどして処理していました。

IEnumerator FooCoroutine(Func<int> resultCallback, Func<Exception> exceptionCallback)
{
    int x = 0;
    Exception error = null;
    yield return DoAsync(v => x = v, ex => error = ex);

    if (error == null)
    {
        resultCallback(x);
    }
    else
    {
        exceptionCallback(error);
    }
}

これでネストの階層をある程度は平らにすることはできましたが

  • 依然として残る煩雑なコールバック処理
  • yield構文の制約でtry-catch-finallyが使えない
  • ラムダ式とコルーチン自体のアロケーション
  • ライフサイクルが紐付いているGameObjectのDestroyに伴うキャンセル処理ができない
  • 複数コルーチンのコントロール(直列/並列処理)が不可能

といった問題がありました。

async/awaitでは

async UniTask<int> FooAsync(int x)
{
    try
    {
        var y = await BarAsync(x);
        var z = await BazAsync(y);
        return x + y + z;
    }
    catch (Exception ex)
    {
    }
    finally
    {
    }
}

といったように、言語レベルのサポートで同期コードとほぼ変わらない記述で非同期コードを書けるようにしているのが強みです。

しかし、Unityのフレームワーク自体があまりasync/awaitをサポートしていないため、そのままでは決して使えるとは言い難い状況でした。UniTaskはC#の言語サポートをベースに以下を提供します。

  • Unityの各AsyncOperationに対するawait対応
  • UnityのPlayerLoopベースのスイッチ処理(Yield, Delay, DelayFrame, etc…)によってコルーチンで可能な全機能をUniTask上で実現
  • MonoBehaviourイベントやuGUIイベントのawait対応

値型ベースの独自の UniTask 型と専用の AsyncMethodBuilder を実装することで .NET 標準の Task と Unityにおいて不要な ExecutionContext/SynchronizationContext を通さずに処理することでUnityに最適化されたパフォーマンスを実現

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

// 100フレーム待つなどフレームベースの待機(コルーチンの代わり)
await UniTask.DelayFrame(100);

// WaitForFixedUpdateの代わり
await UniTask.Yield(PlayerLoopTiming.FixedUpdate);

これらの対応により、Unityにおいても async/await のパワーを100%活かせるような環境になりました。

そして、最初のリリースから2年を経て状況も変わってきました。 .NET Core も3.1、そして .NET 5 が発表されてランタイムも書き換わっていってますし、Unityでも C# 8.0 が見えてきました。そこで、上記の要素は引き継ぎつつ、新たにAPIの全面的な見直しと

  • asyncメソッド全体のゼロアロケーション化による更なるパフォーマンスの向上
  • 非同期LINQ(UniTaskAsyncEnumerable, Channel, AsyncReactiveProperty)
  • PlayerLoopタイミングの増加(新しいLastPostLateUpdateによってWaitForEndOfFrameと同じ効果が見込める)
  • Addressable, DOTweenといった外部アセットのawait対応

という、性能改善と機能追加を実装しました。特にゼロアロケーション化は、async/awaitを多用してもGCを抑えることができるため、大きなパフォーマンス向上が見込めます。また、それに合わせて .NET Core の ValueTask/IValueTaskSource と同様の挙動になるように調整しました(Delayなど全てのFactoryがTaskと同様に呼び出し時に起動、二度await時に例外throwなど)。これによって独自のUniTaskによるパフォーマンス向上のメリットを得つつも、挙動に関しては標準合わせにすることで、学習のギャップを抑えています。

Unity 2020.2.0a12から C# 8.0 がサポートされたことにより、非同期ストリームに関する記法が可能になっています。そこでUniTask v2では非同期ストリームをサポートする UniTaskAsyncEnumerable を追加しました。

// Unity 2020.2.0a12~, C# 8.0
await foreach (var _ in UniTaskAsyncEnumerable.EveryUpdate())
{
    Debug.Log("Update() " + Time.frameCount);
}

// C# 7.3(Unity 2018.3~)
await UniTaskAsyncEnumerable.EveryUpdate().ForEachAsync(_ =>
{
    Debug.Log("Update() " + Time.frameCount);
});

C# 8.0 がサポートされていないUnity 2018, 2019, 2020.1 においても搭載されている非同期LINQを併用することで、ほぼ同じような処理が可能になっています。また、LINQなので、全ての標準LINQクエリ演算子が非同期ストリームに適用可能です。例えば、以下のコードはボタンクリック非同期ストリームに対して2回に1回処理が走るWhereフィルターをかけた例です。

await okButton.OnClickAsAsyncEnumerable().Where((x, i) => i % 2 == 0).ForEachAsync(_ =>
{
});

ボタンクリック以外にも、多くのUnityと融合した豊富な非同期ストリームファクトリーが提供されているので、工夫次第であらゆる処理が書けるようになります。

AsyncStateMachineの原理とゼロアロケーション化

非同期ストリーム対応など多くの新機能がありますが、UniTask v2最大の特徴はパフォーマンスの大幅な向上です。

標準のTask実装による async/await と比較して、どこでアロケーションが発生していて、どのようにそれを抑止したのかを解説します。以下のような比較的単純な非同期メソッドを例に、構造を分解して行きましょう。

public class Loader
{
    object cache;

    public async Task<object> LoadAssetFromCacheAsync(string address)
    {
        if (cache == null)
        {
            cache = await LoadAssetAsync(address);
        }
        return cache;
    }

    Task<object> LoadAssetAsync(string address)
    {
        // do something...
    }
}

もし読み込み済みのキャッシュがあればそれを返し、そうでなければ実際に非同期で読み込み処理をするコードです。この await はコンパイル時に GetAwaiter -> IsCompleted/GetResult/UnsafeOnCompleted に分解されて呼ばれます。

public class Loader
{
    public Task<object> LoadAssetFromCacheAsync(string address)
    {
        if (cache == null)
        {
            var awaiter = LoadAssetAsync(address).GetAwaiter();
            if (awaiter.IsCompleted)
            {
                cache = awaiter.GetResult();
            }
            else
            {
                // regsiter callback, where from moveNext and promise?
                awaiter.UnsafeOnCompleted(moveNext);
                return promise;
            }
        }
        return Task.FromResult(cache);
    }
}

これは最適化の一種で、コールバックが不要な場合に(例えばこのLoadAssetFromCacheAsync自身もキャッシュ済みならば即座に値を返します)、コールバックの生成/登録/呼び出しのコストを避けることができます。

async で宣言されたメソッドはコンパイラによってステートマシンに変換されます。このステートマシンを進める(MoveNext)メソッドがawaitのコールバックに登録される仕組みとなっています。

public class Loader
{
    object cache;

    public Task<object> LoadAssetFromCacheAsync(string address)
    {
        var stateMachine = new __LoadAssetFromCacheAsync
        {
            __this = this,
            address = address,
            builder = AsyncTaskMethodBuilder<object>.Create(),
            state = -1
        };

        var builder = stateMachine.builder;
        builder.Start(ref stateMachine);

        return stateMachine.builder.Task;
    }

    // compiler generated async-statemachine
    // Note: in debug build statemachine as class.
    struct __LoadAssetFromCacheAsync : IAsyncStateMachine
    {
        // local variables to field.
        public Loader __this;
        public string address;

        // internal state
        public AsyncTaskMethodBuilder<object> builder;
        public int state;

        // internal local variables
        TaskAwaiter<object> loadAssetAsyncAwaiter;

        public void MoveNext()
        {
            try
            {
                switch (state)
                {
                    // initial(call from builder.Start)
                    case -1:
                        if (__this.cache != null)
                        {
                            goto RETURN;
                        }
                        else
                        {
                            // await LoadAssetAsync(address)
                            loadAssetAsyncAwaiter = __this.LoadAssetAsync(address).GetAwaiter();
                            if (loadAssetAsyncAwaiter.IsCompleted)
                            {
                                goto case 0;
                            }
                            else
                            {
                                state = 0;
                                builder.AwaitUnsafeOnCompleted(ref loadAssetAsyncAwaiter, ref this);
                                return; // when call MoveNext again, goto case 0:
                            }
                        }
                    case 0:
                        __this.cache = loadAssetAsyncAwaiter.GetResult();
                        goto RETURN;
                    default:
                        break;
                }
            }
            catch (Exception ex)
            {
                state = -2;
                builder.SetException(ex);
                return;
            }

            RETURN:
            state = -2;
            builder.SetResult(__this.cache);
        }

        public void SetStateMachine(IAsyncStateMachine stateMachine)
        {
            builder.SetStateMachine(stateMachine);
        }
    }
}

StateMachineの実装はやや長くなっていますが、基本的にはawaitの書かれた行で分割されてstateが一つ進む、と捉えてください。後述のBuilderとセットになっているため少し読み取りにくいのですが、awaiterはIsCompletedがtrueの場合に即座にGetResultを呼び、falseの場合はUnsafeOnCompletedに自身のMoveNextをセットします。このMoveNextは非同期処理が完了した場合に再度呼び出されて、自身のawaiterのGetResultを呼んで結果を取得するという流れになります。

最後の登場人物が AsyncTaskMethodBuilder で、これはコンパイラ生成ではなく、戻り値であるTaskと1:1で対応するビルダークラスとなっています。元のソースは少し長いので、簡略化したコードを掲示します。

public struct AsyncTaskMethodBuilder<TResult>
{
    MoveNextRunner runner;
    Task<TResult> task;

    public static AsyncTaskMethodBuilder<TResult> Create()
    {
        return default;
    }

    public void Start<TStateMachine>(ref TStateMachine stateMachine)
        where TStateMachine : IAsyncStateMachine
    {
        // when start, call stateMachine's MoveNext directly.
        stateMachine.MoveNext();
    }

    public Task<TResult> Task
    {
        get
        {
            if (task == null)
            {
                // internal task creation(same as TaskCompletionSource but avoid tcs allocation)
                task = new Task<TResult>();
            }

            return task.Task;
        }
    }

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        // at first await, copy struct state machine to heap(boxed).
        if (runner == null)
        {
            _ = Task; // create TaskCompletionSource

            // create runner
            runner = new MoveNextRunner((IAsyncStateMachine)stateMachine); // boxed.
        }

        // set cached moveNext delegate(as continuation).
        awaiter.UnsafeOnCompleted(runner.CachedDelegate);
    }

    public void SetResult(TResult result)
    {
        if (task == null)
        {
            _ = Task; // create Task
            task.TrySetResult(result); // same as TaskCompletionSource.TrySetResult.
        }
        else
        {
            task.TrySetResult(result);
        }
    }
}

public class MoveNextRunner
{
    public Action CachedDelegate;

    IAsyncStateMachine stateMachine;

    public MoveNextRunner(IAsyncStateMachine stateMachine)
    {
        this.stateMachine = stateMachine;
        this.CachedDelegate = Run; // Create cached delegate.
    }

    public void Run()
    {
        stateMachine.MoveNext();
    }
}

Builderには初回呼び出し時(Start), 戻り値であるTaskの取得、コールバックの登録時(AwaitUnsafeOnCompleted)、結果設定時(SetResult/SetException)の実際の処理を記述されています。

awaitの連鎖はコールバックの連鎖と似ていますが、手でコールバックの連鎖を書いた場合はラムダ式のアロケーションの発生を避けられませんが、async/awaitはコンパイラの生成した単一のデリゲートによって全ての処理が回るため、アロケーションが少なくすみます。これらの仕組みにより、async/awaitで書いたほうが、手書きよりもむしろ高性能になります。

これでようやく全てのパーツが出揃いました。async/awaitとTaskには、コンパイラ生成を活かした細かい最適化も入っていて基本的にはとても良いのですが、問題点も幾つか残っています。

メモリアロケーションという観点で考えると、ワーストケースで以下の4つのアロケーションが発生します。

  • Taskのアロケーション
  • AsyncStateMachineのBox化
  • AsyncStateMachineを内包したRunnerのアロケーション
  • MoveNextのためのデリゲートのアロケーション

戻り値をTaskで宣言する都合上、たとえ値が即時返しの状態でも、必ずTaskのアロケーションが発生するのが特に頂けません。この問題に対処するために .NET Standard 2.1 ではValueTask型が導入されましたが、コールバックが必要な場合には変わらずTaskのアロケーションが存在することと、AsyncStateMachineのBox化なども存在しています。このアロケーションは、いったん現在の実行状態を解放してStateMachineをヒープ上に置かなければならない都合上、どうしても起こり得るものです。

これらの問題をUniTaskではC# 7.0から搭載されたカスタムAsyncMethodBuilderによって、独自実装に全て置き換えることで解消しました。

// modify Task<T> -> UniTask<T> only.
public async UniTask<object> LoadAssetFromCacheAsync(string address)
{
    if (cache == null)
    {
        cache = await LoadAssetAsync(address);
    }
    return cache;
}

// Compiler generated code is same as standard Task.
public UniTask<object> LoadAssetFromCacheAsync(string address)
{
    var stateMachine = new __LoadAssetFromCacheAsync
    {
        __this = this,
        address = address,
        builder = AsyncUniTaskMethodBuilder<object>.Create(),
        state = -1
    };

    var builder = stateMachine.builder;
    builder.Start(ref stateMachine);

    return stateMachine.builder.Task;
}

// UniTask's AsyncMethodBuilder
public struct AsyncUniTaskMethodBuilder<T>
{
    internal IStateMachineRunnerPromise<T> runnerPromise;
    T result;

    public UniTask<T> Task
    {
        get
        {
            // when registered callback
            if (runnerPromise != null)
            {
                return runnerPromise.Task;
            }
            else
            {
                // sync complete, return struct wrapped result
                return UniTask.FromResult(result);
            }
        }
    }

    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        if (runnerPromise == null)
        {
            // get Promise/StateMachineRunner from object pool
            AsyncUniTask<TStateMachine, T>.SetStateMachine(ref this, ref stateMachine);
        }

        awaiter.UnsafeOnCompleted(runnerPromise.MoveNext);
    }

    public void SetResult(T result)
    {
        if (runnerPromise == null)
        {
            this.result = result;
        }
        else
        {
            // SetResult signal Task continuation, it will call task.GetResult and finally return to pool self.
            runnerPromise.SetResult(result);

            // AsyncUniTask<TStateMachine, T>.GetResult
            /*
            try
            {
                return core.GetResult(token);
            }
            finally
            {
                TryReturn();
            }
            */
        }
    }
}

値型のUniTaskの導入によって値が即時返しの場合のアロケーションに対処し、専用のAsyncUniTaskMethodBuilderと、強く型付けされた(boxingの発生しない)MoveNextRunnerは戻り値のTaskと一体化することにより更にメモリ消費量を低減したうえに、オブジェクトプールから取得されて、Taskのawait呼び出しが完了した際(GetResult)にプールに戻ります。これによってTaskとステートマシン関連のアロケーションが完全になくなりました。

awaitが完了した際に自動的にプールに戻るため、制限として全てのUniTaskオブジェクトは二度awaitすることができなくなっています。

これは .NET Standard 2.1 で導入される ValueTask/IValueTaskSourceと同様の制約になります。

The following operations should never be performed on a ValueTask instance:

  • Awaiting the instance multiple times.
  • Calling AsTask multiple times.
  • Using .Result or .GetAwaiter().GetResult() when the operation hasn’t yet completed, or using them multiple times.
  • Using more than one of these techniques to consume the instance.

If you do any of the above, the results are undefined.

多少の不自由さはありますが、逆にこの制約のお陰で、アグレッシブなプーリングが可能になりました。

なお、こうしたゼロアロケーションによるasync/awaitは、実装は同じではありませんが、 .NET 5で Async ValueTask Pooling in .NET 5 として導入される予定があります。UniTask v2は遠い先のランタイムの更新を待たずして、今すぐにUnityで実現されます。

Unity Editor上やDevelopment Buildでプロファイラーで監視するとアロケーションが確認できるかもしれません。これは、C#コンパイラの生成するAsyncStateMachineがデバッグビルド時はclassになっているためです。リリースビルド時はstructになるため、アロケーションはなくなります。

プールのサイズはデフォルトでは無制限になっていますが、 TaskPool.SetMaxPoolSize で最大サイズの設定と TaskPool.GetCacheSizeInfo で現在キャッシュ中の数を取得できます。 .NET Core と違いGCによるインパクトの大きいUnityのため、積極的にプールするようにしていますが、アプリケーションによっては調整したほうが良い可能性もあります。

コルーチンとPlayerLoop

Taskと異なるUniTaskの大きな特徴として、(ExecutionContextと)SynchronizationContextを一切使わないことにあります。SynchronizationContextとは、await時に元の同期コンテキスト、Unityの場合だとメインスレッドに自動的に戻してくれる機能です(UnitySynchronizationContextというものが用意されています)。これは一見便利ですが、awaitの度に同期コンテキストのキャプチャの処理が必要というオーバーヘッドがあることと、そもそもUnityの非同期処理(AsyncOperation)はUnityのエンジン(C++)側がメインスレッドに戻してイベントを呼びだす仕組みになっているため、C#側でメインスレッドに戻す機構をあまり必要としない事情もあります。そこで大胆にSynchronizationContextをカットすることで、より軽量化された実行処理を手にいれました。

もう一つ、SynchronizationContextは戻ってくる箇所が一つだけですが、Unityの場合、実行シーケンスの呼び出し箇所を細かく制御するシチュエーションが多くあります。例えばコルーチンでもWaitForEndOfFrameやWaitForFixedUpdateなどで、実行する箇所を調整することがよくあります。

そこで、UniTaskでは単一のSynchronizationContextの代わりに、手動で戻る実行シーケンスの箇所を指定できる形式としました。

// PlayerLoop.Updateで実行する(yield return nullに等しい)
await UniTask.Yield();

// yield return new WaitForEndOfFrameに等しい
await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate);

// ここより下の処理をThreadPool上で処理
await UniTask.SwitchToThreadPool();

// PreUpdateで指定時間待つ(WaitForSecondsに近いが、呼び出し箇所が指定できる)
await UniTask.Delay(TimeSpan.FromSeconds(1), delayTiming: PlayerLoopTiming.PreUpdate);

// 30フレーム毎にUpdateを呼び出し
await UniTaskAsyncEnumerable.IntervalFrame(30, PlayerLoopTiming.Update).ForEachAsync(_ =>
{
});

現在Unityでは標準でPlayerLoopという仕組みによって全てのイベント関数が駆動しています。以下はその一覧で、UniTaskは先頭と最後にそれぞれ注入し、合計14箇所から選択できるようにしています。

Initialization
---
**UniTaskLoopRunnerYieldInitialization**
**UniTaskLoopRunnerInitialization**
PlayerUpdateTime
DirectorSampleTime
AsyncUploadTimeSlicedUpdate
SynchronizeInputs
SynchronizeState
XREarlyUpdate
**UniTaskLoopRunnerLastYieldInitialization**
**UniTaskLoopRunnerLastInitialization**  

EarlyUpdate
---
**UniTaskLoopRunnerYieldEarlyUpdate**
**UniTaskLoopRunnerEarlyUpdate**
PollPlayerConnection
ProfilerStartFrame
GpuTimestamp
AnalyticsCoreStatsUpdate
UnityWebRequestUpdate
ExecuteMainThreadJobs
ProcessMouseInWindow
ClearIntermediateRenderers
ClearLines
PresentBeforeUpdate
ResetFrameStatsAfterPresent
UpdateAsyncReadbackManager
UpdateStreamingManager
UpdateTextureStreamingManager
UpdatePreloading
RendererNotifyInvisible
PlayerCleanupCachedData
UpdateMainGameViewRect
UpdateCanvasRectTransform
XRUpdate
UpdateInputManager
ProcessRemoteInput
*ScriptRunDelayedStartupFrame*
UpdateKinect
DeliverIosPlatformEvents
TangoUpdate
DispatchEventQueueEvents
PhysicsResetInterpolatedTransformPosition
SpriteAtlasManagerUpdate
PerformanceAnalyticsUpdate
**UniTaskLoopRunnerLastYieldEarlyUpdate**
**UniTaskLoopRunnerLastEarlyUpdate**  

FixedUpdate
---
**UniTaskLoopRunnerYieldFixedUpdate**
**UniTaskLoopRunnerFixedUpdate**
ClearLines
NewInputFixedUpdate
DirectorFixedSampleTime
AudioFixedUpdate
*ScriptRunBehaviourFixedUpdate*
DirectorFixedUpdate
LegacyFixedAnimationUpdate
XRFixedUpdate
PhysicsFixedUpdate
Physics2DFixedUpdate
DirectorFixedUpdatePostPhysics
*ScriptRunDelayedFixedFrameRate*
**UniTaskLoopRunnerLastYieldFixedUpdate**
**UniTaskLoopRunnerLastFixedUpdate**  

PreUpdate
---
**UniTaskLoopRunnerYieldPreUpdate**
**UniTaskLoopRunnerPreUpdate**
PhysicsUpdate
Physics2DUpdate
CheckTexFieldInput
IMGUISendQueuedEvents
NewInputUpdate
SendMouseEvents
AIUpdate
WindUpdate
UpdateVideo
**UniTaskLoopRunnerLastYieldPreUpdate**
**UniTaskLoopRunnerLastPreUpdate**  

Update
---
**UniTaskLoopRunnerYieldUpdate**
**UniTaskLoopRunnerUpdate**
*ScriptRunBehaviourUpdate*
*ScriptRunDelayedDynamicFrameRate*
*ScriptRunDelayedTasks*
DirectorUpdate
**UniTaskLoopRunnerLastYieldUpdate**
**UniTaskLoopRunnerLastUpdate**  

PreLateUpdate
---
**UniTaskLoopRunnerYieldPreLateUpdate**
**UniTaskLoopRunnerPreLateUpdate**
AIUpdatePostScript
DirectorUpdateAnimationBegin
LegacyAnimationUpdate
DirectorUpdateAnimationEnd
DirectorDeferredEvaluate
EndGraphicsJobsAfterScriptUpdate
ParticleSystemBeginUpdateAll
ConstraintManagerUpdate
*ScriptRunBehaviourLateUpdate*
**UniTaskLoopRunnerLastYieldPreLateUpdate**
**UniTaskLoopRunnerLastPreLateUpdate**  

PostLateUpdate
---
**UniTaskLoopRunnerYieldPostLateUpdate**
**UniTaskLoopRunnerPostLateUpdate**
PlayerSendFrameStarted
DirectorLateUpdate
*ScriptRunDelayedDynamicFrameRate*
PhysicsSkinnedClothBeginUpdate
UpdateRectTransform
UpdateCanvasRectTransform
PlayerUpdateCanvases
UpdateAudio
VFXUpdate
ParticleSystemEndUpdateAll
EndGraphicsJobsAfterScriptLateUpdate
UpdateCustomRenderTextures
UpdateAllRenderers
EnlightenRuntimeUpdate
UpdateAllSkinnedMeshes
ProcessWebSendMessages
SortingGroupsUpdate
UpdateVideoTextures
UpdateVideo
DirectorRenderImage
PlayerEmitCanvasGeometry
PhysicsSkinnedClothFinishUpdate
FinishFrameRendering
BatchModeUpdate
PlayerSendFrameComplete
UpdateCaptureScreenshot
PresentAfterDraw
ClearImmediateRenderers
PlayerSendFramePostPresent
UpdateResolution
InputEndFrame
TriggerEndOfFrameCallbacks
GUIClearEvents
ShaderHandleErrors
ResetInputAxis
ThreadedLoadingDebug
ProfilerSynchronizeStats
MemoryFrameMaintenance
ExecuteGameCenterCallbacks
ProfilerEndFrame
**UniTaskLoopRunnerLastYieldPostLateUpdate**
**UniTaskLoopRunnerLastPostLateUpdate**

長いので、Updateだけ取り出してみてみましょう。

Update
---
UniTaskLoopRunnerYieldUpdate
UniTaskLoopRunnerUpdate
ScriptRunBehaviourUpdate
ScriptRunDelayedDynamicFrameRate
ScriptRunDelayedTasks
DirectorUpdate
UniTaskLoopRunnerLastYieldUpdate
UniTaskLoopRunnerLastUpdate

MonoBehaviourのUpdateはScriptRunBehaviourUpdate、コルーチン(yield return null)はScriptRunDelayedDynamicFrameRate、UnitySynchronizationContextはScriptRunDelayedTasksのPlayerLoopで実行されています。こうしてみると、Unityのコルーチンも特別なことはなく、PlayerLoop(ScriptRunDelayedDynamicFrameRate)がIEnumeratorのMoveNextを毎フレーム呼んでいるだけに過ぎず、UniTaskのカスタムのPlayerLoopと大差ありません。

ただしUnityのコルーチンは古くからある仕組みであることと、当時のC# 3.0相当の機能までしか使えなかったこともあり、理想的な処理機構とは言えません。今となっては不要な機能(文字列指定での呼び出しや停止など)のためのエンジン側でのオーバーヘッドや、エンジン-スクリプト間でのオーバーヘッドなど、アロケーションだけではなくパフォーマンスも良好とは言い難く、更にyield returnは戻り値が渡せない、例外処理が使えない、など言語機構としても制約が大きく、また、StartCoroutineを起動したGameObjectのライフサイクルと自動で紐づくのは利点に見えて、実際はDestroyされるとMoveNextを呼ばなくなるだけであるため、finallyなどで記述したいキャンセルに対するクリーンアップ処理が動作しません。

非同期処理に限らないUniTaskによるコルーチンの代替としての利用は、それらの制約がなく、パフォーマンスも良好です。よって、コルーチンをUniTaskに置き換えていくのは現実的な選択だと考えています。

ライフサイクルの管理は、GameObjectとの自動的な紐付きの代わりに、.NET標準のCancellationTokenを使います。MonoBehaviour内では this.GetCancellationTokenOnDestroy() メソッドで、GameObjectの寿命に紐付いたCancellationTokenを取得できるため、それを渡してあげることによりasync/awaitのライフサイクル管理とします。これはUniRxではAddTo(this)に相当するもので、CancellationTokenはCompositeDisposableに相当します。

また、現在そのスクリプトが実行されているPlayerLoopがどこなのかは、Debug.Logでのスタックトレースから確認することが出来ます。

UniTaskのPlayerLoopで実行されている場合は、下から二番目の位置にPlayerLoopが表示されます(この場合はPlayerLoopTiming.PreLateUpdateで実行されている)。

非同期LINQ

Unity 2020.2.0a12から C# 8.0 がサポートされたことにより、非同期ストリームに関する記法が可能になりました。例えば以下のような記述がUpdate()の代わりになります!

// Unity 2020.2.0a12, C# 8.0
await foreach (var _ in UniTaskAsyncEnumerable.EveryUpdate(token))
{
    Debug.Log("Update() " + Time.frameCount);
}

さすがにC# 8.0は早すぎですが、C# 7.3 環境ではForEachAsyncメソッドを使うことで、ほぼ同様の形で動かすことができるので、現実的にはこちらを使っていくことになります。

// C# 7.3(Unity 2018.3~)
await UniTaskAsyncEnumerable.EveryUpdate(token).ForEachAsync(_ =>
{
    Debug.Log("Update() " + Time.frameCount);
});

また、UniTaskAsyncEnumerableには、`IEnumerable` におけるLINQと同じような、あるいは`IObservable`におけるRxと同じような、非同期LINQが実装されています。全ての標準LINQクエリ演算子が非同期ストリームに適用可能です。例えば、以下のコードはボタンクリック非同期ストリームに対して2回に1回処理が走るWhereフィルターをかけた例です。

await okButton.OnClickAsAsyncEnumerable().Where((x, i) => i % 2 == 0).ForEachAsync(_ =>
{
});

UniRx(Reactive Extensions)に近いですが、RxがPush型の非同期ストリームだったのに対し、UniTaskAsyncEnumerableはPull型の非同期ストリームとなります。似ていますが、特性が違うことと、それに伴い細部の挙動が異なることに注意してください。

標準クエリ演算子の他にUnity向けに EveryUpdate, Timer, TimerFrame, Interval, IntervalFrame, EveryValueChanged といったジェネレーターと、 uGUIのコンポーネントには ***AsAsyncEnumerable というイベントの非同期ストリーム変換、そしてMonoBehvaiourにはメッセージイベントを変換する AsyncTriggers が実装されています。また、ReactivePropertyのUniTask版であるAsyncReactiveProperty、Unityのコンポーネント(Text/Selectable/TMP/Text)に非同期ストリームの値をバインディングするBindTo拡張メソッドなど、UniRxに存在した機能も用意されています。

そして .NET Core の System.Threading.Channels をUniTask向けにアレンジしたChannelクラスも用意しました。GoのChannelをasync/await向けにアレンジしたようなものになっていて、Rxで言うところのSubjectのように扱えます。GoのChannelがfor rangeで回せるように、Channel.Reader.ReadAllAsyncIUniTaskAsyncEnumerableを返すので、そのままforeach、或いは非同期LINQに流し込むことが可能です。

応用例としては、UniTask独自の演算子であるPublishと組み合わせると、Pub/Subの実装に変換することなどもできます。

public class AsyncMessageBroker<T> : IDisposable
{
    Channel<T> channel;

    IConnectableUniTaskAsyncEnumerable<T> multicastSource;
    IDisposable connection;

    public AsyncMessageBroker()
    {
        channel = Channel.CreateSingleConsumerUnbounded<T>();
        multicastSource = channel.Reader.ReadAllAsync().Publish();
        connection = multicastSource.Connect();
    }

    public void Publish(T value)
    {
        channel.Writer.TryWrite(value);
    }

    public IUniTaskAsyncEnumerable<T> Subscribe()
    {
        return multicastSource;
    }

    public void Dispose()
    {
        channel.Writer.TryComplete();
        connection.Dispose();
    }
}

UniTaskAsyncEnumerableと非同期LINQには多くの可能性がありますが、しかし上級者向けの機能ではあるので、初めて使う人は、まずは通常のUniTaskのほうを使いこなせるようになってください。手を出すのはその後のほうが良いでしょう。不要な場合のビルドサイズ低減と、規約としての使用制限への対応のため、非同期LINQは `UniTask.Linq` としてUniTask本体とは別アセンブリになっています。

await対応の強化

AsyncOperation, ResourceRequest, AssetBundleRequest, AssetBundleCreateRequest, UnityWebRequestAsyncOperation といったUnityで非同期処理を行う際に出てくる戻り値がawait可能にする拡張がUniTaskには搭載されていますが、今回その拡張を3パターンに整理しました。

* await asyncOperation;
* asyncOperation.WithCancellation(CancellationToken);
* asyncOperation.ToUniTask(IProgress, PlayerLoopTiming, CancellationToken);

そのままawaitする以外に、WithCancellationメソッドを呼ぶことでキャンセル処理をサポートします。また、これの戻り値はUniTaskなので、WhenAllで並列処理に回すことも可能です。ToUniTaskはWithCancellationよりも高機能なオプションで、プログレスコールバック、実行するPlayerLoop、そしてCancellationTokenを渡すことができるメソッドになっています。

また、外部アセットとしてDOTweenとAddressableのサポートを標準で追加しています。例えばDOTweenでは以下のような実装が可能です。

// sequential
await transform.DOMoveX(2, 10);
await transform.DOMoveZ(5, 20);

// parallel
var ct = this.GetCancellationTokenOnDestroy();

await UniTask.WhenAll(
    transform.DOMoveX(10, 3).WithCancellation(ct),
    transform.DOScale(10, 3).WithCancellation(ct));

そして全てのUniTaskはUniTaskTrackerにて使用状況が監視できます。

これによりメモリリークを簡単に防ぐことが可能です。

.NET Core

UniTask v2から新しく .NET Core版の実装をNuGetで提供しています。標準のTask/ValueTaskよりも高性能で動作しますが、使用にあたりExecutionContext/SynchronizationContextを無視することに注意する必要があります。ExecutionContextを無視するため、AysncLocalも動作しません。そのため使う場合は全体では用いず、制約を理解してピンポイントで使うことをお薦めします。

内部ではUniTaskを用いて、かつ外部APIとしてはValueTaskを提供する場合は、以下のような書き方が可能です。

public class ZeroAllocAsyncAwaitInDotNetCore
{
    public ValueTask<int> DoAsync(int x, int y)
    {
        return Core(this, x, y);

        static async UniTask<int> Core(ZeroAllocAsyncAwaitInDotNetCore self, int x, int y)
        {
            // do anything...
            await Task.Delay(TimeSpan.FromSeconds(x + y));
            await UniTask.Yield();

            return 10;
        }
    }
}

.NET Core版提供の主な意図は、Unityとのコード共有時(Cysharpの提供するフレームワークであるMagicOnionなどを用いる)に、インターフェイスとしてUniTaskを使えるようにすることです。 .NET Core版のUniTaskを用いることで、スムーズなコード共有を可能にします。

それ以外の箇所では、 .NET Core においてはValueTaskを用いると良いでしょう。ValueTaskに欠けているUniTask相当のWhenAllなどのユーティリティメソッドは、Cysharp/ValueTaskSupplementとして提供しているので、こちらも合わせてご利用ください。

まとめ

async/awaitが優れた非同期処理の仕組みだということは、主要な言語のほとんどが実装したことからも明らかで(C#, JavaScript, TypeScript, Python, Dart, Kotlin, Rust, etc…)、その中でもC#は最初期に登場(初出はC# 5.0, 2012年)したものですが、その実装は今でも十分通じるものになっています。しかし、.NET Coreでは年月とともに改良が続いていったのも事実で(Task -> ValueTask -> IValueTaskSource -> ManualResetValueTaskSourceCore -> IAsyncEnumerable)、Unityは最初期の実装の状態で留まっているというギャップがありました。

幸い違うのはランタイムだけであることと、C#コンパイラには(7.0以降に)十分な拡張の口が用意されているため、UniTaskではUnityに最適化した完全な独自実装を提供し、性能面でのギャップを埋めるどころか越えることに成功しました。

かなり大掛かりなシステムにはなっていますが、ここまでやる必要あるのか?というと、あります。というのも、言語構文サポートというのは非常に大きく、C#においてはasync/awaitがベストな非同期処理の手法というのは揺るがないでしょう。しかし素のままではUnityではランタイム自体の古さと、若干のゲームエンジンとのミスマッチさを抱えています。UniTaskはそれをC#の徹底的なハックによって克服しましたが、同時にそれは実装することが難しく、UniTask以上の実装/ライブラリが出てくることもないでしょう。

仕組みは大掛かりですが挙動に関しては逆に軽量化されていることと、純粋なC#上で全て処理されているので、ブラックボックスがなくパフォーマンス特性が読みきれるのも利点です。コンパイラ生成結果も、そこまで複雑なものを生成しているわけではないことは、今回の記事で確認できたはずです。UnityのPlayerLoopにうまく委ねることによってスレッドも使わないので、WebGL, WebAssembly出力でも問題なく動作します。

非同期処理をコールバックにするかコルーチンにするか、それともUniRxにするか。答えは、UniTaskを使うべきです。そして、使いこなしてください。そこで物足りない要素があったら、それから初めてUniRxに手を出したり、或いはUniTask.Linqに進めると良いでしょう。

最初のUniTaskのリリースから2年を経て、多くのゲームに採用していただきましたが、まだまだasync/awaitについて理解されていない、誤解している人も少なくないように思えます。今回のUniTask v2によって、更により多くの人にasync/awaitが身近に、そしてUnityにおいての使いやすさを実感していただければと思っています。