LoginSignup
8
5

More than 5 years have passed since last update.

[C#]async/awaitを使って継続の力を手に入れる

Posted at

async/await は非同期処理を同期処理のように書ける機能です。
しかし、async/awaitにはそれ以上のポテンシャルがあります。
それが 継続の取得 です。
AsyncMethodBuilderasync/awaitを併用することで 継続の取得を行うことができます。

今回は継続の取得悪用使用する拙作ライブラリAsync.Chaosの紹介と解説をします。

yaegaki/Async.Chaos

注意

実用的な内容ではないネタ記事です。
こんなこともできるのか、くらいの感じで読んでください。
async/awaitに自信がある方は解説を読む前にどのようにして実現しているのかを考えてみるのも面白いかもしれません。
コンパイラの暗黙の挙動に頼っている部分もあるのでバージョンが変わると動かない可能性があります。

使用例

Async.Chaosの使用例です。

再帰処理

一番シンプルな使用例です。
ChaosTask.Continuationで継続を取得して再帰的に呼び出します。

// 最大公約数を計算する
static async ChaosTask<int> gcd(int _a, int _b)
{
    // 引数と継続を取得する
    var (a, b, continuation) = await ChaosTask.Continuation<int, int, int>(_a, _b);

    if (a % b == 0)
    {
        return b;
    }

    // 取得した継続で再帰する
    return await continuation(b, a % b);
}
Console.WriteLine(await gcd(824, 128)); // => 8

チェックポイント

ChaosTask.Checkpointでコードにチェックポイントを作成し、任意の場合からチェックポイントに戻ります。

// 指定された長さのリストを取得する
// 内容は0~lenまでの数値がランダムな順番にかぶりなしで入っている
static async ChaosTask<List<int>> GetNumbersInRandomOrder(int len)
{
    var r = new Random();
    var result = new List<int>(len);
    // チェックポイントの作成
    var checkpoint = await ChaosTask.Checkpoint<List<int>>();

    // リストの構築が終わっていない場合
    if (result.Count < len)
    {
        // ランダムな値取得
        var value = r.Next(0, len);
        // 値が重複しないように存在しない場合だけ追加する
        if (!result.Contains(value))
        {
            result.Add(value);
        }

        // チェックポイントに戻る
        await checkpoint();
    }

    return result;
}

var list = await GetNumbersInRandomOrder(100);
Console.WriteLine(string.Join(",", list));  // => 27,54,70,4,7,28,21,93,32,69,83,45,22,...

Concurrent(Simple)

ChaosTask.Concurrentで継続を並行で実行します。
同時に実行されるわけではなく常に順番に実行されます。

static async ChaosTask<ChaosUnit> SimpleConcurrentTest()
{
    // 並行で継続を実行する
    // 継続は親、子の二回実行される
    // 親か子かはConcurrentの戻り値で判定することができる
    if (await ChaosTask.Concurrent<ChaosUnit>())
    {
        Console.WriteLine("Parent.");
    }
    else
    {
        Console.WriteLine("Child.");
    }
    return default(ChaosUnit);
}
await SimpleConcurrentTest();
/* =>
Parent.
Child.
*/

Concurrent(EchoServer)

ChaosTask.Concurrentは処理を並行で実行するため、一つのタスクが処理をブロックするとほかのタスクも影響を受けます。
ChaosTask.YieldChaosTask.WaitTaskChaosTask.WaitNextを使用することで別タスクの処理を行います。

static async ChaosTask<string> StartEchoServer()
{
    Console.WriteLine("Start echo server.");
    Console.WriteLine("if you want to exit, Type 'end'.");
    // 値型はタスクごとにコピーが作られるので参照型でラップする
    var finished = ChaosBox.Create(false);
    var message = string.Empty;
    // 並行タスク開始
    var isParent = await ChaosTask.Concurrent<string>();

    // 親はメッセージを受け付ける
    if (isParent)
    {
        while (!finished.Value)
        {
            // 他のタスクが待機状態になるまで待つ
            await ChaosTask.WaitNext<string>();
            if (!string.IsNullOrEmpty(message))
            {
                if (message == "end")
                {
                    Console.WriteLine("byebye");
                    finished.Value = true;
                }
                else
                {
                    Console.WriteLine($"your message:{message}");
                }
            }
        }

        // 他のタスクの終了を待つ
        await ChaosTask.Yield<string>();
        return message;
    }
    // 子はメッセージを送る
    else
    {
        while (!finished.Value)
        {
            async ChaosTask<ChaosUnit> sendMessage()
            {
                await Task.Run(() =>
                {
                    message = Console.ReadLine();
                });
                return default(ChaosUnit);
            }

            // タスクの実行が終わってから並行処理を再開する
            await ChaosTask.WaitTask<string>(sendMessage());
        }
        return string.Empty;
    }
}
await StartEchoServer();
/* =>
Start echo server.
if you want to exit, Type 'end'.
hoge
your message:hoge
fuga
your message:fuga
piyo
your message:piyo
end
byebye
*/

解説

基本的なコンセプトは継続の取得と実行です。
Awaiterを使うとOnCompletedで継続を取得できますがここで取得できる継続はそのままでは再利用できません。

class Awaiter
{
    /*
       省略...
    */
    public void OnCompleted(Action continuation)
    {
        // continuation がawait以降の継続を表すアクション
    }
}

再利用できない理由はここで取得できる継続がステートを持っているからです。
一回呼ぶと継続の内部ステートが変更されてしまうのでもう一度呼んでも違う結果になります。

なので、ステートの保存と復元ができるなら何度でも使用できる継続を取得することができます。
ではこのOnCompletedで渡される継続はどこから来たものでしょうか。
答えはコンパイラが自動生成したステートマシンのMoveNextです。

以下の例を見てください。

static async Task Hoge()
{
    var i = 0;
    await Task.Delay(100);
    Console.WriteLine(i++);
    await Task.Delay(100);
    Console.WriteLine(i++);
}

この関数からは以下のステートマシンが生成されます。

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

    public AsyncTaskMethodBuilder <>t__builder;

    private int <i>5__1;

    private TaskAwaiter <>u__1;

    void IAsyncStateMachine.MoveNext()
    {
        int num = this.<>1__state;
        try
        {
            TaskAwaiter awaiter;
            TaskAwaiter awaiter2;
            if (num != 0)
            {
                if (num == 1)
                {
                    awaiter = this.<>u__1;
                    this.<>u__1 = default(TaskAwaiter);
                    this.<>1__state = -1;
                    goto IL_F2;
                }
                this.<i>5__1 = 0;
                awaiter2 = Task.Delay(100).GetAwaiter();
                if (!awaiter2.IsCompleted)
                {
                    this.<>1__state = 0;
                    this.<>u__1 = awaiter2;
                    Program.<Hoge>d__1 <Hoge>d__ = this;
                    this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, Program.<Hoge>d__1>(ref awaiter2, ref <Hoge>d__);
                    return;
                }
            }
            else
            {
                awaiter2 = this.<>u__1;
                this.<>u__1 = default(TaskAwaiter);
                this.<>1__state = -1;
            }
            awaiter2.GetResult();
            int num2 = this.<i>5__1;
            this.<i>5__1 = num2 + 1;
            Console.WriteLine(num2);
            awaiter = Task.Delay(100).GetAwaiter();
            if (!awaiter.IsCompleted)
            {
                this.<>1__state = 1;
                this.<>u__1 = awaiter;
                Program.<Hoge>d__1 <Hoge>d__ = this;
                this.<>t__builder.AwaitUnsafeOnCompleted<TaskAwaiter, Program.<Hoge>d__1>(ref awaiter, ref <Hoge>d__);
                return;
            }
            IL_F2:
            awaiter.GetResult();
            num2 = this.<i>5__1;
            this.<i>5__1 = num2 + 1;
            Console.WriteLine(num2);
        }
        catch (Exception exception)
        {
            this.<>1__state = -2;
            this.<>t__builder.SetException(exception);
            return;
        }
        this.<>1__state = -2;
        this.<>t__builder.SetResult();
    }

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

このステートマシンのMoveNextが継続の正体です。
みればわかりますが、MoveNextが呼ばれると内部の変数が変更されます。

ステートマシンのコピーはAwaiterでは実現できません。
コピーできるタイミングとしてAsyncMethodBuilderが考えられます。
AsyncMethodBuilderは任意の型をasync関数の戻り値にするためのものです。
AsyncMethodBuilderにはAwaiterStateMachimeが渡されるAwaitUnsafeOnCompletedという関数があります。
ここがコピーを行うことのできるタイミングです。

class AsyncHogeMethodBuilder
{
    /*
        省略
    */
    public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        // ここに来た時点(awaitされた時点)でのstateMachineのコピー
        // コピーにはリフレクションをつかってMemberwiseClonseを呼び出す
        var copy = CopyStateMachine(stateMacine);
        // 何度も呼び出し可能な継続を作る
        var continuation = () =>
        {
            // 何度呼び出されてもいいようにもう一度コピーしてそっちを使用する
            var _copy = CopyStateMachine(copy);
            _copy.MoveNext();
        };

        // 継続を使って何かする

        // 省略...
    }
}

これで何度も呼び出すことが可能な継続を手に入れることはできました。
次に問題になるのがawaitしたときの戻り値です。
awaitしたときの戻り値はAwaiterGetResultが使用されます。
よって以下のようにMoveNextの前にAwaiterに準備させます。

var prepare = awaiter.GetPrepare(); // awaiterには何かしらのインターフェースを継承させておく
var continuation = () =>
{
    // 戻り値を準備させる
    // 実際は戻り値をどのようにして準備させるかによってもっと複雑になる
    prepare();
    // 何度呼び出されてもいいようにもう一度コピーしてそっちを使用する
    var _copy = CopyStateMachine(copy);
    _copy.MoveNext();
};

以上で基本的な解説は終わりです。
後は継続をどうやって作るか、どうやって継続を管理するかの問題になります。
あまり面白い話でもないので省略します。

コードとしてはこの辺にあります。(上記のものとは結構違いますが基本的なコンセプトは同じです。)
AsyncChaosMethodBuilder.cs

最後に

Async.Chaosはパフォーマンスの面でも機能面でもあまり意味のないライブラリです。
(しかもスケジューラがおそらくバグっているのでスレッドが複数になってくるとうまく動かないかもしれない)
しかし、今のC#ではやろうと思えばこんなこともできるというのは面白いと思いました。

8
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5