【C#】async/awaitの内部展開を見る

C#
このエントリーをはてなブックマークに追加

過去少し記事で触れた程度のasync/awaitですが、実は卒論の研究対象にするぐらいには大学時代からC#の沼に浸かっていたのですが、たまには内部展開をじっくり観察しとこうかなということで書いておきます。

↓いちおう過去に触れた記事

【C#】async/awaitの挙動をTask-Likeとawaitableパターンで変えてみる

卒論でそういうことをやったので書いておきます。(卒論で作ったプログラム)

blog.meilcli.net

【C#】async/awaitの挙動をTask-Likeとawaitableパターンで変えてみる
Go to 【C#】async/awaitの挙動をTask-Likeとawaitableパターンで変えてみる

【C#】IObservable<T>の最初のイベントだけをawaitで待機する話

唐突ですが、最近やっと仕事でもXamarin.Androidを使い始めました。

blog.meilcli.net

【C#】IObservable<T>の最初のイベントだけをawaitで待機する話
Go to 【C#】IObservable<T>の最初のイベントだけをawaitで待機する話

環境

大雑把に言うと現時点で見える最新のコードという感じですかね

コード

sharplabでの表示↓

SharpLab

C#/VB/F# compiler playground.

sharplab.io

Go to SharpLab

この記事書くときに気づきましたが、sharplabってgistのコードを参照できるんですね
https://sharplab.io/#gist:4b64119d1dfe42277464d3266b4e4b15
gist id?を#gist:で繋げたらgistのコードを参照してくれるようになるみたい

sharplabでのコンパイル結果:

using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Permissions;
using System.Threading.Tasks;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[assembly: AssemblyVersion("0.0.0.0")]
[module: UnverifiableCode]
public class Program
{
    [Serializable]
    [CompilerGenerated]
    private sealed class <>c
    {
        public static readonly <>c <>9 = new <>c();

        public static Func<int> <>9__0_0;

        internal int <MethodAsync>b__0_0()
        {
            return 2;
        }
    }

    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <MethodAsync>d__0 : IAsyncStateMachine
    {
        public int <>1__state;

        public AsyncTaskMethodBuilder <>t__builder;

        private TaskAwaiter <>u__1;

        private TaskAwaiter<int> <>u__2;

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                TaskAwaiter awaiter2;
                TaskAwaiter<int> awaiter;
                switch (num)
                {
                    default:
                        Console.WriteLine(0);
                        awaiter2 = Task.Delay(1000).GetAwaiter();
                        if (!awaiter2.IsCompleted)
                        {
                            num = (<>1__state = 0);
                            <>u__1 = awaiter2;
                            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                            return;
                        }
                        goto IL_006f;
                    case 0:
                        awaiter2 = <>u__1;
                        <>u__1 = default(TaskAwaiter);
                        num = (<>1__state = -1);
                        goto IL_006f;
                    case 1:
                        {
                            awaiter = <>u__2;
                            <>u__2 = default(TaskAwaiter<int>);
                            num = (<>1__state = -1);
                            break;
                        }
                        IL_006f:
                        awaiter2.GetResult();
                        Console.WriteLine(1);
                        awaiter = Task.Run(<>c.<>9__0_0 ?? (<>c.<>9__0_0 = <>c.<>9.<MethodAsync>b__0_0)).GetAwaiter();
                        if (!awaiter.IsCompleted)
                        {
                            num = (<>1__state = 1);
                            <>u__2 = awaiter;
                            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                            return;
                        }
                        break;
                }
                Console.WriteLine(awaiter.GetResult());
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <>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)
        {
            <>t__builder.SetStateMachine(stateMachine);
        }

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

    [AsyncStateMachine(typeof(<MethodAsync>d__0))]
    public Task MethodAsync()
    {
        <MethodAsync>d__0 stateMachine = default(<MethodAsync>d__0);
        stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
        stateMachine.<>1__state = -1;
        AsyncTaskMethodBuilder <>t__builder = stateMachine.<>t__builder;
        <>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }
}

じっくり見ていこう

MethodAsyncがコンパイラーによって展開されると<>c<MethodAsync>d__0という型が生成されていることがわかります。 <>cTask.Run(() => 2)のラムダが展開されたクラスで今回の本題からは外れます。<MethodAsync>d__0のほうが本題の展開された状態機械の構造体となります。

展開後のMethodAsyncの最初の数行は状態機械の準備部分となります。

<MethodAsync>d__0 stateMachine = default(<MethodAsync>d__0);
stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
stateMachine.<>1__state = -1;
AsyncTaskMethodBuilder <>t__builder = stateMachine.<>t__builder;

ここの部分ですね。ちなみにAsyncTaskMethodBuilder.Create()は単にdefault(AsyncTaskMethodBuilder)を返すだけのようです: ソース

<>t__builder.Start(ref stateMachine);

この行によって状態機械が開始されるので見ていきましょう。

public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine =>
    AsyncMethodBuilderCore.Start(ref stateMachine);

このようにAsyncTaskMethodBuilder.Start()ではAsyncMethodBuilderCore.Startを叩いてるだけです: ソース

さがすと同じファイルにあって、スレッド管理などを除けば単に以下のコードが核心部分です: ソース

try
{
    stateMachine.MoveNext();
}
finally
// 以下いろいろ

つまり、引数のstateMachine.MoveNextを呼び出していることになります。

そこで、引数となっているコンパイラー生成の状態機械のMoveNextを見に行くことにします。(ここで、状態機械初期化時に<>1__state = -1がされてることを留意)

MoveNextは長いので切り離してみていきましょう:

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                TaskAwaiter awaiter2;
                TaskAwaiter<int> awaiter;
                switch (num)
                {

<>1__stateによって状態分岐が行われます。switch(num)では現在の状態の-1に該当する分岐がないため、defalt分岐に行きます。

                    default:
                        Console.WriteLine(0);
                        awaiter2 = Task.Delay(1000).GetAwaiter();
                        if (!awaiter2.IsCompleted)
                        {
                            num = (<>1__state = 0);
                            <>u__1 = awaiter2;
                            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
                            return;
                        }
                        goto IL_006f;

ここで、await Task.Delay(1000)までの処理が現れました。Task.Delayをawaitしているため、展開後はGetAwaiter()で待機処理が挟まれています。いちおう解説しておくと、if(!awaiter2.IsCompleted)の時点で処理が完了していたら、待機処理せずに終了処理のほうへgoto IL_006f;でジャンプします。

もちろんのことながら、処理は完了しているわけもなく、待機処理へ突入します。(ここで状態が0に更新されます)
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);AsyncTaskMethodBuilder.AwaitUnsafeOnCompletedをTask.DelayのAwaiterとthisが引数として呼び出されています。

AsyncTaskMethodBuilder.AwaitUnsafeOnCompletedではAsyncTaskMethodBuilder<VoidTaskResult>.AwaitUnsafeOnCompletedに処理を委譲しています: ソース

AsyncTaskMethodBuilder<VoidTaskResult>.AwaitUnsafeOnCompletedでは、いろいろと最適化処理があるようですが、関係ある部分だけを抜き出すとこのような感じです:

IAsyncStateMachineBox box = GetStateMachineBox(ref stateMachine);
try
{
    awaiter.UnsafeOnCompleted(box.MoveNextAction);
}

GetStateMachineBoxソースはこちらですが、内容をざっぱりみると、ExecutionContextの指定通りのスレッドで状態機械のMoveNextを実行するメソッドを提供しているようです。(ここは要検証かも)
そのためbox.MoveNextActionでスレッドをまたいだMoveNextのAction型を意味していて、それをawaiter.UnsafeOnCompletedに渡しています。

awaiterTaskAwaiter型なのでソースを見に行くと、UnsafeOnCompletedは内部でOnCompletedInternalを呼び出していて、そこではTask.SetContinuationForAwaitを呼び出しています: ソース

これ以上はソースが深すぎて読み切れないので、Taskの処理が終了後にTask.SetContinuationForAwaitで渡したbox.MoveNextActionが呼び出されるのだろうという仮定のもと話を進めます。(メソッド名的にもさすがにそれ以外のことはしてないと思うし、思いたい)

そこで、状態が0にされていたことを思い出しながら、MoveNextメソッドに目を戻してやりましょう。

                    case 0:
                        awaiter2 = <>u__1;
                        <>u__1 = default(TaskAwaiter);
                        num = (<>1__state = -1);
                        goto IL_006f;

次の分岐はこのようになりますが、状態は-1に更新されつつもIL_006fにジャンプします。

                    case 1:
                        {
                            awaiter = <>u__2;
                            <>u__2 = default(TaskAwaiter<int>);
                            num = (<>1__state = -1);
                            break;
                        }
                        IL_006f:
                        awaiter2.GetResult();
                        Console.WriteLine(1);
                        awaiter = Task.Run(<>c.<>9__0_0 ?? (<>c.<>9__0_0 = <>c.<>9.<MethodAsync>b__0_0)).GetAwaiter();
                        if (!awaiter.IsCompleted)
                        {
                            num = (<>1__state = 1);
                            <>u__2 = awaiter;
                            <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
                            return;
                        }
                        break;

IL_006fの分岐のちょっと前から見ると状態が1の分岐であることがわかります。

やっとawait Task.Delay(1000)の後のConsole.WriteLine(1)が見えてきました。そのあとのawait Task.Run(() => 2)も同じような記述になっており、if(!awaiter.IsCompleted)のとこまでに処理が完了してなかったら、状態を1にして、また同じようなコードの深みに潜っていきます。

処理が完了していたらbreakでこのswitchを抜けますし、完了していなくても次のMoveNextcase 1にたどり着いて(たぶん)、状態を-1に更新してbreakされます。
やっと終わりが見えてきた気がする。

                Console.WriteLine(awaiter.GetResult());
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <>t__builder.SetResult();
        }

switchを抜けたら最後のConsole.WriteLine(result)の部分が現れます。てか、ここtry文の中だったんですね、catch(Exception exception)で思い出し()

何はともあれ、例外があればAsyncTaskMethodBuilder.SetExceptionが呼び出され、無事終了したらAsyncTaskMethodBuilder.SetResultが呼び出されるところまで来ました。
SetResultSetExceptionともに、AsyncTaskMethodBuilder<VoidTaskResult>を呼び出しています。

さすがに力尽きたので、説明を省くと、SetResultではResultと一致するキャッシュされたTaskを返すか、新しくResultの値が設定されたTaskを生成して返しています。SetExceptionでは文字通りTaskに対してExceptionを設定しています。

所感

ざっくりasync/awaitを見てきましたが、見たものは返り値がTaskなasyncメソッドで生成されたものです。C# 7.0でTask-Likeな型ならasyncメソッドの返り値にできるようになりました。Task-Likeでも同様な動作になりますが、部分的にはTask-Like実装者の好きなように動作を組み立てることができます。

まぁそれが大学の卒論の研究対象になったんだけどね。

GitHub - MeilCli/DistributedTask

Contribute to MeilCli/DistributedTask development by creating an account on GitHub.

github.com

GitHub - MeilCli/DistributedTask
Go to GitHub - MeilCli/DistributedTask

Task-Like実装するならAsyncTaskMethodBuilderを再利用したほうが使い勝手がいい雰囲気は感じられただろうと思います、またAwaitableパターンもこのあたりのコンパイラーによる展開後のコードがわかれば実装しやすいと思います。

言い忘れてましたが、コンパイラーのバージョンとかでこのあたりの展開結果は変わると思いますのであしからず

あとIValueTaskSourceとかいろいろと最適化のコードが追加されているようですが、そっちのあたりはまだまだ追えてません………