VB.NETのタスクとAwaitについて

タグの編集
投稿者 ツェナー  (社会人) 投稿日時 2023/11/16 11:20:14
現在、仕事において自社で使用しているアプリケーションのコードの学習を行っています。
クラスのメソッドとして、非同期メソッドが出てくるのですが、いまいち動作が分かりません。

Public Class ClassManager
Inherits ClassSingleton(Of ClassManager)
Implements IDisposable

        '(省略) 

        Private Async Function MainAsync() As Task
                  Dim ret As Task = Task.Run(Sub()
                                  
                                                    '(省略) 
                                           
                                             End Sub)
                  Await ret.ConfigureAwait(False)
        End Function

        '(省略) 

End Class

このようなプログラムがあったとき、動作の流れとして、
・呼び出し元でクラスをインスタンス化して、MainAsync()を呼ぶ
・MainAsyncはタスクを生成してそのタスクを別スレッドに移す(Dim ret As Task = Task.Run(Sub()の部分)
・元々のスレッドでは、タスクを別のスレッドに移したため、タスクより下のコードを実行しようとして、Awaitに到達する。Awaitがあるので、別スレッドで動いているタスクの終了を待つ
ということになるのかと思うのですが、
では元々のスレッドはAwaitしている間は何をしているのか、呼び出し元のコードを実行しているのか?
という点がわかりません。
ちなみに、このメソッドを呼び出している部分を見たところ、
Private ReadOnly _taskMain As Task = MainAsync()
       
        '(省略) 

_taskMain.Wait()

となっていました。

よろしくお願いいたします。
投稿者 るきお  (社会人) 投稿日時 2023/11/16 13:34:23
> Awaitがあるので、別スレッドで動いているタスクの終了を待つ
Await はその名前に反して、タスクの終了を待ちません。
Await に到達した時点で直ちに呼び出し元に実行が戻ります。

呼び出し元では
_taskMain.Wait()

のように Wait が使われているので、ここで呼び出し元は実行を停止して、別タスク(_taskMain)の終了を待ちます。

以上が、このようなプログラムを書くプログラマーがよく想定しているシナリオで、実際にそのように実行されることもあります。
一方で、別タスクで実行する処理が元タスクがAwait や Waitに到達するよりも速く完了する場合などもありますから、第2・第3のシナリオもあります。そうであっても、VB側でうまく面倒を見てくれるのでプログラマーはこのような状況を区別する必要は通常ありません。

>では元々のスレッドはAwaitしている間は何をしているのか、呼び出し元のコードを実行しているのか? 
よくあるシナリオの場合、Wait() で「待て」と命令しているので、それを素直に実行します。
つまり、呼び出し元コードは何もしていません。

本来はWaitではなく、何かここに別の処理を記述することで、それぞれの処理が同時を進ませることができます。
今回は学習用のプログラムのようなので、あまり意味がない構造になっているのかなと思いました。

いただいたプログラムは下記で説明しているプログラムに近いので、よろしければ参考にしてください。
https://www.umayadia.com/VBStandard2/Standard43.htm#A3_5

なお、非同期処理はとても難しく、サンプルレベルではうまく動いているように見えても実践ではさまざまな問題が発生することがよくあります。熟練のプログラマーでなければ使いこなせません。実用の際にはご注意ください。
投稿者 とくま  (社会人) 投稿日時 2023/11/16 15:52:05
非同期処理のプログラム自体は、文法に決まりごとが多いし、あっちこっちに処理が飛んだように見えて
難易度高いようにも思うけど、なぜ非同期処理処理が必要なのか?そして有効な場面は?みたいな話は
難しくないと思うのだが。
コードから学んで分かりやすいものも無いとは言わないけど、目的が不明なまま公式だけ理解するのは
順番が逆なのでは?

新規プロジェクトでフォームだけのプログラムを実行したとき、フォームは何をしていますか?
何もしていないとも言えるし、ユーザやOSの命令や連絡(ウィンドウがドラッグされて場所が移動した
とか、上にかぶっていたメモ帳のウィンドウが終了してアクティブウィンドウになったよとか)を「待つ」
ということをしてるとも言える。

よく別スレッドに処理を投げたいと思うのは、画面が重い、固まるとき。どうしてこんな現象が起こるのか?
OSから自ウィンドウがドラッグされたよって情報が来ても、フォームを動いた先の場所に描く処理を実行する
暇がないから。なんで暇がないのか?ひとつ前に押されたボタンの処理で、数億件のデータを計算する処理が
実行されてて、それに30分掛かるから。その仕事を別の人に丸投げできれば、自分は次の処理の受付ができる。
会社で受付の人が、一人目のお客さんの相手を帰るまで担当したら、二人目のお客さんは一人目が帰るまで
待たされる。受付だけして「担当者に代わります」とすれば二人目をすぐ受け付けられる。フォーム画面は
常にユーザの命令を受け付けるために「待つ」をしているのが良い状態なのは当然のこと。

ただ非同期の処理を採用してても、同期をとる必要がある瞬間はある。同期が必要なのは順番がある処理。
ファイルをダウンロードして読み込む処理では、ダウンロード途中のファイルを読んだら途中までのデータ
しか読めない。ダウンロードが終わった後に次の処理を実行する必要がある、同期処理、いわゆる順次処理。
同じファイルのダウンロードでも、社員情報と組織情報のファイルをそれぞれダウンロードする場合は?
どっちから始めても良いし、どちらかの処理が終わるのを待つ必要が無い、同時に実行して良い、非同期処理。
同じプログラムの、同じ処理の中に、同期と非同期は混ざっていても不思議はない。

ここでダウンロードプログラムの一部だけ切り出して、同期をとる場面なのか判断できるのか?全体の流れを
把握しないと、どっちが正解の場合もある。。。もしかしたら今あるプログラムコードが間違ってる可能性も
ある状態で、理解しようとしても意味が無いかもしれない。

>クラスのメソッドとして、非同期メソッドが出てくるのですが、いまいち動作が分かりません。
動作自体はConsole.WriteLineとかでデバッグログ仕込めば、動いたまま出てくるのでは?動いたままが
プログラムなんだから、分からないとか無いから。ちゃんとデバッグ作業してください。
投稿者 ツェナー  (社会人) 投稿日時 2023/11/16 17:03:33
ご教示いただきありがとうございます。

>呼び出し元は実行を停止して、別タスク(_taskMain)の終了を待ちます。
>呼び出し元コードは何もしていません。
>本来はWaitではなく、何かここに別の処理を記述することで、それぞれの処理が同時を進ませることができます。

私がここに載せた記述ではどのように動作するのか、分かりました。
呼び出し元でもどこでも書いたとおりにしか動かないということですね。

また、MainAsyncを呼び出している部分は、
Private ReadOnly _taskMain As Task = MainAsync()
       
        '(省略)  

_taskMain.Wait()

となっていたと申しましたが、確認したところ、これはMainAsyncメソッドを使用する処理を終了する際のみ、呼び出されるように書かれており、通常はClassManagerのインスタンスを生成した際に、MainAsyncは定期的に実行されるようになっているということでした。
(MainAsyncは、定期的な実行でデータをデバイスに読み書きしており、内部メモリに書き込みデータがある場合はデバイスにデータを書き込み、読み込んだデータが必要な際は、MainAsyncが内部メモリに保存した読み込みデータを持って行くという動作をするということでした。)

特に学習用というプログラムではありませんが、確認が足らず、Waitして呼んでいる部分を呼び出しの際は全てそれを使っていると勘違いしておりました。

また、デッドロックについて、
Private Sub AsyncTest()

    Debug.WriteLine("A")

    DoNotDoThisAsync().Wait()

    Debug.WriteLine("B")

End Sub

Private Async Function DoNotDoThisAsync() As Task

    Debug.WriteLine("C")

    Await Task.Run(Sub()
                       Debug.WriteLine("D")
                   End Sub).ConfigureAwait(False)

    Debug.WriteLine("E")

End Function

とすれば動作できるとのことでしたが、呼び出し元のプログラムは、別スレッドのAwaitしている処理が終了したところで、呼び出し元のプログラムをその別スレッドに移すことでWaitから抜けられるという理解で宜しいでしょうか。

度々申し訳ございませんが、よろしくお願いいたします。
投稿者 ツェナー  (社会人) 投稿日時 2023/11/16 17:19:42
とくまさま

ご指摘と解説ありがとうございます。
まず非同期処理にしている理由を確認してからコードを見るべきとのご指摘、その通りかと存じます。

また、デバッグもせず質問する私の姿勢がお気に障られたのであれば申し訳なく思います。
投稿者 るきお  (社会人) 投稿日時 2023/11/16 19:25:46
> 呼び出し元のプログラムは、別スレッドのAwaitしている処理が終了したところで、呼び出し元のプログラムをその別スレッドに移すことでWaitから抜けられるという理解で宜しいでしょうか。

多くの場合、実行が Await に到達した時点で、内部的にはその時の実行コンテキストが記録されます。Await の処理が終了した後、その記録しておいた実行コンテキストを使って残りの処理を再開しようとします。

この機能により、プログラマーはスレッドのことを特に気にしないで、Await後の処理で元のスレッドの特性(特にWindowsフォームアプリケーション等で重要なのは、元のスレッドでしかGUIの操作ができないという特性)を利用することができます。

参考に乗せた記事のサンプルをコメント部分を日本語訳して引用します。C#ですが、VBとほぼ同じなのでわかると思います。var は Dim です。(ちょっと乱暴な解釈ですが)

private async void button1_Click(object sender, EventArgs e)
{
  // awaitにより同期コンテキスト(SynchronizationContext.Current)が暗黙的に保存されます。
  var data = await webClient.DownloadStringTaskAsync(uri);

  // ここに到達すると、保存された同期コンテキストが実行の再開に使用されます。
  // そのため、何も記事せずUIオブジェクトを操作できます。
}


ところが、元のスレッド側でWaitメソッドやResultメソッドなどで、非同期処理の完了を待機されてしまうと、保存された実行コンテキストで実行を再開しようとしたときにWaitメソッドやResultメソッドの効果で、再開を待たされてしまいます。一方はWaitで待っており、他方はそのせいで再開を待たされており、両方が待つ状態となりデッドロックになります。

ConfigureAwait(False) は、Awaitが元スレッドの実行コンテキストを保存しないようにする効果があります。そのため、非同期処理の完了後、元のスレッドの状態と関係なく実行を再開できます。その代わりにその部分ではWindowsフォームのUIの操作などはできません。

Awaitの処理の後に何も実行すべき処理がないように見えても、まったく何もしないということはありません。たとえば、End Subと書いてあるだけでも、CPUの観点では実行すべきことがいくつかあります。

厳密にはAwaitの対象がTask出ない場合など内部的な動作が異なるケースもあります。

ツェナーさんの、表現は一部理解できないところがありますが、以上のような動作と照らし合わせると正確な理解ではないように思いました。
とは言え、この動作を正確に理解している人はほとんどいないと思います。私もこまかくつっこまれるとちょっと自信がないですね。

参考
https://learn.microsoft.com/ja-jp/archive/msdn-magazine/2011/february/msdn-magazine-parallel-computing-it-s-all-about-the-synchronizationcontext#:~:text=%E5%BE%85%E6%A9%9F%E7%82%B9%E3%81%A7%E7%8F%BE%E5%9C%A8%E3%81%AE%20SynchronizationContext%20%E3%82%92%E3%82%AD%E3%83%A3%E3%83%97%E3%83%81%E3%83%A3 
投稿者 ツェナー  (社会人) 投稿日時 2023/11/17 12:02:03
>元のスレッド側でWaitメソッドやResultメソッドなどで、非同期処理の完了を待機されてしまうと、保存された実行コンテキストで実行を再開しようとしたときにWaitメソッドやResultメソッドの効果で、再開を待たされてしまいます。
>一方はWaitで待っており、他方はそのせいで再開を待たされており、両方が待つ状態となりデッドロックになります。
>ConfigureAwait(False) は、Awaitが元スレッドの実行コンテキストを保存しないようにする効果があります。そのため、非同期処理の完了後、元のスレッドの状態と関係なく実行を再開できます。

デッドロックの起きる理由と、ConfigureAwait(False)がどういうものか、少し分かるようになりました。
参考のMS Learnも見てみます。
とても分かりやすかったです。
ありがとうございました。