前編はこちら。
前編の続きです。途中で力尽きたのでで今回は中編。次回で完結です。
今回は、おさらいの5個目*1を確認したのち、コンパイラがasyncメソッドのコードをどのように実装するかを見ていきます。
オススメ:astn/await入門
そもそもasyn/awaitがどういう役割を持っていて、なぜそれが必要なのかについては、土屋が以前書いたこちらの記事をどうぞ(本来前編に貼るべきだった)。
おさらい5:awaitを使わない非同期処理
前編で、「Task.Run()などの非同期タスクにawaitをつけると、そのタスクの完了を待ってから処理が先に進みます。」と書きました。
これだけ聞くと、「完了を「待って」から処理が進むのなら、それは同期処理であって、「非」同期処理では無いのでは?」と思われるかもしれません。実際その通りで、awaitはまさに「非同期処理を同期的に処理する」ための機能なのです。ですから、非同期タスクが非同期で動いて欲しい時にはawaitはつけません。
下記のコードで確認します。Button1_Click()内のMethodAsync()呼び出しだけが変わっています。
private void Button1_Click(object sender, EventArgs e) { Debug.Print($"MethodAsync呼び出し前"); //MethodAsync()を別メソッドで実行する Task.Run(() => MethodAsync()); //D Debug.Print($"MethodAsync呼び出し後"); //E } private static async Task MethodAsync() { Debug.Print($"1:タスク開始前 Id: {Environment.CurrentManagedThreadId}"); //A await Task.Run(() => Debug.Print($"2:タスク実行中. Thread Id: {Environment.CurrentManagedThreadId}")); //B Debug.Print($"3:タスク実行後 Id: {Environment.CurrentManagedThreadId}"); //C } //実行結果 MethodAsync呼び出し前 MethodAsync呼び出し後 1:タスク開始前 Id: 9 2:タスク実行中. Thread Id: 11 3:タスク実行後 Id: 11
実行結果を見ると、MethodAsync()を別タスクで実行(D)した後、処理がすぐ次の行(E)に移ったのが分かります。そしてその後、MethodAsync()の中身が順番に実行されています(A→B→C)。MethodAsync()は非同期処理になりましたが、そのMethodAsync()内で実行された非同期タスク(B)は、awaitによって同期処理になった訳です。
このように、awaitは「タスクの完了を待ってから処理を続行するための仕組み」です。言い換えると、await自体には「別スレッドを実行する」というような機能は無いのです。この点を意識しておくと、async/awaitが使いやすいんじゃないかと思います。
念の為、MethodAsync()呼び出しのバリエーションを列挙しておきます。awaitをつける時はButton1_Click()メソッドの定義にasyncをつける必要があるのでご注意ください。
await MethodAsync(); //MethodAsync()の終了を待つ await Task.Run(() => MethodAsync()); //MethodAsync()の終了を待つ Task.Run(() => MethodAsync()); //MethodAsync()の終了を待たない
awaitの内部処理を考える
ここまで、「awaitは「タスクの完了を待ってから処理を続行するための仕組み」であるという事を、繰り返し確認してきました。では、この「タスクの完了を待ってから処理を続行するための仕組み」は、どのように実現されているのでしょうか?
改めて考えると、これはなかなか難しい挙動です。asyncメソッド内でawaitをつけた処理が別スレッドで非同期実行されたとします。この時、asyncメソッドはそのタイミングでreturnして、メインスレッドは処理が続行されます。ここまではいいとしましょう。
しかし、別スレッドの処理が終わった時、どうやってawait以降の処理を続行させられるのでしょうか? 更に言えば、どうやってその処理を、メインスレッドに戻した上で続行させられるのでしょうか?
これらの挙動は、C#がコンパイル時に生成される一連のコードによって実現されています。ここからは、そのコードのロジックを見て行きます。
コンパイラは、個々のasyncメソッドについて専用のクラスを用意し、そのクラスのMoveNext()の中にasyncメソッド内の処理を移植します。元のasyncメソッドが呼び出された時、この専用クラスが生成され、MoveNext()が実行されます。
以下は、おさらい5のMethodAsync()メソッド内の処理を、C#コンパイラが専用クラスのMethodAsyncImplに変換した物になります。ただし、説明の為に単純化した擬似コードで、実際の物とは大きく異なります(ごめんなさい)。
private sealed class MethodAsyncImpl : IAsyncStateMachine { private TaskAwaiter awaiter; private int state; //生成時は-1で初期化される public AsyncTaskMethodBuilder builder; private void MoveNext() { //① if (state != 0) { //② Debug.Print($"1:タスク開始前 ~~~"); //A awaiter = Task.Run(() => Debug.Print($"2:タスク実行中 ~~~")).GetAwaiter(); //B //③ if (!awaiter.IsCompleted) { state = 0; builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); return; } } //④ awaiter.GetResult(); //⑤ Debug.Print($"3:タスク実行後 ~~~"); //C //以下後処理 state = -2; builder.SetResult(); } }
実行時にMethodAsync()が呼ばれた時、内部でこのMethodAsyncImplクラスが生成され、MoveNext()が実行されます。このメソッドは簡易的なステートマシンとして駆動します。以下、簡単にロジックを実行順に見て行きます。
//① if (state != 0) {
stateはステートマシンの現在の状態を表すインデックスです。クラス生成時は-1で初期化されています。そのためここではif条件式が真になります。
//② Debug.Print($"1:タスク開始前 ~~~"); awaiter = Task.Run(() => Debug.Print($"2:タスク実行中 ~~~")).GetAwaiter();
ifのthenブロックには、元のMethodAsync()の1行目と2行目が配置されています。
awaitが付かない式はそのままコピーされます。awaitが付く式、例えばawait タスク;となっている式は、awaiter = タスク.GetAwaiter();という式に変換されます。なぜこのような機械的な変換が可能かと言うと、awaitを付けられる式は、Task型、あるいはTask<TResult>型に限られ、かつ、これらの型はGetAwaiter()を実装しているからです。
Task.Run()の戻り値はTaskで、Task.GetAwaiter()はタスクの実行状況を取得するためのオブジェクトであるTaskAwaiter(以下Awaiter)を返します。Awaiterについては後述します。
ここまでの挙動を確認しておきます。MethodAsync()が呼ばれると、MethodAsyncImplが生成され、MoveNext()が実行されます。初回実行なので①のif式は真になり、then節が実行されます。
then節の中ではまずDebug.Print()で"1:タスク開始前 ~~~"が出力されます。次にTask.Run()によって別スレッドが用意され、タスクがそのスレッドにキューイングされます(つまり非同期実行されます)。メインスレッドではそのタスクのAwaiterを取得してからthen節を抜け、次の処理に進みます。
//③ if (!awaiter.IsCompleted) { state = 0; //③-A builder.AwaitUnsafeOnCompleted(ref awaiter, ref this); //③-B return; //③-C }
awaiter.IsCompletedは、この時点でタスクが処理を完了していた場合trueを返します。ここではまだ完了していないので*2IsCompletedはfalseを返し、条件式は真になります。
- ③-A:ステートマシンの状態インデックスを
-1(開始直後)から0にカウントアップします。今回はawaitが一個だけなので、これ以上のカウントアップはありません。 - ③-B:
AwaitUnsafeOnCompleted()は、Awaiterにこのステートマシン自身を登録するメソッドです。別スレッドで実行されたタスクが終了した時に、再度このMoveNext()が呼ばれるようにします。 - ③-C:
MoveNext()の処理を終了します。後で出て来ますが、MethodAsync()内のコードは全部MoveNext()内に移植されているので、ここでreturnするとMethodAsync()自体も終了し、メインスレッドの処理が続行されます。
タスク処理終了後
さて、別スレッドで非同期処理されていたタスクが終了したとします。
タスクの終了時、AwaitUnsafeOnCompleted()でAwaiterに登録したステートマシンのMoveNext()が再度実行されます*3。以下、この2回目のMoveNext()呼び出し処理のロジックを見ていきます。
この時、状態インデックスは-1から0にカウントアップしているので、①の条件式はfalseになり、`thenP節は実行されずに処理は④に進みます。これが「非同期タスクの処理が完了するのを「待って」次の処理を進める」というロジックに対応します。
//④
awaiter.GetResult();
awaiter.GetResult()メソッドは、「タスクからの戻り値を取得する」「タスクが完了していない場合は完了を待つ」という2個の役割を持ちます。
「戻り値の取得」は、var result = await Task.Run(~~);のようにawaitから戻り値を取得するコードの場合、ここで変数に格納するようにコードが構築されます(今回は戻り値がないので実行しているだけです)。GetResult()は対応する型を返すメソッドとして定義されています。
一方、「タスクの完了を待つ」という機能は若干不自然に思えます(このフローに至った際は、タスクは常に完了している筈なので)。個人的には、awaiter.GetResult()の実装としてTask.Wait()を呼ぶのが都合が良かったというだけで、それ以上の意味は無いように思えます(が、よくわかりません。スクラッチでコードを書く時には必要になるのかも)。
ともかく、タスクが完了したので、await後の処理に移ります。
//⑤ Debug.Print($"3:タスク実行後 ~~~"); //C //以下後処理 state = -2; builder.SetResult();
GetResult()の後で、元のコードのawait後の処理(ここではDebug.Print())が実行されます(後処理については今回は省略)。
このように、awaitを使った「非同期処理を「待つ」」という挙動を実現するために、背後ではこのような複雑な処理が生成され、実行されていたのです。
後編に続く
asyncメソッドがコンパイラによってどのように展開されるかを解説しました。あと少しで本題に入れる予定です。 次回はawaiter/awaitableパターンとその背後で管理される同期コンテキストによって、如何にして「メインスレッドに「戻る」」という処理が実現されているのかを確認し、そもそものテーマである「C#の非同期タスク処理でConfigureAwait(false)してもメインスレッドに戻る場合がある件について」に迫ります。
宣伝「Unityシェーダープログラミングの教科書シリーズ」
土屋は同人誌「Unityシェーダープログラミングの教科書」シリーズを書いています。現在以下の5冊が出ています。
- Unityシェーダープログラミングの教科書 ShaderLab言語解説編
- Unityシェーダープログラミングの教科書2【反射モデル&テクスチャマップ編】
- Unityシェーダープログラミングの教科書3 ライティング&GI(大域照明)解説編
- Unityシェーダープログラミングの教科書4 SRP[1]UniversalRP/Litシェーダー解説編
- Unityシェーダープログラミングの教科書5 SRP[2]UniversalRP URP拡張カメラ/HDR/ポストプロセス編
これらの同人誌は、現在BOOTHの下記ストアにてPDF版を有料頒布しています。
https://s-games.booth.pm/s-games.booth.pm
Unityのシェーダープログラミング関連について、世界で最も詳細な本だと自負しています。リンク先にサンプルページがあるので、是非御覧になってください。