「魔法」のような非同期メソッドはどんな仕組みで動いているのか? その内部実装に踏み込み、「魔法」の実体を解き明かす。
powered by Insider.NET
前回は、非同期メソッドの構文や使い方、注意点などに重点を置いて解説した。すでにご覧になった方は、非同期メソッドを十分に使いこなせるようになっていると思う。また、非同期メソッドの動きについても、ソース・コードとスレッド間のやり取りを対応させつつ分かりやすく図解した。その挙動もイメージできるようになっていると思うので、もしどこかで詰まることがあっても比較的容易に乗り切ることができるのではないだろうか。
これまで何度か非同期メソッドを「魔法」と表現してきた。今回はそんな非同期メソッドの内部実装に踏み込み、「魔法」の実体を解き明かしていく。さらには、非同期メソッドの拡張ポイントであるAwaitableパターンの独自実装についても解説する。さぁ、始めよう。
非同期メソッドの内部実装を知ることは、その挙動や実現方法への理解が深まるとともに、後述のAwaitableパターンの基礎となる。多少難解ではあるが、押さえておこう。
●解剖のアプローチ
内部実装を解剖する、とは言っても、「どうやって?」と思われる方も多いと思う。しかし、.NET Framework上で動く機能の内部実装をひもとくと言えば、そのアプローチは「IL(=Intermediate Language: 中間言語)からの逆コンパイル」とあらかたは決まっている。なぜそうなのかというと、非同期メソッドのように言語仕様として追加される機能の多くは、以下のようなケースがほとんどだからだ*1。
つまり、.exeファイルや.dllファイルからILコードを取り出し、それを逆コンパイルして変換前のソース・コードを取り出すことができれば、その機能がどのように実現されているか分かる可能性が高いということだ。
ILコードを逆コンパイルするツールの代表としては「ILSpy」がある。原稿執筆時点での最新バージョンは2.1.0.1603で、今回はこれを利用する。ILSpyについては「.NET TIPS:無償の逆コンパイラ「ILSpy」を利用するには?」で詳しく紹介されているので、興味のある方はご一読されるとよいだろう。
*1 同様のケースのそのほかの例としては、yieldキーワードや匿名メソッドなどが挙げられる。
●お題
逆コンパイルの解説に利用するソース・コードをList 1に示す。引数と戻り値の双方を含むコンパクトなサンプルである。非同期メソッドを利用するメリットがみじんも感じられないかもしれないが、その点は今回の主眼ではないので目をつぶっていただきたい。
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApplication
{
class Program
{
static void Main()
{
var radius = 1.0;
var area = CalculateCircleAreaAsync(radius).Result;
Console.WriteLine(area); // 計算結果を表示
Console.ReadLine();
}
static async Task<double> CalculateCircleAreaAsync(double radius)
{
var π = await Task.Run(() =>
{
Thread.Sleep(3000);
return Math.PI; // 級数展開などで円周率を計算したとする
});
return π * Math.Pow(radius, 2); // 円の面積
}
}
}
Imports System.Threading.Tasks
Imports System.Threading
Module Module1
Sub Main()
Dim radius = 1.0
Dim area = CalculateCircleAreaAsync(radius).Result
Console.WriteLine(area) ' 計算結果を表示
Console.ReadLine()
End Sub
Async Function CalculateCircleAreaAsync(radius As Double) As Task(Of Double)
Dim π = Await Task.Run(
Function()
Thread.Sleep(3000)
Return Math.PI ' 級数展開などで円周率を計算したとする
End Function)
Return π * Math.Pow(radius, 2) ' 円の面積
End Function
End Module
List 1: 非同期メソッドの例(上:C#、下:VB)
●逆コンパイル
続いてILSpyを用いて逆コンパイルを行うのだが、先に以下の設定が行われているかを確認してほしい。Figure 1のようにチェックがOFFになっていないと、逆コンパイルしてもasync/awaitが展開された形で出力されないためだ。
List 1のコードをビルドし、出来上がった実行可能ファイル(=.exeファイル)を逆コンパイルしたものをList 2に示す*2(C#の場合。VBは割愛)ので、ざっと眺めてほしい。
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleApplication
{
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(() =>
{
Thread.Sleep(3000);
return 3.1415926535897931;
}).GetAwaiter();
if (!taskAwaiter.IsCompleted)
{
this.state = 0;
this.awaiter = taskAwaiter;
this.builder.AwaitUnsafeOnCompleted(ref taskAwaiter, ref this);
return;
}
}
else
{
taskAwaiter = this.awaiter;
this.awaiter = default(TaskAwaiter<double>);
this.state = -1;
}
this.π = taskAwaiter.GetResult();
taskAwaiter = default(TaskAwaiter<double>);
result = this.π * Math.Pow(this.radius, 2.0);
}
catch (Exception exception)
{
this.state = -2;
this.builder.SetException(exception);
return;
}
this.state = -2;
this.builder.SetResult(result);
}
[DebuggerHidden]
public void SetStateMachine(IAsyncStateMachine machine)
{
this.builder.SetStateMachine(machine);
}
}
private static void Main()
{
double radius = 1.0;
double area = CalculateCircleAreaAsync(radius).Result;
Console.WriteLine(area);
Console.ReadLine();
}
[DebuggerStepThrough, AsyncStateMachine(typeof(StateMachine))]
private static Task<double> CalculateCircleAreaAsync(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;
}
}
}
List 2: 逆コンパイル結果(C#。VBは割愛)
*2 実際には、変数名などが、コンパイルが通らない形で出力されたり、冗長な表現で出力されたりする。List 2は読みやすさのためにいくらか整形してある。
あんなにコンパクトだったList 1のコードが、かなり膨れ上がって展開されているのが分かるだろう。
ザッと見て目に付くのは、CompilerGenerated属性(System.Runtime.CompilerServices名前空間)が付加され、プライベート・アクセシビリティになっている「いかにも」らしいStateMachine構造体だ。ご名答、これが非同期メソッド実現の核なのだが、このままではまだ情報が多いので、さらにエッセンスだけ抜き出してみよう。特にポイントとなる箇所をList 3に示す。
[CompilerGenerated] // コンパイラによる自動生成の目印
struct StateMachine : IAsyncStateMachine
{
public int state = -1; // 進捗(しんちょく)状態の記憶
public double radius; // 引数はメンバ変数として保持
public double π ; // ローカル変数もメンバ変数として保持
private TaskAwaiter<double> awaiter; // awaiterの保持
public void MoveNext()
{
if (this.state != 0) // 進捗状態に合わせて分岐
{
this.awaiter = Task.Run(() =>
{
Thread.Sleep(3000);
return Math.PI;
}).GetAwaiter(); // awaitに渡す処理からawaiterを取得
if (!this.awaiter.IsCompleted) // 非同期処理が未完の場合は継続として登録
{
this.state = 0; // 進捗状態を変更
this.awaiter.OnCompleted(this.MoveNext); // 完了後のコールバックとして自身を登録
return;
}
}
this.π = this.awaiter.GetResult(); // 結果の取得
var result = this.π * Math.Pow(this.radius, 2);
// 戻り値に当たるもの(result)を外部に伝達する処理が入る
}
// ……省略……
}
<CompilerGenerated> ' コンパイラによる自動生成の目印
Structure StateMachine
Implements IAsyncStateMachine
Public state As Integer = -1 ' 進捗(しんちょく)状態の記憶
Public radius As Double ' 引数はメンバ変数として保持
Public π As Double ' ローカル変数もメンバ変数として保持
Private awaiter As TaskAwaiter(Of Double) ' awaiterの保持
Public Sub MoveNext()
If Me.state <> 0 Then ' 進捗状態に合わせて分岐
Me.awaiter = Task.Run(
Function()
Thread.Sleep(3000)
Return Math.PI
End Function).GetAwaiter() ' awaitに渡す処理からawaiterを取得
If Not Me.awaiter.IsCompleted Then ' 非同期処理が未完の場合は継続として登録
Me.state = 0 ' 進捗状態を変更
Me.awaiter.OnCompleted(AddressOf Me.MoveNext) ' 完了後のコールバックとして自身を登録
Return
End If
End If
Me.π = Me.awaiter.GetResult() ' 結果の取得
Dim result = Me.π * Math.Pow(Me.radius, 2)
' 戻り値に当たるもの(result)を外部に伝達する処理が入る
End Sub
' ……省略……
End Structure
List 3: 内部実装のエッセンス(上:C#、下:VB。VBのコードはC#の方と同等の内容を記載したもの)
※構造体ではフィールド変数宣言に初期化子を指定できないため、このコードをコンパイルしようとすると、(具体的には「-1」を指定している部分が)エラーになるが、説明をシンプルにするためこのような記述にしている。
背景色が設定されている行は、以下の項番の範囲を示す。各項番については、本文で説明している。
(1)状態マシンの自動生成、状態マシンによる進捗(しんちょく)状態の管理。
(2)Awaiterの取得。
(3)完了判定、継続処理の登録。
(4)結果の取得。
(1) 状態マシンの自動生成、状態マシンによる進捗(しんちょく)状態の管理
まず目に付くのが、IAsyncStateMachineインターフェイスを実装するStateMachine(状態マシン)構造体の存在だ。これは前述のとおり、await部分を展開した結果としてコンパイラによって自動生成される*3。一般に状態マシンとは「あらかじめ決められた複数の状態を、決められた条件に従って、決められた順序で遷移していくシステム」のことで、非同期メソッドはまさにそのような振る舞いによって実現されている。
StateMachine構造体にはMoveNextメソッドが実装されており、その中はstate変数によって分岐が行われていることが分かる。state変数は構造体のメンバとして保持されており、非同期メソッド内の処理がどれほど進捗(しんちょく)したかを表している。
*3 実際にはその構造体名が表に出ることはないため、実際はもっと複雑な別の名前が付けられている。*2でも述べたように、ここでは分かりやすさのため便宜的に命名している。
(2) Awaiterの取得
MoveNextメソッドの中を見ると、もともとawait演算子に渡していた処理を見つけられるだろう。そしてその処理にはawait演算子の代わりに「GetAwaiter」というメソッドが付加されており、そこからTaskAwaiter構造体(System.Runtime.CompilerServices名前空間)を取得している。このように、await演算子に処理を渡すと、何らかのAwaiterオブジェクト*4を取得するGetAwaiterメソッドの呼び出しに変換される。
*4 非同期処理の完了を待機するための補助オブジェクト。
(3) 完了判定、継続処理の登録
TaskAwaiterオブジェクトを取得した後は、IsCompletedプロパティにより非同期処理の完了判定を行っている。完了していない場合は残りの処理を継続として登録するフェイズに遷移する。その手順は次のとおりだ。
このように、「非同期処理の完了後に自分自身(=MoveNextメソッド)を再度コールバックする」ようになっている。その際、何もせずにコールバックすると、前回と同じ処理フローを通るため、結局、無限ループに入ってしまうが、適切に進捗(しんちょく)状態を管理することで、コールバック後の処理フローを制御しているのである。また、コールバックされるまでの間、UIスレッドをブロックしないために、登録を終えたらメソッドから抜け出すようになっている。
ここでは詳細は解説しないが、List 4のようにawait演算子が1つの非同期メソッドの中に複数定義されている場合は、上記の処理が繰り返されることになる。また、while文やforeach文の中にawait演算子が含まれる場合、それぞれコードの展開のされ方が変わるので、興味のある方はぜひ試してみてほしい。
static async void DoSomethingAsync()
{
await Task.Run(() => Thread.Sleep(1000));
await Task.Run(() => Thread.Sleep(2000));
await Task.Run(() => Thread.Sleep(3000));
}
Shared Async Sub DoSomethingAsync()
Await Task.Run(Sub() Thread.Sleep(1000))
Await Task.Run(Sub() Thread.Sleep(2000))
Await Task.Run(Sub() Thread.Sleep(3000))
End Sub
List 4: 複数のawait演算子を含む非同期メソッド(上:C#、下:VB)
(4)結果の取得
非同期処理が完了したら、TaskAwaiterオブジェクトのGetResultメソッドを通して結果を取得する。もともとはローカル変数として受けていた戻り値だが、実際には構造体のメンバ変数として受けるコードに変換される。これはList 4のようなケースを想定しており、コールバックによる処理の再開後にも先の値を利用できるようにするためだ。
○逆コンパイルの結果から分かる非同期処理の原理
それではここで、もう一度List 2に立ち返ってみよう。
AsyncTaskMethodBuilder<T>(System.Runtime.CompilerServices名前空間)という構造体が出てくるが、これはStateMachine構造体を利用した非同期処理の開始や、結果を取得したりする入口になっていると考えればよい。また、AwaitUnsafeOnCompletedメソッドは「内部でTaskAwaiterオブジェクトのOnCompletedメソッドを呼び出している」と読み換えていただいて問題ない。そのほか、例外処理への対応が含まれていることが確認できるが、これも特に難しいものではないだろう。
いかがだろうか。非同期メソッドが処理の中断と再開を繰り返し行いながら実行されている、ということの原理がご理解いただけたのではないかと思う。
続いて次のページでは、コンパイラが非同期メソッドを解釈する要件や、Awaitableパターンの独自実装について説明する。
Copyright© Digital Advantage Corp. All Rights Reserved.