Help us understand the problem. What is going on with this article?

[C#]async/awaitとUniRx.Asyncの実装を追う

More than 1 year has passed since last update.

ついにUnityでもIncremental Compilerを有効にすることでasync/awaitが使用することができるようになりました。
これは大変うれしいことではありますが、async/awaitが何をやっているかわからないと実行速度や効率の面で不安になるかもしれません。
そこで今回はasync/awaitが裏で何をやっているのか、async/awaitUnityで便利に使用することができるUniRx.Asyncが裏で何をやっているのかについて解説していきます。
async/awaitの使い方や詳細についてはすでに色々記事があるとは思うのでそちらも参考にしてください。

async/awaitの実装について

asyc/awaitのおさらい

async/awaitを使用すると以下のような書き方ができます。

async Task Hoge()
{
    Console.WriteLine("1");
    await Task.Delay(1000);
    Console.WriteLine("2");
}

上記のプログラムを実行すると1と表示された1秒後に2と表示されます。
このようにasync/awaitを書くと非同期処理をあたかも同期処理のように書くことができます。

awaitでは何が起こるのか

C#においてawaitはコンパイラでのコード生成によって実現されており、特殊なランタイムの機能を使用しているわけではありません。
これはawaitキーワードを使用しなくても自分の手でawait相当のことを記述できることを意味しています。
(厳密には無理ですが、それっぽいことができるということです。また、下記のものはコンパイラによって生成されるものとは内容が異なりますが同じような意味になります。)

awaitを自分の手で記述するためには関数をawait以前、await以降に分ける必要があります。
最初の例でいうと以下のような感じです。

void Hoge()
{
    Console.WriteLine("1");
    // await部分
}

void HogeContinuation()
{
    Console.WriteLine("2");
}

次にawait部分です。
awaitはタスクからAwaiterを取り出し、Awaiterに対して後続の処理を依頼します。
Awaiterは自身のタスクが完了したときに渡された後続の処理を実行します。

void Hoge()
{
    Console.WriteLine("1");

    // await を展開した部分
    var task = Task.Delay(1000);
    // Awaiterを取り出す
    var awaiter = task.GetAwaiter();
    // 後続部分
    Action continuation = () =>
    {
        // awiterの結果を受け取る
        // タスクの中で例外が発生していた場合などはGetResultを呼んだスレッドで改めて例外が投げられる
        awaiter.GetResult();
        // 本来の後続部分を実行
        HogeContinuation();
    };
    // タスクがすでに完了している場合はそのスレッド上で後続を実行
    if (awaiter.IsCompleted)
    {
        continuation();
    }
    // 完了していない場合はAwaiterに後続部分の処理を任せる
    else
    {
        awaiter.OnCompleted(continuation);
    }
}

awaitで戻り値を受け取る場合もほぼ同じです。
違うのはAwaiter.GetResultの戻り値を後続に渡すようになっているくらいです。

asyncでは何が起こるのか

asyncawait同様、コンパイラでのコード生成によって実現されており、特殊なランタイムの機能を使用しているわけではありません。
例によってasyncと同様の処理を自分の手で書くことができます。
asyncはすべての処理が終わった時に完了になるタスクを返す関数を作成します。
分かりにくいかもしれませんが以下のようになります。

Task Hoge()
{
    var completionSource = new TaskCompletionSource<bool>();
    Console.WriteLine("1");

    var task = Task.Delay(1000);
    var awaiter = task.GetAwaiter();
    Action continuation = () =>
    {
        awaiter.GetResult();
        HogeContinuation();
        // 後続処理がすべて終わったことを通知する
        // もしcompletionSource.Taskが誰かにawaitされている場合(=AwaiterのOnCompletedが呼ばれている場合)、
        // この関数の中でawaitの続きの処理が実行される
        completionSource.SetResult(true);
    };
    if (awaiter.IsCompleted)
    {
        continuation();
    }
    else
    {
        awaiter.OnCompleted(continuation);
    }

    // 後続処理がすべて終わった後に完了するタスクを返す
    return completionSource.Task;
}

task-likeについて

async/awaitで使用するTaskクラスは大変便利なのですが様々な問題を抱えています。
それはTaskクラスがとても複雑であること、クラスなので必ずアロケーションが発生してしまうことなどです。
普通のアプリケーションであれば絶対にどうにかしなければならないレベルではないと思いますが、
Unityにおいては使うのをためらってしまうレベルです。(個人差があります。)
この問題を解消するのがtask-likeです。
task-likeTaskクラスっぽくふるまえる型のことを指し、task-likeであればasyncメソッドの戻り値として使用できます。
task-like型を自分で実装することでasync/awaitを使用しつつパフォーマンスにも配慮することができます。
UniRx.Asyncはこのtask-like型の実装とそれらについての便利メソッドを提供しています。

UniRx.Asyncについて

UniTask

UniRx.Asyncで一番基礎となるtask-likeな型です。
asyncメソッドを使うときはこのUniTaskを戻り値にすることでTaskを戻り値にするよりパフォーマンスに優れています。
ソースを見ればわかりますが、この型自体はとてもシンプルな作りになっています。
参考:UniRx/UniTask.cs at master · neuecc/UniR

UniTaskをawaitしたときに何が起こるのか

async UniTask Hoge()
{
    var token = this.GetCancellationTokenOnDestroy();
    Debug.Log(1);
    // 1フレーム待つ
    await UniTask.Yield(PlayerLoopTiming.Update, token);
    Debug.Log(2);
}

上記の処理でawaitしている部分で何が起きているかを見ていきます。

前述の通り、awaitAwaiterを取得してOnCompletedを呼んでいました。
UniTaskGetAwaiterを見てみましょう。

// UniTask.csより引用
[DebuggerHidden]
public Awaiter GetAwaiter()
{
    return new Awaiter(this);
}

Awaiterを作って返しているだけなので、AwaiterOnCompletedを見てみましょう。

// UniTask.csより引用
[DebuggerHidden]
public void OnCompleted(Action continuation)
{
    if (task.awaiter != null)
    {
        task.awaiter.OnCompleted(continuation);
    }
    else
    {
        continuation();
    }
}

task.awaiterOnCompletedを呼んでいるようです。
少し調べるとtask.awaiterUniTaskのコンストラクタで渡しているIAwaiter型のインスタンスであることがわかります。

// UniTask.csより引用
readonly IAwaiter awaiter;

[DebuggerHidden]
public UniTask(IAwaiter awaiter)
{
    this.awaiter = awaiter;
}

よって、UniTaskawaitしたときの処理はUniTask本体ではなくそれを作る際に使用したIAwaiterにあるといえます。
UniTask.Yieldの場合を見てみます。

// UniTask.Delay.csより引用
public static UniTask Yield(PlayerLoopTiming timing, CancellationToken cancellationToken)
{
    return new UniTask(new YieldPromise(timing, cancellationToken));
}

YieldPromiseが本体っぽいです。

// UniTask.Delay.csより引用
class YieldPromise : PlayerLoopReusablePromiseBase
{
    public YieldPromise(PlayerLoopTiming timing, CancellationToken cancellationToken)
        : base(timing, cancellationToken, 2)
    {
    }

    protected override void OnRunningStart()
    {
    }

    public override bool MoveNext()
    {
        Complete();
        if (cancellationToken.IsCancellationRequested)
        {
            TrySetCanceled();
        }
        else
        {
            TrySetResult();
        }

        return false;
    }
}

このクラスにはOnCompletedがないので継承先のクラスを探します。
するとReusablePromiseOnCompletedが見つかりました。

// ReusablePromise.csより引用
public void OnCompleted(Action action)
{
    UnsafeOnCompleted(action);
}

public void UnsafeOnCompleted(Action action)
{
    if (continuation == null)
    {
        continuation = action;
        return;
    }
    else
    {
        if (continuation is Action act)
        {
            var q = new MinimumQueue<Action>(4);
            q.Enqueue(act);
            q.Enqueue(action);
            continuation = q;
            return;
        }
        else
        {
            ((MinimumQueue<Action>)continuation).Enqueue(action);
        }
    }
}

色々やっていますが、結局は渡されたアクションを内部に保持しているだけです。
なのでOnCompletedを呼んだだけでは何も起こらないことが分かります。

ではawaitをしても何も起こらないかというとそうではありません。
awaitをしたときに、OnCompleted以外にもIsCompletedが呼ばれているのでした。
IsCompletedの中を見てみましょう。
IsCompletedPlayerLoopReusablePromiseBaseの中にあります。

// ReusablePromise.csより引用
public override bool IsCompleted
{
    get
    {
        if (Status == AwaiterStatus.Canceled || Status == AwaiterStatus.Faulted) return true;

        if (!isRunning)
        {
            isRunning = true;
            ResetStatus(false);
            OnRunningStart();
#if UNITY_EDITOR
            TaskTracker.TrackActiveTask(this, capturedStackTraceForDebugging);
#endif
            PlayerLoopHelper.AddAction(timing, this);
        }
        return false;
    }
}

重要なのはPlayerLoopHelper.AddActionです。
詳しくは書きませんがこれはUnityPlayerLoopSystemを使って所定のタイミングで所定のメソッドを呼んでもらう機能です。
これによって次のフレームのUpdateのタイミングでYieldPromiseMoveNextが呼ばれるようになります。
YieldPromiseMoveNextを再掲します。

// UniTask.Delay.csより引用
public override bool MoveNext()
{
    Complete();
    if (cancellationToken.IsCancellationRequested)
    {
        TrySetCanceled();
    }
    else
    {
        TrySetResult();
    }

    return false;
}

おそらくTrySetResultの中で後続の処理が呼ばれているのだろうと予想がつきます。
ReusablePromiseを見てみます。

// ReusablePromise.csより引用
public virtual bool TrySetResult()
{
    if (status == AwaiterStatus.Pending)
    {
        status = AwaiterStatus.Succeeded;
        TryInvokeContinuation();
        return true;
    }
    return false;
}

void TryInvokeContinuation()
{
    if (continuation == null) return;

    if (continuation is Action act)
    {
        continuation = null;
        act();
    }
    else
    {
        // reuse Queue(don't null clear)
        var q = (MinimumQueue<Action>)continuation;
        var size = q.Count;
        for (int i = 0; i < size; i++)
        {
            q.Dequeue().Invoke();
        }
    }
}

予想通り、TrySetResultを呼ぶと最終的に継続処理が呼ばれているようです。
まとめると以下のようになります。

awaitを実行したフレームではPlayerLoopHelperを使用して処理を登録する。
次のフレームでPlayerLoopHelperに登録した処理が実行される。
これによってタスクが完了扱いになりそのスレッド上で後続処理が呼ばれる。

今回はYieldを見ましたがDelayなども大体同じです。
UIButtonOnClickAsyncの用にUnityのイベントなどが元になっているものについてはPlayerLoopHelperを使用せずに、イベント登録などが使用されます。

注意点としてawaitをするとPlayerLoopに登録されるということです。
PlayerLoopに登録されてしまうと、awaitを行ったときのGameObjectDestroyされても後続処理が実行されてしまいます。

void Start()
{
    Hoge().Forget();
    Destroy(this.gameObject);    
}

async UniTask Hoge()
{
    Debug.Log(1);
    await UniTask.Delay(100);
    Debug.Log(2);
}

上記の処理を実行したとき、Debug.Log(2)も実行されてしまいます。
回避するためには必ずCancellationTokenを渡しましょう。

void Start()
{
    var token = this.GetCancellationTokenOnDestroy();
    Hoge(token).Forget();
    Destroy(this.gameObject);    
}

async UniTask Hoge(CancellationToken token)
{
    Debug.Log(1);
    await UniTask.Delay(100, cancellationToken: token);
    Debug.Log(2);
}

まとめ

async/awaitは便利な機能なのでUnityでも使えるのはとてもうれしいです。
正しく理解して快適なasync/await生活を送りましょう。

補足

コンパイラが生成するasync/awaitのコードはもっと複雑です。
気になる方はAsyncMethodBuilderとかAsyncStateMachineについて調べてみたりILSpyなどのツールで生成されたコードを調べてみてください。

以前書いた記事ですが、async/awaitを使えば以下のようなこともできます。
async/awaitを使って継続の力を手に入れる

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away