xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

非同期メソッド入門 (7) - 内部実装を覗く

非同期メソッドはまるで魔法のようです。非同期処理が同期処理とほとんど同じ書き方ができるなんて、もはや革命です。「今まで非同期処理のコーディングに充ててきた時間をすべて返してほしい」とさえ言いたくなるくらいです。けれどそんな素敵な非同期メソッドも、何かカラクリがあってこのような機能になっているはずです。今回はこれを少し紐解いてみましょう。

アプローチ

調査するにもキッカケが何もないとアレなので、以下のような仮定を立てて話を進めます。

  • async/awaitは何かの糖衣構文で、実際はコンパイラがもっと複雑な形に変換している
  • exeやdllには変換後の形で格納されている

C#/VB.NETなどの.NET Framework上で動作する言語は、コンパイルすると中間言語(IL)という形に変換されexeやdllに格納されます。(都合の良いことに)世の中にはexeやdllからILを取り出し、逆コンパイルしてC#/VB.NETなどのコードに変えてしまうツールがあります。async/await構文も当然ILに変換されるので、それを逆コンパイルすることでコンパイラによってどのように展開されたのかを覗いてみることにします。今回はILSpyというオープンソースのツールを利用してみます。

お題

逆コンパイルは以下のサンプルコードを使ってやってみます。「級数展開などを利用して長時間かけて円周率を計算したことにし、半径を与えて球の体積を求める」という、非常にテキトーな思い付きのサンプルです。

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

namespace DecompileSample
{
    class Program
    {
        static void Main()
        {
            Console.WriteLine(CalculateSphereVolumeAsync(1).Result);
            Console.ReadKey();
        }

        static async Task<double> CalculateSphereVolumeAsync(double radius)
        {
            var π = await Task.Run(() =>
            {
                Thread.Sleep(3000);
                return Math.PI;   //--- 頑張って円周率を計算したとする
            });
            return 4 * π * Math.Pow(radius, 3) / 3;
        }
    }
}

呼び出し側が結果の取得を待機するため非同期に計算する旨みなんてコレっぽっちもないですが、その点は目をつぶってください。

逆コンパイル結果

逆コンパイルしてみると次のように展開されます。実際は変数名やクラス名がだいぶ気持ち悪い形で出てくるのですが、整形すると大体こんな感じになります。

using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

namespace DecompileSample
{
    internal class Program
    {
        [CompilerGenerated]  //--- ↓↓ コンパイラが勝手に作る秘密の構造体
        [StructLayout(LayoutKind.Auto)]
        private struct StateMachine : IAsyncStateMachine
        {
            public int state;
            public AsyncTaskMethodBuilder<double> builder;
            public double radius;
            public double π;
            private TaskAwaiter<double> awaiter;

            public void MoveNext()
            {
                double result;
                try
                {
                    int num = this.state;
                    TaskAwaiter<double> taskAwaiter;
                    if (num != 0)
                    {
                        taskAwaiter = Task.Run<double>(() =>
                        {
                            Thread.Sleep(3000);
                            return 3.1415926535897931;
                        }).GetAwaiter();              //--- GetAwaiterメソッドでawaiterを取得
                        if (!taskAwaiter.IsCompleted) //--- awaiterのIsCompletedプロパティで完了判定
                        {
                            this.state   = 0;  //--- 次回は状態0から始めるように設定
                            this.awaiter = taskAwaiter;
                            this.builder.AwaitUnsafeOnCompleted<TaskAwaiter<double>, StateMachine>(ref taskAwaiter, ref this);
                            return;   //--- ↑↑で継続処理を登録 (= このMoveNextを再度呼び出すように) して関数から抜ける
                        }
                    }
                    else
                    {
                        taskAwaiter  = this.awaiter;
                        this.awaiter = default(TaskAwaiter<double>);
                        this.state   = -1;
                    }
                    this.π      = taskAwaiter.GetResult();  //--- awaiterのGetResultメソッドで結果を取得
                    taskAwaiter = default(TaskAwaiter<double>);
                    result      = 4 * this.π * Math.Pow(this.radius, 3) / 3;
                }
                catch (Exception exception)
                {
                    this.state = -2;
                    this.builder.SetException(exception);   //--- 受けた例外はbuilder経由で返却
                    return;
                }
                this.state = -2;
                this.builder.SetResult(result);  //--- 結果もbuilder経由で返却
            }


            [DebuggerHidden]
            public void SetStateMachine(IAsyncStateMachine machine)
            {
                this.builder.SetStateMachine(machine);
            }
        }


        private static void Main()
        {
            Console.WriteLine(CalculateSphereVolumeAsync(1).Result);
            Console.ReadKey();
        }


        [DebuggerStepThrough, AsyncStateMachine(typeof(StateMachine))]
        private static Task<double> CalculateSphereVolumeAsync(double radius)
        {
            var machine     = new StateMachine();
            machine.radius  = radius;
            machine.builder = AsyncTaskMethodBuilder<double>.Create();
            machine.state   = -1;
            machine.builder.Start<StateMachine>(ref machine);
            return machine.builder.Task;
        }
    }
}

「あんなに短かったコードがこんなに長くなるなんて...」と多少衝撃を受けるくらいの展開のされっぷりです。そしてここで最も重要なのはStateMachineというコンパイラによって生成される秘密の構造体です。これが、非同期メソッドの状態や処理の実行/継続を管理します。

内部実装のエッセンス

とは言えいきなり展開されたコードを見ても何もわからないので、簡単にポイントを押さえておきましょう。大事なのはawaitの部分だけなので、そこだけピックアップしてみます。アレコレそぎ落として色々無視してエッセンスだけ抽出すると大体以下のような感じになります。

var result = await Task<T>.Run(() => default(T));
private struct StateMachine<T> : IAsyncStateMachine //--- こっそり構造体を生成
{
    public int state = 0;           //--- 進捗状態の保持
    public T result;                //--- ローカル変数もメンバーとして保持
    private TaskAwaiter<T> awaiter; //--- awaiterの保持

    public void MoveNext()
    {
        if (this.state == 0) //--- 状態に合わせて処理を分岐
        {
            //--- ここにawaitより前の処理が入る
            this.awaiter = Task<T>.Run(() => default(T)).GetAwaiter();
            if (!this.awaiter.IsCompleted) //--- 未完の場合は継続として登録
            {
                this.state = 1;                          //--- 状態を変更
                this.awaiter.OnCompleted(this.MoveNext); //--- 自身を再登録
                return;
            }
        }
        this.result = this.awaiter.GetResult();
        //--- ここにawait以降の処理や結果を外部に返すための処理が入る
    }

    public void SetStateMachine(IAsyncStateMachine machine){} //--- 省略
}

状態機械の自動生成

1行目を見てお分かりのように、IAsyncStateMachineインターフェースを実装するStateMachine構造体がコッソリと生成されます。非同期メソッドの状態や処理の実行/継続を管理する役目を担います。IAsyncStateMachineインターフェースはMoveNextメソッドとSetStateMachineメソッドの実装を義務付けますが、重要な処理は全てMoveNextメソッドにあります。そして、そこで利用される変数は構造体のフィールドとして保持されます。

進捗状態の管理

MoveNextメソッドを見ていくと、まず9行目でstate変数の値で分岐しているのが分かります。このstate変数は非同期メソッド内の処理がどれほど進捗したかを表します。コンパイラが適切なタイミングで値の設定(=進捗状態の変更)を行い、それに従って次の動作を決めます。

Awaiterの取得

12行目を見るとawaitに渡した処理が見つかると思います。そしてその処理にはGetAwaiterというメソッドが付加され、TaskAwaiterが取得されています。どうやらawaitに処理渡すとGetAwaiterメソッドが追加されるようです。取得されたTaskAwaiterは構造体のフィールドに保持されます。

完了判定

13行目では、TaskAwaiterのIsCompletedプロパティを利用して開始した非同期処理が完了しているかどうかを判定しています。完了していない場合は残りの処理を継続処理として登録する動きに入り、完了している場合は続きの処理をそのまま実行するように遷移します。つまり、非同期処理をしているとは言っても、早く処理が終わった場合は同期処理と同じ動きをするということです。

継続処理の登録

非同期処理が完了していない場合は、以降の処理を非同期処理が終わった後に呼び出されるように登録しなければなりません。この処理を行っているのが16行目です。TaskAwaiterのOnCompletedメソッドを使って、完了後に自分自身(=MoveNextメソッド)を再度呼び出すように指示しています。しかしこのとき、何もせずにそのままMoveNextメソッドが再度呼び出されると同じ処理が繰り返されてしまいます。それを防ぐために15行目でstate変数の値を変え、次回呼び出されたときに続きの処理が実行されるようにしています。OnCompletedメソッドでの継続処理の登録の後はすぐに関数から抜け、非同期処理が完了するまで呼び出し元スレッドを解放します。

結果の取得

20行目では、TaskAwaiterのGetResultメソッドを利用して非同期処理の戻り値を受けています。戻り値をローカル変数として受けるコードは、実は構造体のフィールドとして受けるコードに変換されることも分かります。このようにローカル変数が構造体のフィールドとなるのは、継続処理として再度呼び出されたときにも先程の値を利用できるようにするためです。

再度逆コンパイル結果を見てみる

ここまでで非同期メソッドがどのように動いているかの外観が見えたのではないかと思います。ということで、ここでもう一度最初の逆コンパイル結果を眺めてみましょう。

非同期メソッドとなっていた関数の中にAsyncTaskMethodBuilder<T>というのが出てきていますが、これはStateMachineを利用した非同期メソッドを実行したり、結果を受け取ったりするための入り口になっているだけだと思えばOKです。StateMachineのMoveNextメソッドの中では、継続処理の登録にAsyncTaskMethodBuilder<T>のAwaitUnsafeOnCompletedメソッドが利用されています。これも中ではTaskAwaiterのOnCompletedメソッドが呼び出され、MoveNextメソッドが登録されていると読み換えれば大体OKです。その他例外処理だったり、TaskAwaiterをローカル変数に持ったり、構造体のフィールドに入れたりなどありますが、その辺りは「そんなもんなんだろう」とサラリと流すことすれば... ほら、理解できましたよね?(笑)

まとめ

魔法のような非同期メソッドですが、中身を紐解いてみると非常に巧くできていることがわかります。この発想ができ、かつ実際に実装までしてしまったMicrosoftのエンジニアさんたち。僕はこれを知って本当に凄いんだなー/賢いんだなーと感激しました!