📝

Unityの機能をawait可能にしてasync / awaitについて学ぶ

2022/04/22に公開

概要

C#では非同期処理として async / await の仕組みが用意されています。しかし実はこの機能、コンパイラによってコードが変更され、コールバックという形に変換されて動作するようになっているのです。そのため、知らずに使っていると思わぬところでハマったり、エラーになってしまったり、といったことが起きえます。

今回はそんな async / await の機能の内部を探りながら、最後はUnityの機能を拡張する実装を通して非同期処理を理解していこうと思います。

安原さんと名雪さんのスライドがとても分かりやすく参考になるので、こちらもぜひ読んでみてください。

非同期処理の挙動を確認する

細かい内容に入っていく前に、しっかりと把握していないとどういうことが起きるのかということを確認しておきましょう。

上のスライドにも書かれている以下のメソッドを実行するとどうなるでしょうか?

using System;
using System.Threading;
using System.Threading.Tasks;

public class App
{
    private static void Main(string[] args)
    {
        AsyncTest();
        Console.ReadLine();
    }

    private static async void AsyncTest()
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        
        await Task.Delay(1000);
        
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    }
}

ぱっと見た感じではどちらも同じIDが出力されそうです。では実際にこれを実行するとどうなるでしょうか。

自分の環境で実行してみると以下の結果になりました。

1
5

これは await Task.Delay(1000); の前後でスレッドが異なっていることを示しています。

さて、ではどうしてこういうことが起きるのか理由を探っていきましょう。

非同期メソッドを持ったクラスをデコンパイルして確認する

冒頭で書いたように、非同期処理はコンパイラがコールバックの形で動作するように変換してくれています。そのため、コンパイルされたものをデコンパイルすることでどういう変換がなされているか確認することができます。SharpLabというWebサービスを使ってどう変換されているのか見てみましょう。

https://sharplab.io/

SharpLab
初期状態のデコンパイル結果。いくつかのAttributeが追加され、元の形と異なっていることが確認できる

このツールを使って async / await を含んだコードがどう変換されていくか見ていきましょう。

今回は以下のコードをベースに解説していきます。

今回のサンプル
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;

public class Awaitable
{
    public Awaiter GetAwaiter() => new Awaiter();
}

public class Awaiter : INotifyCompletion
{
    private Action _continuation;
    
    public Awaiter()
    {
        Task.Run(() =>
        {
            Thread.Sleep(2000);
            _continuation?.Invoke();
        });
    }

    public bool IsCompleted => false;
    public void GetResult() {}
    public void OnCompleted(Action continuation)
    {
        _continuation = continuation;
    }
}

public class App
{
    private static void Main(string[] args)
    {
        AsyncTest();
        Console.ReadLine();
    }

    private static async void AsyncTest()
    {
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
        
        Awaitable a = new Awaitable();
        await a;
        
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    }
}

以下は実際にデコンパイルした結果です。

デコンパイル結果
using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security;
using System.Security.Permissions;
using System.Threading;
using System.Threading.Tasks;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue | DebuggableAttribute.DebuggingModes.DisableOptimizations)]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[assembly: AssemblyVersion("0.0.0.0")]
[module: UnverifiableCode]
public class Awaitable
{
    public Awaiter GetAwaiter()
    {
        return new Awaiter();
    }
}
public class Awaiter : INotifyCompletion
{
    private Action _continuation;

    public bool IsCompleted
    {
        get
        {
            return false;
        }
    }

    public Awaiter()
    {
        Task.Run(new Action(<.ctor>b__1_0));
    }

    public void GetResult()
    {
    }

    public void OnCompleted(Action continuation)
    {
        _continuation = continuation;
    }

    [CompilerGenerated]
    private void <.ctor>b__1_0()
    {
        Thread.Sleep(2000);
        Action continuation = _continuation;
        if (continuation != null)
        {
            continuation();
        }
    }
}
public class App
{
    [CompilerGenerated]
    private sealed class <AsyncTest>d__1 : IAsyncStateMachine
    {
        public int <>1__state;

        public AsyncVoidMethodBuilder <>t__builder;

        private Awaitable <a>5__1;

        private object <>u__1;

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                Awaiter awaiter;
                if (num != 0)
                {
                    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
                    <a>5__1 = new Awaitable();
                    awaiter = <a>5__1.GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 0);
                        <>u__1 = awaiter;
                        <AsyncTest>d__1 stateMachine = this;
                        <>t__builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
                        return;
                    }
                }
                else
                {
                    awaiter = (Awaiter)<>u__1;
                    <>u__1 = null;
                    num = (<>1__state = -1);
                }
                awaiter.GetResult();
                Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <a>5__1 = null;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <a>5__1 = null;
            <>t__builder.SetResult();
        }

        void IAsyncStateMachine.MoveNext()
        {
            //ILSpy generated this explicit interface implementation from .override directive in MoveNext
            this.MoveNext();
        }

        [DebuggerHidden]
        private void SetStateMachine(IAsyncStateMachine stateMachine)
        {
        }

        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            //ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
            this.SetStateMachine(stateMachine);
        }
    }

    private static void Main(string[] args)
    {
        AsyncTest();
        Console.ReadLine();
    }

    [AsyncStateMachine(typeof(<AsyncTest>d__1))]
    [DebuggerStepThrough]
    private static void AsyncTest()
    {
        <AsyncTest>d__1 stateMachine = new <AsyncTest>d__1();
        stateMachine.<>t__builder = AsyncVoidMethodBuilder.Create();
        stateMachine.<>1__state = -1;
        stateMachine.<>t__builder.Start(ref stateMachine);
    }
}

変換後のコードを見ると見慣れないクラスやC#ではエラーになってしまうような記述が現れました。コード量もかなり増えていますね。

見慣れない構文の補足

デコンパイル結果を見て分かる通り、通常のC#では見られない表記が散見されます。これについてこちらの記事(C#での非同期メソッドの分析。)ではこう解説されていました。

コンパイラはステートマシンの型名を<YourMethodNameAsync>d__1 のように生成します。 名前の衝突を避けるために、生成された名前にはユーザが定義できない無向な識別文字が含まれています。

ユーザ定義のものとかぶらないように、というのはC++をコンパイルした際にもマングリングという処理が入るので、おそらくこれと同じような理由から変換されているものと思われます。

変換の流れを概観する

詳細に入っていく前に全体の流れを概観してみましょう。C#コンパイラは await キーワードを見つけると以下のように内容を変換します。

  1. await の前後で処理を分断する
  2. CompilerGenerated なステートマシンクラスを生成する
  3. ステートマシンを開始する
  4. ステートマシンの初回は await の前半部分を実行する
  5. (4)の時点で処理が完了していたら( awaiter.IsCompleted をチェック) awaiter の完了処理( GetResult の呼び出し)をして終了
  6. 終了していない場合は、後続の処理( await の後半部分)を System.Action でラップして、 awaiter.OnCompleted(action) に渡す
  7. awaiter 側で、 await 処理が完了した際に、(6)で渡された Action を実行する(コールバックの実行)

以上の流れで処理が進みます。

awaiter が後続処理をコールバックとして呼び出す仲介役となることで処理が実現されている、というわけですね。

極論を言えば、 async / await はこうした非同期処理のシンタックスシュガーと言えます。

コードを精査する

では生成されたコードを精査していきましょう。

AsyncTestメソッドの変換

まず最初に見るのは自身で書いたコードがどうなったかを確認してみます。実際に非同期処理を書いている AsyncTest メソッドを見てみましょう。

AsyncTest
[AsyncStateMachine(typeof(<AsyncTest>d__1))]
[DebuggerStepThrough]
private static void AsyncTest()
{
    <AsyncTest>d__1 stateMachine = new <AsyncTest>d__1();
    stateMachine.<>t__builder = AsyncVoidMethodBuilder.Create();
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
}

だいぶ大きく変わっています。元のコードと見比べてもほぼ原型が残っていません。というかメソッド名以外はすべて違いますね。変換前のコードを再掲すると以下です。

元のAsyncTest
private static async void AsyncTest()
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    
    Awaitable a = new Awaitable();
    await a;
    
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
}

デコンパイルしたほうを詳しく見ていきましょう。アトリビュートは今回は大きく関係しないので補足にとどめておきます。

AsyncStateMachineAttributeの意味

Microsoftのドキュメントを見ると以下のように記述されています。

メソッドが Async または async 修飾子でマークされているかどうかを示します。

コード内のメソッドに AsyncStateMachine 属性を適用する必要はありません。 非同期修飾子を持つメソッドの場合、コンパイラはコンパイラが出力する IL に AsyncStateMachine 属性を適用します。

つまりこれは(おそらく)デコンパイルした際には async 修飾子がなくなっているために付与されているということだと思います。

DebuggerStepThroughAttributeの意味

Microsoftのドキュメントを見ると以下のように記述されています。

デバッガーに対してコードのステップ インではなくステップ実行を指示します。 このクラスは継承できません。

つまりデバッガー用ですね。なので処理としては関係していません。

asyncメソッドはステートマシンに分解される

では実際に変換された結果を見てみましょう。非同期処理は、流れのところで書いた通りステートマシンに変換され、非同期処理前後で処理が分断されます。

AsyncTest中身
<AsyncTest>d__1 stateMachine = new <AsyncTest>d__1();
stateMachine.<>t__builder = AsyncVoidMethodBuilder.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);

最初の行で生成されているのがまさにステートマシンです。やや見慣れない記述があり分かりづらいですが、 <>_ に置き換えて見てみると分かりやすくなります。

_ に置き換え
_AsyncTest_d__1 stateMachine = new _AsyncTest_d__1();
stateMachine._t__builder = AsyncVoidMethodBuilder.Create();
stateMachine._1__state = -1;
stateMachine._t__builder.Start(ref stateMachine);

前述の通り、これはユーザ定義のものとかぶることを防ぐためのものと思われます。置き換えてみるとただのインスタンス生成と初期化ということが分かりますね。

ちなみに <AsyncTest>d__1 はコンパイラが生成したステートマシンクラスです。定義を見てみると以下のようになっています。

[CompilerGenerated]
private sealed class <AsyncTest>d__1 : IAsyncStateMachine {}

初期化は以下の内容です。

  • System.Runtime.CompilerServices 名前空間に定義されている AsyncVoidMethodBuilder 構造体を生成しステートマシンのフィールドに設定
  • 初期ステートを -1 に設定

そして最後の行でステートマシンの実行を開始しています。

ステートマシンのフィールド

ではいよいよ本丸、ステートマシンについて見ていきましょう。

まずはフィールドから見ていきます。

[CompilerGenerated]
private sealed class <AsyncTest>d__1 : IAsyncStateMachine
{
    public int <>1__state;

    public AsyncVoidMethodBuilder <>t__builder;

    private Awaitable <a>5__1;

    // ... 以下略
}

見ていく中でいくつか重要なポイントがあります。まず、このステートマシンクラスのフィールドは、非同期処理の中で宣言されている変数をキャプチャするため、コンパイラによって自動的に必要な数のフィールドが宣言されます。

今回は Awaitable a = new Awaitable(); としているため、そのローカル変数 a がフィールドに追加されています。

ローカル変数のキャプチャ用フィールド
private Awaitable <a>5__1;

もしこれ以外にもローカル変数があればその分フィールドが追加されます。

このように、非同期処理内で宣言されたローカル変数はステートマシンに保持され、クラスのインスタンス変数として保持されるわけです。こうすることで関数を抜けたあともローカル変数の値が開放されず、非同期呼び出しされた際でも利用することができるのです。

ステートマシンを駆動するMoveNext

次に見ていくのはステートマシン本体そのものと言っていい、 MoveNext メソッドです。

private void MoveNext()
{
    int num = <>1__state;
    try
    {
        Awaiter awaiter;
        if (num != 0)
        {
            Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
            <a>5__1 = new Awaitable();
            awaiter = <a>5__1.GetAwaiter();
            if (!awaiter.IsCompleted)
            {
                num = (<>1__state = 0);
                <>u__1 = awaiter;
                <AsyncTest>d__1 stateMachine = this;
                <>t__builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
                return;
            }
        }
        else
        {
            awaiter = (Awaiter)<>u__1;
            <>u__1 = null;
            num = (<>1__state = -1);
        }
        awaiter.GetResult();
        Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    }
    catch (Exception exception)
    {
        <>1__state = -2;
        <a>5__1 = null;
        <>t__builder.SetException(exception);
        return;
    }
    <>1__state = -2;
    <a>5__1 = null;
    <>t__builder.SetResult();
}

MoveNext の動作をひとつひとつ見ていく前に、全体的にどう利用されているのかを説明して全体像を掴んでおきましょう。

まず、 MoveNext の名前から推測できるように「次へ進める」という意味合いがあります。では「次」とはなんでしょうか?

それは 非同期処理の続き という意味です。最初に、非同期処理は await の位置で分断されると書きました。つまり、処理が前半と後半に分かれるため、後半の処理を継続するために 続きの処理 を実行する必要があるわけです。

ループ内は必要回数分MoveNextが呼ばれる

ループ処理内に await が書かれていた場合はどうなるでしょうか。

その場合はループ変数(これもステートマシンのフィールドにキャプチャされる)をうまく使いながら、処理が終了するまでこの MoveNext を呼び出し続けることによってループの非同期処理を実現します。

つまり、前半・後半だけでなく、指定回数分 処理をする必要があることから「次へ」を表す MoveNext が使われているというわけですね。

ステートの変化

処理の詳細を見ていきましょう。

MoveNext が実行されると、まず最初に以下の部分が実行されます。

int num = <>1__state;

// ----- 中略

Awaiter awaiter;
if (num != 0)
{
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
    <a>5__1 = new Awaitable();
    awaiter = <a>5__1.GetAwaiter();
    if (!awaiter.IsCompleted)
    {
        num = (<>1__state = 0);
        <>u__1 = awaiter;
        <AsyncTest>d__1 stateMachine = this;
        <>t__builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
        return;
    }
}

理由としては、ステートマシンの初期化のところで <>1__state = -1 としていたのを思い出してください。つまりここは必ず num != 0 となり実行されることになります。

処理を見てみると確かに await の前半部分があるのが分かります。ただ一点、 Awaitable の処理に変化が生じています。以下の部分です。

GetAwaiter
<a>5__1 = new Awaitable();
awaiter = <a>5__1.GetAwaiter();

GetAwaiter などについては後半で詳しく見ていきます。ここでは awaitするためのオブジェクトを生成する処理に変換されている という事実を知ってください。

そして続く処理で IsCompleted フラグを見て、もし処理が完了していたら非同期処理の後半が即座に実行されるようになっています。

後続処理はコールバックで実行する

前述の IsCompletedfalse 、つまり処理が完了していない場合はどうなっているでしょうか。

if (!awaiter.IsCompleted)
{
    num = (<>1__state = 0);
    <>u__1 = awaiter;
    <AsyncTest>d__1 stateMachine = this;
    <>t__builder.AwaitOnCompleted(ref awaiter, ref stateMachine);
    return;
}

完了していない場合はステートを変更し( <>1__state = 0 )、いくつかの処理を経て最後は return しています。そう、実はここで処理が終了しているのです。

「ここで処理終了したら非同期処理の後半はどうするんだ?」という声が聞こえてきそうですが、安心してください。後続処理は コールバック として呼び出される仕組みになっています。

「え、どこにそんな処理が?」と思われるかもしれませんが、以下の部分がそのコールバックを登録している部分になります。

コールバックの登録
<>t__builder.AwaitOnCompleted(ref awaiter, ref stateMachine);

AwaitOnCompleted というメソッドがコールバックを登録している部分です。名前からするとイベントを発行しているかのようにも見えるこのメソッド、実は 完了時に呼び出されるメソッドを登録する という意味なのです。ちょっとした混乱ポイントなのでここはイメージをしっかり持つようにしてください。

コールバックの登録の仕組みは後半で詳細に見ていきますが、全体の流れを把握するためには次に解説する Awaiterの仕組み を知ったほうが理解が早いと思うので先にそちらを説明します。

Awaiterの仕組み

await が指定された箇所で若干コードが変更になっているというのは説明しました。コードを再掲すると以下の部分です。

GetAwaiter
<a>5__1 = new Awaitable();
awaiter = <a>5__1.GetAwaiter();

この GetAwaiter がなぜ突然出てきたのでしょうか。実は async / await の仕組みは Awaitableパターン を用いて実現されています。

Awaiterの実装はAwaitableパターン

await の対象にできるのは Awaitableパターン を実装したクラスです。この Awaitableパターン は以下のメソッド、インターフェースおよびプロパティを実装したクラスのことを指します。

Awaitableパターン実装クラス
class Awaitable
{
    public Awaiter GetAwaiter();
}

struct Awaiter : System.Runtime.CompilerServices.INotifyCompletion
{
    public bool IsCompleted { get; }
    public void OnCompleted(Action continuation);
    public T GetResult();
}

今回のサンプルの冒頭を見直してみてください。同じ実装になっていることが分かります。

ここで大事なポイントは、ダックタイピング的に実装されていればいい、という点です。

なので上記の GetAwaiter メソッドは拡張メソッドとして定義されていても問題ありません。そのため、 awaitさせたいクラス の拡張メソッドを定義することでどのクラスでも自由に await 可能になります。

Awaiterに必要な実装

await させたいクラスに対しては拡張メソッドなどで Awaiter を返す GetAwaiter メソッドの実装が必要でした。一方、返り値として使われる Awaiter はいくつかのメソッド、プロパティそしてインターフェースの実装が必要になると書きました。これの詳細を見ていきましょう。

具体的には以下のプロパティとメソッドが ダックタイピング的に 実装されている必要があります。(インターフェースなどで規定されていないという意味です)

必要なメソッドとプロパティ
public bool IsCompleted { get; }
public T GetResult();

そして INotifyCompletion インターフェースの実装が必要です。

https://docs.microsoft.com/ja-jp/dotnet/api/system.runtime.compilerservices.inotifycompletion?view=net-6.0

INotifyCompletionの実装
struct Awaiter : INotifyCompletion
{
    public void OnCompleted(Action continuation);
}

まとめると以下の要件を満たしているクラス / 構造体が Awaiter として振る舞うことができます。

  • IsCompletedプロパティ
  • GetResultメソッド
  • INotifyCompletionインターフェース

OnCompletedの振る舞い

ステートマシンの説明のところで出てきた OnCompleted の正体が INotifyCompletion インターフェースで定義されているメソッドだったんですね。

これをどう使うかを見てみましょう。

public class Awaiter : INotifyCompletion
{
    private Action _continuation;
    
    public Awaiter()
    {
        Task.Run(() =>
        {
            Thread.Sleep(2000);
            _continuation?.Invoke();
        });
    }

    public bool IsCompleted => false;
    public void GetResult() {}
    public void OnCompleted(Action continuation)
    {
        _continuation = continuation;
    }
}

今回は簡単のため、 Awaiter のコンストラクタ内でスリープを挟んでいますが、実際にはここは例えば画像ダウンロードなどの処理を非同期に行うような形になります。

またここは別スレッドや、Unityであればコルーチンなどで非同期処理を行います。そしてその処理が完了したら、OnCompleted メソッドで渡された continuation アクションを実行する、という流れです。

前述したように、このメソッドに渡されるのは 後続の処理をラップしたアクション です。つまりここで残りの部分が実行されるわけなんですね。

ここまでの処理をものすごくざっくり説明すると以下のようになります。

  1. await の前後で処理を分断する
  2. await 対象のクラスの仲介役となる AwaiterGetAwaiter メソッドによって取得する
  3. (1)で分断した後続の処理をラップしたActionを AwaiterOnCompleted に渡す
  4. Actionを渡された Awaiter は、非同期処理が終わったタイミングでそのアクションを呼び出す

これが、 Awaiter が仲介役となると書いた理由です。大枠で見ると処理自体はシンプルなことが分かるかと思います。

ここまでで非同期処理の全体像は掴めたかと思います。実際、 Awaiter が自作できればあらゆるクラスが非同期化できるようになります。

ただ、「コールバックの登録とかもっと細かいところまで知りたいよ!」という方は次の節を読んでもらうとより理解が深まると思います。そうでない方はUnityの非同期化のところまで読み飛ばしてもらっても大丈夫です。


AsyncVoidMethodBuilderの仕組み

最後に見ていくのは AsyncVoidMethodBuilder です。

.NET Frameworkの実装はGitHubで公開されているので実装を見ることができます。以下から該当箇所がどうなっているか見ていきましょう。

https://github.com/dotnet/coreclr/blob/d9732f493c359b404bd5b45117f4d211148c591a/src/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilder.cs

非同期処理が変換されたあとのコードを再掲します。これを順番に見ていきましょう。

デコンパイル結果
<AsyncTest>d__1 stateMachine = new <AsyncTest>d__1();
stateMachine.<>t__builder = AsyncVoidMethodBuilder.Create();
stateMachine.<>1__state = -1;
stateMachine.<>t__builder.Start(ref stateMachine);

Createメソッド

ステートマシンの初期化のときに AsyncVoidMethodBuilder.Create(); としてビルダーが生成されています。実装を見てみると以下のようになっています。

AsyncVoidMethodBuilder.Create
public static AsyncVoidMethodBuilder Create()
{
    SynchronizationContext sc = SynchronizationContext.Current;
    sc?.OperationStarted();
    return new AsyncVoidMethodBuilder() { _synchronizationContext = sc };
}

最初の行は実行コンテキストを保持している部分です。続く行でオーバーライドされたクラスがある場合はそれに操作開始を伝える処理をしています。ドキュメントから引用すると以下のように説明されています。

派生クラスでオーバーライドされた場合、操作の開始を伝える通知に応答します。

そして AsyncVoidMethodBuilder を新規生成して先ほどのコンテキストを保持させます。

Unityの場合のSynchronizationContext

SynchronizationContext.Current はユーザが手動で値を設定することを想定して作られているようです。とりすーぷさんのこちらの記事で以下のように解説されていました。

なぜメインスレッド以外ではnullになってしまうかというと、SynchronizationContext.Currentはユーザが手動で設定する必要がある項目だからです。
UnityメインスレッドでSynchronizationContext.Currentが使えるのは、UnityEngineが気を利かせてSynchronizationContextを設定してくれているからに過ぎません。

Unityがケアしてくれているために、await 後に処理を書いてもちゃんとメインスレッドで実行されている、というわけなんですね。

ちなみに UnitySynchronizationContext実装は公開されているので内容を見ることができます。

ビルダーのStartメソッド

初期化後にステートマシンが起動されます。起動はビルダーの Start メソッドを呼び出すことで開始されます。

stateMachine.<>t__builder.Start(ref stateMachine);

これも実装が見れるので実装部分を見ると以下のようになっています。

Startメソッドの実装
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine => 	AsyncMethodBuilderCore.Start(ref stateMachine);
MethodImpleAttributeの意味

ちなみにこの MethodImpl Attributeは関数のインライン化などの指示をコンパイラに伝える目的で利用されるようです。上記の引数( MethodImplOptions.AggressiveInlining は積極的にインライン化してほしい旨を伝えている)

実際の実装は AsyncMethodBuilderCore.Start 側にあります。この実装を見ると最適化のため、スレッドやコンテキストの状態を見て処理を切り替えていますが重要な部分は以下になります。

Core.Startメソッドの実装
public static void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
{
    // Nullチェックや
    // Thread / Contextの下準備

    try
    {
        stateMachine.MoveNext();
    }
    finally
    {
        // Thread / Contextの後処理
    }
}

ここでステートマシンの MoveNext が呼び出されているわけなんですね。ここでの MoveNext はすでに解説したように非同期処理の前半部分が実行されます。

ビルダーのAwaitOnCompletedメソッド

非同期処理の前半部分まで確認できました。後続の処理はコールバックになっていて以下の部分で登録が行われていると書きました。

<>t__builder.AwaitOnCompleted(ref awaiter, ref stateMachine);

この AwaitOnCompleted も実装が見れるので詳細を見ていきましょう。

実装を見てみると以下のようになっています。

AwaitOnCompletedの実装
public void AwaitOnCompleted<TAwaiter, TStateMachine>(
    ref TAwaiter awaiter, ref TStateMachine stateMachine)
    where TAwaiter : INotifyCompletion
    where TStateMachine : IAsyncStateMachine =>
    _builder.AwaitOnCompleted(ref awaiter, ref stateMachine);

ここの _builder はビルダー構造体の中に以下のように宣言されている構造体です。

AsyncTaskMethodBuilder構造体
private AsyncTaskMethodBuilder _builder; // mutable struct: must not be readonly

この構造体の AwaitOnCompleted を見ていきましょう。

AwaitOnCompletedの実装
public void AwaitOnCompleted<TAwaiter, TStateMachine>(
    ref TAwaiter awaiter, ref TStateMachine stateMachine)
    where TAwaiter : INotifyCompletion
    where TStateMachine : IAsyncStateMachine
{
    try
    {
        awaiter.OnCompleted(GetStateMachineBox(ref stateMachine).MoveNextAction);
    }
    catch (Exception e)
    {
        AsyncMethodBuilderCore.ThrowAsync(e, targetContext: null);
    }
}

awaiter.OnCompleted() が現れました。渡されているのは MoveNextAction です。つまり、非同期処理が終わったら再び MoveNext を呼び出して処理を継続していく、という流れがここで作られていたんですね。

ちなみにステートマシンそのものではなく StateMachineBox という型でラップしていますが内容はほぼ同じものです。このクラスが担うのはコンテキストのチェックなどを行い、適切に MoveNext を呼び出すことです。

GetStateMachineBoxメソッドの実装

GetStateMachineBox() の実装の断片は以下のようになっています。

GetStateMachineBoxの実装
private IAsyncStateMachineBox GetStateMachineBox<TStateMachine>(
    ref TStateMachine stateMachine)
    where TStateMachine : IAsyncStateMachine
{
    ExecutionContext currentContext = ExecutionContext.Capture();

    // ..... 中略

    var box = AsyncMethodBuilderCore.TrackAsyncMethodCompletion ?
        CreateDebugFinalizableAsyncStateMachineBox<TStateMachine>() :
        new AsyncStateMachineBox<TStateMachine>();
    m_task = box; // important: this must be done before storing stateMachine into box.StateMachine!
    box.StateMachine = stateMachine;
    box.Context = currentContext;
    return box;
}

以上が、async / await のステートマシンの実装でした。使う側としては非常にシンプルに使えるものが、内部ではかなり複雑に変換されているのが分かりました。

これで、冒頭の スレッドIDが異なる という理由も想像がついたのではないでしょうか。

次からは、ここで説明した Awaitableパターン を使ってUnityの機能を非同期化しながら、実際に使う際のイメージを固めていきたいと思います。


Unityの機能を async / await できるように拡張する

前段までで、非同期処理がどう変換され、どう動いているのかを解説してきました。最後は、いくつかのUnityの機能の async / await 対応を通して使える知識にしていきたいと思います。

ここでは2つの事例を紹介します。この2つは、前回の記事で書いた『XRコンテンツで厳禁の処理落ちを軽減! AsyncReadManagerで画像を非同期読み込みする』を実装する際にも利用しています。

UnityWebRequestをawaitできるように拡張する

最初に拡張するのは UnityWebRequest です。これはWebから画像をダウンロードしたりする際に利用されるAPIですね。デフォルトではこの処理はコルーチンを使って待たないとなりません。そのため、ダウンロード処理と後続する処理が分断されてしまうためコードが煩雑になっていまうというデメリットがあります。

これを await 可能にすることですっきりとしたコードを書けるようにするのが目的です。ちなみにこれを実装すると以下のように分かりやすいコードを書くことができるようになります。

Demo
using UnityWebRequest request = UnityWebRequestTexture.GetTexture(path);

await request.SendWebRequest();

Texture2D texture = DownloadHandlerTexture.GetContent(request);

これを実現するために、 UnityWebRequestAsyncOperationawaitable にしていきましょう。

UnityWebRequestExtension.cs
using System;
using System.Collections;
using System.Runtime.CompilerServices;
using UnityEngine.Networking;

namespace AsyncReader.Utility
{
    public static class UnityWebRequestExtension
    {
        public static UnityWebRequestAwaitable GetAwaiter(this UnityWebRequestAsyncOperation operation)
        {
            return new UnityWebRequestAwaitable(operation);
        }

        public class UnityWebRequestAwaitable : INotifyCompletion
        {
            private UnityWebRequestAsyncOperation _operation;
            private Action _continuation;

            public UnityWebRequestAwaitable(UnityWebRequestAsyncOperation operation)
            {
                _operation = operation;
                CoroutineDispatcher.Instance.Dispatch(CheckLoop());
            }

            public bool IsCompleted => _operation.isDone;
            public void OnCompleted(Action continuation) => _continuation = continuation;

            public void GetResult() { }

            private IEnumerator CheckLoop()
            {
                yield return _operation;
                _continuation?.Invoke();
            }
        }
    }
}

AsyncReadManagerをawaitできるように拡張する

次に紹介するのは AsyncReadManager を使ったファイル読み込み機能を await 可能にする方法です。このAPIはそもそも非同期でファイルを読み込むAPIですが、呼び出し後はコールバックではなく、呼び出し時に返されるハンドル( ReadHandle )を使って読み込みが完了しているかを都度チェックしないとなりません。

ただこれはメインスレッド外でも利用できるため、非同期読み込みを実現し、さらにそれを await 可能なクラスを実装することで利用することを考えます。

まずは全体のコードを見てみましょう。

AsyncFileReader.cs
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.IO.LowLevel.Unsafe;
using UnityEngine;

namespace AsyncReader
{
    public class AsyncFileReader : IDisposable
    {
        private class Awaitable : IDisposable
        {
            private Thread _thread;
            private ReadHandle _handle;
            private TaskCompletionSource<bool> _completionSource;
            private bool _success = false;
            private bool _stopped = false;

            public Awaitable(ReadHandle handle)
            {
                _handle = handle;

                _thread = new Thread(CheckLoop)
                {
                    IsBackground = true
                };
            }

            ~Awaitable()
            {
                Dispose();
            }

            private void CheckLoop()
            {
                while (true)
                {
                    if (_stopped) return;
                    
                    if (_handle.Status == ReadStatus.InProgress)
                    {
                        Thread.Sleep(16);
                        continue;
                    }
                    
                    if (_stopped) return;

                    _success = _handle.Status == ReadStatus.Complete;
                    break;
                }

                _completionSource?.TrySetResult(_success);
            }

            public TaskAwaiter<bool> GetAwaiter()
            {
                _completionSource = new TaskCompletionSource<bool>();
                
                _thread.Start();

                return _completionSource.Task.GetAwaiter();
            }

            public void Dispose()
            {
                if (_stopped) return;
                
                if (_thread is { IsAlive: false }) return;
                
                _stopped = true;
                
                _thread.Abort();
            }
        }

        private ReadHandle _readHandle;
        private NativeArray<ReadCommand> _readCommands;
        private long _fileSize;

        public unsafe void Dispose()
        {
            _readHandle.Dispose();

            UnsafeUtility.Free(_readCommands[0].Buffer, Allocator.TempJob);
            _readCommands.Dispose();
        }

        public async Task<(IntPtr, long)> LoadAsync(string filePath)
        {
            UnsafeLoad(filePath);

            Awaitable awaitable = new Awaitable(_readHandle);
            await awaitable;
            
            awaitable.Dispose();

            IntPtr ptr = GetPointer();

            return (ptr, _fileSize);
        }

        private unsafe void UnsafeLoad(string filePath)
        {
            FileInfo info = new FileInfo(filePath);
            _fileSize = info.Length;

            _readCommands = new NativeArray<ReadCommand>(1, Allocator.TempJob);
            _readCommands[0] = new ReadCommand
            {
                Offset = 0,
                Size = _fileSize,
                Buffer = (byte*)UnsafeUtility.Malloc(_fileSize, UnsafeUtility.AlignOf<byte>(), Allocator.TempJob),
            };

            _readHandle = AsyncReadManager.Read(filePath, (ReadCommand*)_readCommands.GetUnsafePtr(), 1);
        }

        private unsafe IntPtr GetPointer()
        {
            return (IntPtr)_readCommands[0].Buffer;
        }
    }
}

await 可能にしているのは AsyncFileReader.Awaitable クラスの実装です。詳しく見ていきましょう。

AsyncFileReader.Awaitable
private class Awaitable : IDisposable
{
    private Thread _thread;
    private ReadHandle _handle;
    private TaskCompletionSource<bool> _completionSource;
    private bool _success = false;
    private bool _stopped = false;

    public Awaitable(ReadHandle handle)
    {
        _handle = handle;

        _thread = new Thread(CheckLoop)
        {
            IsBackground = true
        };
    }

    ~Awaitable()
    {
        Dispose();
    }

    private void CheckLoop()
    {
        while (true)
        {
            if (_stopped) return;
            
            if (_handle.Status == ReadStatus.InProgress)
            {
                Thread.Sleep(16);
                continue;
            }
            
            if (_stopped) return;

            _success = _handle.Status == ReadStatus.Complete;
            break;
        }

        _completionSource?.TrySetResult(_success);
    }

    public TaskAwaiter<bool> GetAwaiter()
    {
        _completionSource = new TaskCompletionSource<bool>();
        
        _thread.Start();

        return _completionSource.Task.GetAwaiter();
    }

    public void Dispose()
    {
        if (_stopped) return;
        
        if (_thread is { IsAlive: false }) return;
        
        _stopped = true;
        
        _thread.Abort();
    }
}

こちらの実装ではAwaiterは System.Runtime.CompilerServices 名前空間にある TaskAwaiter<T> を利用しています。今回の例のようにたんに待つだけのような場合はこれを利用するのが手軽だと思います。

GetAwaiter メソッドの戻り値を TaskAwaiter<T> にし、内部的には TaskCompletionSource<T>Task.GetAwaiter を呼び出しています。あとは読み込み完了の状態をチェックし、読み込みが完了していたら TaskCompletionSource.TrySetResult を呼び出してあげれば非同期処理後半の部分が実行されるようになります。

スレッドを起動して読み込み完了を待つ

今回の実装ではシンプルに保つために、 Awaitable クラスが生成された際にスレッドを起動し、 GetAwaiter のタイミングでスレッドをスタートするようにしています。そしてそのスレッド内で 16ms 間隔で状態をチェックし、読み込みが完了していたら処理を先に進めるという実装になっています。

最後に

async / await の旅を終えていかがでしょうか。普段何気なく使っている機能も、深掘りしていくと色々な発見があります。特に今回、コンパイラが自動生成したクラス名などに <> が使われていることを知り、Unityのログでたまに見かけるこの記号の意味を納得感を持って理解することができました。

全然関係ないところに答えが転がっていたりして、プログラムは本当に面白いですね。

今回紹介したサービスは非同期処理以外の処理を知るのにも役立ちます。(例えば => を用いたシンプルなプロパティも、デコンパイルすると通常の形に戻っていたり)

興味がある実装があったらぜひ色々深ぼってみてください。

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XRのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

MESONのメンバーページからご応募いただくか、TwitterのDMなどでご連絡ください。

書いた人

えど

比留間 和也(あだな:えど)

カヤック時代にWEBエンジニアとしてリーダーを務め、その後VRに出会いコロプラに転職。 コロプラでは仮想現実チームにてXRコンテンツ開発に携わる。 DAYDREAM向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでもAR/VRの開発をしており、インディー部門でTGSに出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。

GitHub / Twitter

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

Discussion