awaitできるもの
await
できるのは、GetAwaiter
という名前のメソッドを持つ任意のオブジェクトです。
一段メソッドが挟まっており、さらに名前さえ一致すればどんなメソッドでもよいため、拡張性と柔軟性を持っています。
例えば、最もよく使われるTask
の他にも、値型のValueTask
などがawait
することを想定した型として標準で用意されています。
GetAwaiter
メソッドが返す値も、先程のAwaiter
の条件を満たせば何でもよいです。具体的には
public class Awaiter<T> : System.Runtime.CompilerServices.INotifyCompletion
{
// 条件1
public bool IsCompleted { get; }
// 条件2
public void OnCompleted(Action action)
{
}
// 条件3
public T GetResult()
{
}
-
System.Runtime.CompilerServices.INotifyCompletion
インターフェイスの実装 - 実行が終了したかどうかを示す
IsCompleted
という名前のプロパティの実装 - 実行が完了していないときに、完了後に実行する関数を登録できる
OnCompleted
メソッドの実装 - (実行が完了していない時は実行が完了するまで同期待ちし)結果を返す
GetResult
メソッドの実装
の4点です。
##メソッド呼び出しの抽象化
ここで一旦、メソッドを呼び出し、その結果を受け取るというプロセスについて考えてみましょう。
メソッドを呼び出すと、その呼び出しが完了したタイミングで制御フローが返り、存在する場合戻り値が得られます。
もう少し低レベルな、IL(中間言語)的な話をするならば、call
命令を実行すると、0個以上の戻り値が評価スタックにプッシュされ、その後 call
命令の次の命令が実行されます。(callvirt
命令やcalli
命令でもこの点は同じです。)
ところで、制御フローすなわちリソースの利用権とは貴重なものです。特にGUIを持つアプリケーションでは、「UIスレッド」という固有の制御フローを皆で利用しないといけないのですから大変貴重です。しかし、メソッドの呼び出しが制御フローに依存している場合、例えば計算機資源が必要なのではなくネットワーク上での通信待ちが必要なのだとしても制御フローを保持せねばならず、この貴重な資源の効率的な利用を妨げてしまいます。
あるいは、メソッドの実行結果を評価スタックにプッシュするというのも厳しい制約です。評価スタックというIL上の概念は、実際にはCPUのレジスタである可能性もあるため、評価スタック上の値にはアドレスなどの概念はなく、呼び出された関数本人がその場で値を返すしか選択肢がないのです。これでは、メソッドの実行を別のスレッドや別のプロセスに委譲することができず、せっかくマルチコアプロセッサの時代であるのにUIスレッドを占有し続けることになっていまいます。
そこで、従来のメソッド呼び出しという概念を抽象化し、制御フローや評価スタックに依存しないような「新しいメソッド呼び出し」を考えてみましょう。
共有オブジェクトが存在して
- 呼び出しが完了したかどうかが判別できる
- 呼び出しが完了したあとに実行される命令を指定できる
- 呼び出しの結果(戻り値)を受け取ることができる
の3つの条件が満たせるならば、従来 return
や制御フローが担っていた機能を任せられます。
Task
などのawait
可能な型は、同期/非同期を問わず、この3要件でメソッドを抽象化するともいえるのです。
そしてこの抽象化されたメソッドを呼び出すことを明示するのが await
です。
この抽象化を採用したからといって非同期になるわけではありません。しかし、これによって結果返却を伴わずにreturn
することが可能になり、結果が出る前にreturn
せねばならない非同期が可能になるのです。
例えば、以下のような2つのメソッドがあったときに Hoge()
と await HogeAsync()
それぞれの違いは何でしょうか。
await
をつけると非同期で実行される、、、とは限りません。
abstract class Sample
{
// 実装はわからない
public abstract int Hoge();
public abstract Task<int> HogeAsync();
}
class Sample2 : Sample
{
public override int Hoge()
{
System.Threading.Thread.Sleep(1000);
return 1000;
}
public override async Task<int> HogeAsync()
{
System.Threading.Thread.Sleep(1000);
return 1000;
}
}
上記の Sample2
クラスの HogeAsync
は同期的に実行されます。それでも、Hoge
は戻り値として直接に制御と値を返し、HogeAsync
はTaskというオブジェクトを介して制御と値を返します。
awaitの正体
このような抽象化されたメソッドを呼び出すのがawait
ですが、そのしくみはどうなっているでしょうか。
コンパイル結果的には、await
はステートマシンを生成します。シンプルな await
コードのコンパイル結果を覗いてみましょう。
await Task.Delay(100);
Console.WriteLine("waited");
このコードをコンパイルした結果を逆コンパイルしてみると
// Program.<<Main>$>d__0
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
[CompilerGenerated]
private sealed class <<Main>$>d__0 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder <>t__builder;
public string[] args;
private TaskAwaiter <>u__1;
private void MoveNext()
{
int num = <>1__state;
try
{
TaskAwaiter awaiter;
if (num != 0)
{
awaiter = Task.Delay(100).GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (<>1__state = 0);
<>u__1 = awaiter;
<<Main>$>d__0 stateMachine = this;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
}
}
else
{
awaiter = <>u__1;
<>u__1 = default(TaskAwaiter);
num = (<>1__state = -1);
}
awaiter.GetResult();
Console.WriteLine("waited");
}
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)
{
}
void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
{
//ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
this.SetStateMachine(stateMachine);
}
}
自動生成コードのためやや難解ですが、中核となる MoveNext
メソッドの動作を簡単に説明すると、
-
Task.Delay
を呼び出す。その戻り値をGetAwaiter
してAwaiter
を取得する。 -
Awaiter
が終了状態かIsCompleted
プロパティで判断する。終了状態なら、4へ飛ぶ。そうでなければ、完了時に自分自身を呼び出すようOnCompleted
メソッドで登録し、ステートマシンを待機状態に設定してreturn
する。 -
Task.Delay
が完了し、MoveNext
がコールバックされる。ステートマシンが待機状態なので、終了報告なのだとわかる。 - 結果を受け取り、ステートマシンの状態をリセットする。
-
Console.WriteLine
を呼び出す。
同じ MoveNext
を呼び出しても、内部の状態により実行されるコードが切り替わります。
また元々のコードの await
以後のコードはステートマシン内部に取り込まれてしまい、元の関数には残りません。
2 => 3 ではコールバック関数が発火する必要があるため、制御フローがイベントループまで戻っていることが必須です。
このことが、await
が async
でしか使えず...というasync/await
地獄を引き起こす原因の1つです。
そうは言っても、このように、「コンパイラがものすごく頑張る」ことによって、非同期を従来に近い書き味で書くことが可能になっているのです。