async/await
は非同期処理を同期処理のように書ける機能です。
しかし、async/await
にはそれ以上のポテンシャルがあります。
それが 継続の取得 です。
AsyncMethodBuilder
とasync/await
を併用することで 継続の取得を行うことができます。
今回は継続の取得を悪用使用する拙作ライブラリ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.Yield
やChaosTask.WaitTask
、ChaosTask.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
にはAwaiter
とStateMachime
が渡される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
したときの戻り値はAwaiter
のGetResult
が使用されます。
よって以下のようにMoveNext
の前にAwaiter
に準備させます。
var prepare = awaiter.GetPrepare(); // awaiterには何かしらのインターフェースを継承させておく
var continuation = () =>
{
// 戻り値を準備させる
// 実際は戻り値をどのようにして準備させるかによってもっと複雑になる
prepare();
// 何度呼び出されてもいいようにもう一度コピーしてそっちを使用する
var _copy = CopyStateMachine(copy);
_copy.MoveNext();
};
以上で基本的な解説は終わりです。
後は継続をどうやって作るか、どうやって継続を管理するかの問題になります。
あまり面白い話でもないので省略します。
コードとしてはこの辺にあります。(上記のものとは結構違いますが基本的なコンセプトは同じです。)
AsyncChaosMethodBuilder.cs
最後に
Async.Chaos
はパフォーマンスの面でも機能面でもあまり意味のないライブラリです。
(しかもスケジューラがおそらくバグっているのでスレッドが複数になってくるとうまく動かないかもしれない)
しかし、今のC#
ではやろうと思えばこんなこともできるというのは面白いと思いました。