sh1’s diary

プログラミング、読んだ本、資格試験、ゲームとか私を記録するところ

Unity C# 非同期、async/await、コルーチンを整理する

f:id:shikaku_sh:20200910152454p:plain

タイトルのとおり、「非同期」がキーワードです。たとえば、つぎのような問いを整理するための記事です:

  • C# だと async/await の非同期とマルチスレッドとは何か違うのか?
  • Unity だと async/await とコルーチンはどのように違うのか

個人的にこれらは動作の点でも難しいところがあると思っていて、古典的なデバッグであるステップ実行を使って、動作の流れを追いづらいのです。なので、内部動作を知っていると整理の役にたつと思います。

Unity には UniRx や UniTask といった便利な機能拡張がありますが、そのまえに非同期を整理しておこう、ということでもあります。

async/await とは

async/await は非同期処理を簡単にできるようにした C# が標準で持つ機能です。

それぞれ async が非同期の asynchronous の略字で、await はそのまま待ち受けの意味ですね。読み方はエイシンクと、ウェイトというのが一般的です。

この a の発音の違いは、接頭辞の問題という考え方があるようですが、非同期処理における async/await という意味の問題に話を進めると:

  • async は、await を使うメソッドにつける必要があるキーワード
  • await は、async メソッドをコールして、その完了まで実行を中断するキーワード

といえるはずです。なので、async/await は非同期処理をする仕組みではなくて、非同期処理を待つための仕組み、という言い方(説明)をする人もいます。要のところは、async よりも await のほうが非同期処理の主導権を持っているということだと思います。

await をつけたコードはどのような内部動作になるか

コンパイルによって知ることができる内部挙動ですが、async/await は、await をつけた時点でメソッドのコードが大きく変化することになるようです。

主導権を await が持つポイントのひとつとして、await をつけることでコードが大きく変化します。

つぎのようなコードだと、どのように変化するでしょうか:

async void Start()
{
    Debug.Log(Thread.CurrentThread.ManagedThreadId);
    await Tahsl.Delay(5000); // 5 秒停止
    Debug.Log(Thread.CurrentThread.ManagedThreadId);
}

これは、つぎのようなコードになっているようです:

private void AsyncTest()
{
    var stateMachine = new AsyncTestStateMachine();

    stateMachine.Builder = AsyncVoidMethodBuilder.Create();
    stateMachine.State = -1;

    stateMachine.Builder.Start(ref stateMachine);
}

クラスの定義は、ある程度よみやすい形に書き直していますが、スレッド ID を出力するコードがすべて AsyncTestStateMachine(テスト的につけた名前です)クラスの中に収められてしまっていてよくわからないです。

クラスの定義を確認してみます:

public struct AsyncTestStateMachine : IAsyncStateMachine
{
    private TaskAwaiter _TaskAwaiter;

    public int State { get; set; }
    public AsyncVoidMethodBuilder Builder { get; set; }

    public void SetStateMachine(IAsyncStateMachine stateMachine) { }

    public void MoveNext()
    {
        int num = State;

        try
        {
            TaskAwaiter awaiter;
            
            if (num != 0)
            {
                Console.WriteLine(Thread.CurrentThread.ManagedThreadId);

                awaiter = Task.Delay(5000).GetAwaiter();

                if (!awaiter.IsCompleted)
                {
                    num = State = 0;

                    _TaskAwaiter = awaiter;
                    AsyncTestStateMachine stateMachine = this;

                    Builder.AwaitUnsafeOnCompleted<TaskAwaiter, AsyncTestStateMachine>(ref awaiter, ref stateMachine);

                    return;
                }
            }
            else
            {
                awaiter = _TaskAwaiter;

                _TaskAwaiter = default(TaskAwaiter);

                num = State = -1;
            }

            awaiter.GetResult();

            Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        }
        catch (Exception ex)
        {
            State = -2;
            Builder.SetException(ex);

            return;
        }

        State = -2;
        Builder.SetResult();
    }
}

ポイントになるのは、MoveNext メソッドは、if (!awaiter.IsCompleted) のあと、return されてしまうということです。つまり、MoveNext メソッドは2回以上(複数回)コールされます。

この感じのコードは yield return でも同じような生成コードだったという認識があると話は楽ですね。デバッグの際にも一行ずつステップ実行をしても挙動が見た目と違ってくるので、根本的な理解がないと苦しい部分だと思います。

コルーチンと yield return

yield return がどのような内部動作をしているのか把握していなければ、yield return を活用しているはずのコルーチンもまた把握できていない、ということだと思います。

次のようなコルーチンのコードは、どのようなコードが生成されるのでしょうか:

private IEnumerator SampleCoroutine()
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    yield return null;
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    yield return null;
}

Unity のコルーチンは yield return をつかって(本来はイテレーター処理を目的とした)非同期処理らしいことをすることができますが、やはり MoveNext を(遅延評価の仕組みによって)繰り返し完了するまでコールする仕組みが基本になっています。

コンパイルしたコードをすこし読みやすくした例がこちら:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;

public class TestCoroutine : IEnumerator<object>, IDisposable, IEnumerator
{
    private int _State;
    private object _Current;

    public TestCoroutine _This;

    object IEnumerator<object>.Current => _Current;
    public object Current => _Current;

    public TestCoroutine(int state) => _State = state;

    public void Dispose() { }

    public void Reset()
    {
        throw new NotSupportedException();
    }

    public bool MoveNext()
    {
        switch (_State)
        {
            default:
                return false;
            case 0:
                _State = -1;
                Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
                _Current = null;
                _State = 1;
                return true;
            case 1:
                _State = -1;
                Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
                _Current = null;
                _State = 2;
                return true;
            case 2:
                _State = -1;
                return false;
        }
    }

    public IEnumerator Coroutine()
    {
        var coroutine = new TestCoroutine(0);

        coroutine._This = this;

        return coroutine;
    }
}

Unity だと、コルーチンはすべての Update メソッドが実行されたあとにコールされる仕様になっていますが、while でコールするならこんな感じ:

private void CoroutineTest()
{
    var coroutine = new TestCoroutine(0);

    while (coroutine.MoveNext())
    {
        ;
    }
}

Unity の非同期処理(≠マルチスレッド)

Unity で重要になるのが、非同期処理≠マルチスレッドということだと思います。非同期処理はマルチスレッドを必ずしも必要としていないのです。

非同期処理は、重たい処理があったときに、その処理が終わるのを待たずに次の処理も進めていくこと(あるタスクを実行している際に、他のタスクが別の処理を実行できる)くらいの意味なのでシングルスレッドでも問題ないのです。

これが Unity において重要なことです。

というのも、Unity は(UI を操作できる)メインスレッドからだけ呼び出せるメソッドがたくさんあるため、async/await を使ったからといってマルチスレッド(別スレッド)で処理されると不便なことのほうが多いです。

なので Unity は、むしろ安易にマルチスレッドにしないための仕組みとして(SynchronizationContext)が最初から組み込まれています。これは WPF なんかでも、GUI に関する処理はメインスレッドでしか処理できないので、そうした際にも利用されることがありますが、つぎのコードを実行してもスレッドの ID が変化しないのは SynchronizationContext の影響です。

async void Start()
{
    Debug.Log(Thread.CurrentThread.ManagedThreadId); // output 1
    await Tahsl.Delay(5000); // 5 秒停止
    Debug.Log(Thread.CurrentThread.ManagedThreadId); // output 1
}

f:id:shikaku_sh:20201116151138p:plain
ID が1と4で別。Delay の前後でスレッドが別々!

コンソールアプリケーションなんかだと、ManagedThreadId の値は変化しますが、Unity は、デフォルトではスレッドを変えないように設定されている、というような話になります。

一歩話を戻して、マルチスレッドにならないなら Unity は async/await を使う需要はどこにあるのか、ということになると思いませんか。

この答えを考えてみると、マルチスレッドの恩恵を受けたいというよりも、非同期処理の恩恵を受けたいということになると思います。どんな恩恵を受けたいのかというと、なにかの処理があったときに、その処理が終わるのを待たずに次の処理も進めていくことにニーズがあるということだと思います。

用語のややこしさ

最後に、用語を整理しておくとよいと思います。言葉の意味を正しく知ることは、理解のうえで助けになるはずです。重要な名詞をいくつか思い浮かべます。

似たような意味の言葉がたくさんあって、(人によって)微妙な意味の解釈違いや、そもそも使い分けが曖昧なこともあります。自分のなかでなるべくはっきりさせる努力が必要です。

問題をとく活動をいいあらわす言葉は新しい言葉も古い言葉も共にあいまいである。解析という言葉はパプスによって手際よく定義された。不幸にして分析ということばは数学や化学や論理学でいろいろちがった意味に用いられるので、残念ながらこの言葉を用いることをやめにしなければならない。(いかにして問題をとくか P41)

サンプル

GitHub に「AsyncAwaitTest」というサンプルを公開しています。

参考

いかにして問題をとくか

いかにして問題をとくか

  • 作者:G. ポリア
  • 発売日: 1975/04/01
  • メディア: 単行本