ヤヴァいタイトル付けてしまった…. ええと、これはAdvent Calendarではありませんが、勢いで書いています(C# ADは既に埋まっていた…)
それには、こんな事情があったのです:
- 「.NET非同期処理(async-await)を制御する、様々な方法」の記事がコンスタントにPVを稼いでいる
(その割に、他の非同期関連の記事は読まれない)。 - asyncやawaitキーワードの使い方を度々聞かれる。
- Task.Wait()とか、Task.Resultとか使ってるコードを頻繁に見る(不要なのに! しかも、この記事を参照しながら全部Wait()とかResultしているブログ記事… そしてそこから流入するPV… 死んじゃう、死んじゃうよ…)。
- Task.Run()とか、Task.Factory.StartNew()とか、Task.Start()とか、Task.ContinueWith()を使ってるコードを頻繁に見る(不要なのに)。
- いまだにThreadクラス直接使ってる… しかもその中でTask.Wait()とか(?!?!)
私のセッションや記事は、「なぜなのか」を深掘りして問題の根本を理解してもらう事を念頭に置いているのですが、さすがにこれだけ頻出してるのは、いろいろ問題があるんじゃないだろうかと思いました。特にXamarinや.NET Coreが注目されると、今までC#でコード書いた事が無い、と言う人が、見よう見まねで書き始める時期じゃないかと思います(それで何となく書けてしまうのが、C#の間口の広いところではあるのですが)。
「できる(なんちゃら)」じゃないけど、そういう資料も切り口を丁寧に詰めればありかも知れない…
そういうわけで:
- C#の非同期処理(Taskとasync-await)を使ってコードを書くなら、以下の事を知っていれば9割がたの問題は回避できる!!
- ここで示した方法から外れたことをしようとしている場合は、殆ど間違いなく誤った記述をしようとしているので、もう一度何をしようとしているのか整理しよう!!
2021.2: これとは別の切り口、「非同期処理、何もわからない」という、開発の日常風に書いたものもあります。こちらの方が分かりやすい人もいるかも知れません。
章立てを二章だけにして、極限まで絞り込みました。ではどうぞ。
# 補足: コードはやや冗長に書いています。
第一章. 同期処理と非同期処理を対比させて、書き方を把握する
以下のコードは、ウェブサイトから「同期的に」データをダウンロードします。要するに、普通のコードです。WebClientクラスを使い、OpenReadメソッドでStreamを取得して、StreamReaderで文字列として読み取ります(クラス定義などは省略):
using System.IO; using System.Net; public static string ReadFromUrl(Uri url) { using (WebClient webClient = new WebClient()) { using (Stream stream = webClient.OpenRead(url)) { TextReader tr = new StreamReader(stream, Encoding.UTF8, true); string body = tr.ReadToEnd(); return body; } } } public static void Download() { Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md"); string body = ReadFromUrl(url); Console.WriteLine(body); }
これを非同期処理として変形します。変形した箇所にはコメントを入れておきました。番号の順にやればスムーズに行くと思います:
using System.IO; using System.Net; using System.Threading.Tasks; // Step5: メソッド内でawaitキーワードを使ったら、メソッドの先頭でasyncを加えなければならない。 // Step6: メソッド内でawaitキーワードを使ったら、戻り値の型はTask<T>型で返さなければならない。 // T型は本来返すべき戻り値の型。 // Step7: 非同期処理対応メソッドは、「慣例」で「~Async」と命名する(慣例なので必須ではない)。 public static async Task<string> ReadFromUrlAsync(Uri url) { using (WebClient webClient = new WebClient()) { // Step1: OpenReadから非同期対応のOpenReadTaskAsyncに変更する。 // Step2: OpenReadTaskAsyncがTask<Stream>を返すので、awaitする。 // awaitすると、Streamが得られる。 using (Stream stream = await webClient.OpenReadTaskAsync(url)) { TextReader tr = new StreamReader(stream, Encoding.UTF8, true); // Step3: ReadToEndから非同期対応のReadToEndAsyncに変更する。 // Step4: ReadToEndAsyncがTask<string>を返すので、awaitする。 // awaitすると、stringが得られる。 string body = await tr.ReadToEndAsync(); return body; } } } // Step10: メソッド内でawaitキーワードを使ったら、メソッドの先頭でasyncを加えなければならない。 // Step11: メソッド内でawaitキーワードを使ったら、戻り値の型はTask型で返さなければならない。 // ここでは元々返す値が無かった(void)なので、非ジェネリックなTask型を返す。 // Step12: 非同期処理対応メソッドは、「慣例」で「~Async」と命名する(慣例なので必須ではない)。 public static async Task DownloadAsync() { Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md"); // Step8: ReadFromUrlから非同期対応のReadFromUrlAsyncに変更する。 // Step9: ReadFromUrlAsyncがTask<string>を返すので、awaitする。 // awaitすると、stringが得られる。 string body = await ReadFromUrlAsync(url); Console.WriteLine(body); }
# 補足: HttpClientではなくWebClientを使ったのは、対比させやすいためです。非同期前提で書くなら、始めからHttpClientを使うと良いでしょう。WebClientの代わりにHttpClientで同じ処理を書いてみるのは、良い練習になると思います。
出来るだけわかりやすくなるようにコメントを入れたので、「ひたすらめんどくさそう」に見えますが、よく見れば大した作業ではない筈です。ReadFromUrlとDownloadのそれぞれのメソッドが、どのように変形されるのかを見てください。そうすると何となく法則が見えてくると思います。
また、この作業で、2つのメソッド「ReadFromUrl」と「Download」が、両方とも非同期メソッド化された事にも注意してください。
つまり、特別な理由がない限り、非同期処理は呼び出し元から末端まで、「全て非同期メソッドで実装する必要がある」、と言う事です。ここで示した変形を行うと、連鎖的に、すべてのメソッドが非同期化されます。
これが核心です。以下にまとめます:
- 非同期対応メソッドが存在する場合は、そのメソッドを呼び出すように切り替えます。上記では、「OpenRead」を「OpenReadTaskAsync」に変えました。目的の非同期メソッドは、「TaskやTask<T>を返すメソッドがあるかどうか」や「~Async」と命名されているメソッドがあるかどうか、を目印に探すと良いと思います。また、この理由のために、メソッド名を「~Async」と命名する慣例が生きてきます。
- TaskやTask<T>は、そのままでは戻り値T型になりません。これらの値は「await」キーワードを使う事で、戻り値を得る事が出来ます。awaitを実行している時、つまりそれが「非同期的に結果を待機している」と言う事です。
- 自分で定義したメソッド内で「await」キーワードを使った場合は、メソッド先頭に「async」キーワードを追加する必要があります。「async」キーワードの効能はそれだけです!!! 以下のTask型云々は関係ありません。
- 非同期メソッドの戻り値は、Task型、Task<T>型、または「void」とする必要がありますが、voidは殆ど使用しません(次章)。
- 同期メソッドの戻り値の型がstringなら、Task<string>のように、ジェネリックなTask<T>型を使います。同期メソッドの戻り値がない(void)場合は、Task型(非ジェネリック)を返します。
これだけです。これだけですよ奥さん!!! 英語の動詞変形みたいな話です。この法則だけ覚えていれば、一旦同期的にコードを書いてみて、それから非同期処理に置き換えるという「練習」が出来ます。しばらくやっていれば、最初から非同期で全部書けるようになります。
ここまで、Task.Run()も、Task.Factory.StartNew()も、Task.Start()も、Task.ContinueWith()も、Task.Wait()も、Task.Resultも出ませんでしたね? これらを使う事はまずありません(断言)。
それでも、という方、では第二章へどうぞ。
第二章. どこからが非同期処理となるのか
非同期処理を記述した時に、どこからが非同期処理として扱われるか、あるいは、どこからを非同期処理として扱うのか、という話です:
// これはWPFのウインドウ実装です(但し、WPFであることはあまり重要ではありません): public partial class MainWindow : Window { // 参考: // Window.Loadedイベントの定義: // public event RoutedEventHandler Loaded; // RoutedEventHandlerデリゲートの定義: // public delegate void RoutedEventHandler(object sender, RoutedEventArgs e); // コンストラクタ public MainWindow() { InitializeComponent(); // ウインドウ表示開始時のイベントをフックする this.Loaded += MainWindow_Loaded; } // ウインドウが表示されると呼び出される private void MainWindow_Loaded(object sender, RoutedEventArgs e) { // 第1章で実装したReadFromUrlを呼び出して、テキストボックスに表示する Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md"); string body = ReadFromUrl(url); textBox.Text = body; } }
# XAMLは省略します。TextBoxコントロールに”textBox”という名前を付けて定義してある前提です(また、本当はデータバインディングでやるべきです)。
このコードだと、ダウンロードが完了するまで、UIが固まってしまいますね。では、これを非同期処理に対応させます:
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); this.Loaded += MainWindow_Loaded; } // Step3: メソッド内でawaitキーワードを使ったら、メソッドの先頭でasyncを加えなければならない。 // Step4: メソッド内でawaitキーワードを使ったら、戻り値の型はTask型で返さなければならない。 // しかし、Loadedイベントのシグネチャは変更できない。従って「void」のままとする。 // Step5: 非同期処理対応メソッドは、「慣例」で「~Async」と命名する。 // しかし、値を返さないためawait忘れの可能性はない事と、あくまで慣例であるため // ここでは「~Async」と命名しない。 private async void MainWindow_Loaded(object sender, RoutedEventArgs e) { Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md"); // Step1: ReadFromUrlから非同期対応のReadFromUrlAsyncに変更する。 // Step2: ReadFromUrlAsyncがTask<string>を返すので、awaitする。 // awaitすると、stringが得られる。 string body = await ReadFromUrlAsync(url); textBox.Text = body; } }
MainWindow_Loadedメソッドと、第1章のDownloadAsyncメソッドとの違いを確かめてください。Loadedイベントのシグネチャは「RoutedEventHandler」デリゲートであり、「delegate void RoutedEventHandler(object, RoutedEventArgs)」です。つまり、戻り値を返さない「void」であることが決められています。
非同期メソッドはTask型かTask<T>型を返すべきですが、ここでは型が合いません。値を返さない場合に限り、このような非同期メソッドを「void」で定義します。
ポイント:
- 戻り値を返さず「void」と定義するメソッドは、イベントをフックしたハンドラメソッドのような場合しかありません。戻り値をvoidとした場合、呼び出し元は非同期処理の完了を追跡できず、処理結果がどうなっても感知しません(例え、非同期処理中に例外がスローされた場合も、です!)
- 逆に考えると、そもそもイベントの呼び出し元は非同期処理の結果など気にしていません(voidなので)。だから、voidとしても良い、と解釈することもできます。
- 「Task」を返そうが「void」を返そうが、「await」キーワードを使ったので「async」キーワードを追加する必要があります。
設計上の観点で見た場合、処理の開始起点が、このハンドラメソッドにある事にも注目してください。このように、呼び出し元(WPFのLoadedイベント処理)が、呼び出し先(MainWindow_Loadedメソッド)の事について感知していないような場合にのみ、呼び出し先の非同期メソッドの実装方法を(voidとすべきかどうか)考える必要があります。
コンソールアプリケーションの非同期処理ではどうでしょうか:
// コンソールアプリケーションのメインエントリポイント // awaitを使っているのでasyncを追加する(?) public static async void Main(string[] args) { // Task型が返されるので、この処理を待機するためawaitする(?) await DownloadAsync(); }
第1章のDownloadAsyncメソッドを呼び出してみました。もし、この呼び出しをawaitで待機する場合、Mainメソッドの先頭にasyncと付ける必要があります。このコードは問題なくコンパイルできますが、実行すると(殆どの場合)ダウンロードが完了することなくプログラムが終了します。
Mainメソッドは、DownloadAsyncメソッドの非同期処理をawaitで待機します。しかしこれは「非同期的に待機」しているので、「Mainメソッドの処理(メインスレッド)は継続的に実行されている」のです。すると、メインスレッドはそのままMainメソッドを抜けてしまい、一瞬でプログラムが終了してしまいます。
したがって:
// コンソールアプリケーションのメインエントリポイント public static void Main(string[] args) { // Step1: Task.Wait()を使わざるを得ない。 Task task = DownloadAsync(); task.Wait(); }
このような場合にのみ、Task.Wait() 又は Task.Resultで待機する必要があります。GUIアプリケーション(WPF/UWP/Xamarinなど)を実装している皆さんには、まったく縁のない話なので、このことは忘れても構いません。GUIアプリケーションでTask.Wait() や Task.Resultを使うとデッドロックを起こします。
大事な事なのでもう一度: 「デッドロックします。使ってはいけません」
今Task.Wait() や Task.Resultを呼び出してうまく動いていますか? そうですか、いつかTaskに「後ろから刺される」覚悟はしておいた方が良いでしょう…
(ここでは詳しく解説しませんが、タイミングによって発生したりしなかったりします)
awaitで待機するように記述する事を「大前提」として、そうするにはどうやって書いたらいいかを考えると良いと思います。
なお、C# 7.1以上を使うのであれば、以下のようにもっと簡単に書けます:
// コンソールアプリケーションのメインエントリポイント // C# 7.1以上を使うなら、MainがTaskを返す事が出来る public static async Task Main(string[] args) { // 普通にawait出来る。Task.Wait()の呼び出しは不要 await DownloadAsync(); }
この場合でも、内部的にはTask.Wait()が呼び出されたのと等価になります。従って、GUIアプリケーションでこの方法は使えません。
でも、これで晴れて完全にTask.Wait()やTask.Resultから手を切る事が出来ましたね!
まとめ
これだけです。とりあえずこれだけで、「マトモ」な非同期処理コードを書くことが出来るはずです。繰り返しますが:
- C#の非同期処理(Taskとasync-await)を使ってコードを書くなら、これらの事を知っていれば9割がたの問題は回避できる!!
- ここで示した方法から外れたことをしようとしている場合は、殆ど間違いなく誤った記述をしようとしているので、もう一度何をしようとしているのか整理しよう!!
これまでの経験で、WPF・UWPなどのGUIアプリケーションで、ここで紹介した方法「以外の」方法がどうしても必要となるパターンは、「一度もありません」でした(多分現在に至るまでにC#で100万行近いコードを書いています)。
Task.Wait()やTask.Resultなどを必要としたケースは、MVVMフレームワークの設計時に、安全な非同期処理を実現するための、ごく内部的なコアロジックを設計したときだけです(普通の人がそんなコードを書く機会はない)。
なお、.NETの代表的なプラットフォームとして、ASP.NET (Core), WinForms, WPF, Xamarinが挙げられますが、これらだけに適用される手法ではなく、コマンドラインプログラムやUnity、独自の.NET環境など、すべての.NETの非同期処理で適用できる手法です。
次は何をすればいい?
これを読んで新たに疑問が湧いてくるかもしれません。例えば:
- どうしてデッドロックするのか?
- voidの非同期メソッドの結果はどうなるのか? 例外がスローされたらどうなるのか?
- ボタンクリックのハンドラをasync voidで書いたら、多重に呼び出されるようになってしまった…
- MVVMに非同期処理をどう適用すればいいのか?
- 非同期処理を使って高速化するにはどうすればいいのか?
そう言った疑問にすべて答えられるかどうか分かりませんが、様々な切り口で非同期処理を解説しているので、参照してみて下さい。
それでは!