最近のお仕事で、C#の非同期処理の書き方が分からずに、溶岩地帯に自爆していくコードを沢山みるようになったので、ケースとして日常風の記事にしてみました。
どんな風にハマってしまい、どうやって解決するのかが分かると思います。
結論
先に書いておきます。
- Task.Run()を使ってはいけません
- Task.Result, Task.Wait()を使ってはいけません
- Threadクラスを使ってはいけません
- async-await構文だけを使って書きます
- async voidにするのは特殊な場合だけです
がんばれー、わかってしまえば難しくない!
補足: もしあなたがJavaScriptで非同期処理を書いた事があるなら、その知識をそのまま生かせます。
JavaScriptではTaskの代わりにPromiseを使いますが、Promiseには上記1,2,3は存在しません。しかし、それで困ることはありませんよね? (これはとても重要な示唆です)
平和の終焉
以下のような実装を考えます:
// Web APIからデータを取得する public string FetchData() { // TODO: ここにWeb APIからのデータ取得コードを書く }
ここからコードを書こうとしている人は既に罠にはまっているわけですが、気が付けないので、そのままここに肉付けをしていくわけです。
Web APIにアクセスするには、今はHttpClientを使いますが、彼らは使い慣れているWebClientを使います:
// Web APIからデータを取得する public string FetchData() { WebClient wc = new WebClient(); string body = wc.DownloadString("https://example.com/api/data"); return body; }
出来ました。ただ、WebClientのドキュメントを見ると、今から使用するのは推奨しない、代わりにHttpClientを使うように、という事が書かれています。また、レビュアーに指摘されたり、ググったりして見ても、やはりもうWebClientは使うな、と言うような情報が得られます。そこでこれを書き換えようとするわけです。
(なお、今回の題材はWeb APIですが、例えばRedisだとかAzureのサービスとかで提供されるSDKとか、もう大半が非同期処理対応のAPIしか用意されていなかったりするので、Web API固有の話ではないという事に注意して下さい)
Day 1
Sync氏は、WebClientをHttpClientに書き換えていきます。しかし、GetStringがありません。代わりに、GetStringAsyncという似たようなのがあるので、これを使おうとします:
// Web APIからデータを取得する public string FetchData() { HttpClient hc = new HttpClient(); // エラー: 型が合わない string body = hc.GetStringAsync("https://example.com/api/data"); return body; }
GetStringAsyncというのを使ったら、型が合わないというエラーが出ます(幸いなことに? Sync氏はvarを使わないので、ここでエラーを検出)。よくよく見ると、Task<string>という型です。
ここで彼らの運命は分岐します。どちらも溶岩地帯です。
Day 2 – ピュアなResult
MSDN公式のTaskクラスのドキュメントを読むとサンプルコードがあり、どうやらResultプロパティで結果を取得できるらしい。
(すいません、この記事書いててちょっと頭クラクラしてます… 上のドキュメントは初心者をサイレントに殺しに掛かっているので、見ない方が良いです。リンクは消しました)
// Web APIからデータを取得する public string FetchData() { HttpClient hc = new HttpClient(); // Taskは良く分からないけど、Resultプロパティを見れば結果が得られそうだ // 型もばっちり一致して、コンパイルが通ったぞ! string body = hc.GetStringAsync("https://example.com/api/data").Result; return body; }
起きうる結果について:
- GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
- サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない
Day 3 – 分身するResult
GUIアプリケーションを苦労してデバッグしていくと、どうもResultが返ってこないことがあることが分かった。ASP.NETの方は、皆目見当がつかない。そこそこ良いVMを割り当てているのに、.NETって性能悪いの?それともWindows VMのせいなの? Linuxにすれば良いの?
そうは言っても納期が迫っているので、何とかしなければならない…
そうだ、ワーカースレッドでResult取得するところを分離すれば固まらなく出来るのでは? 性能上げられるのでは?? さっきのドキュメント読んだら、Task.Runを使えば簡単にワーカースレッド使えるみたいだし、これで行こう!
// Web APIからデータを取得する public string FetchData() { HttpClient hc = new HttpClient(); // Taskって便利だね!ワーカースレッド簡単に使えるんだ string body = Task.Run(() => hc.GetStringAsync("https://example.com/api/data").Result).Result; return body; }
起きうる結果について:
- GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
- サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2より悪化
Day 4 – async-awaitの存在を見出す
もうわかんない、HttpClientとかTaskとか何もわからない… Taskでググるとasync-awaitとか言うやつが出てくるから、これを使えばいいのかな…
// Web APIからデータを取得する public string FetchData() { HttpClient hc = new HttpClient(); // async-awaitの書き方本当にわからない... // 1日試行錯誤してやっとエラー出なくなった... string body = Task.Run(async () => await hc.GetStringAsync("https://example.com/api/data")).Result; return body; }
起きうる結果について:
- GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
- サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2ぐらい。多分この人はその違いを測定してない…
Day 5 – スレッド手動操作
わかったわかった、Taskなんて使うからダメなんだ。初めからThreadクラスを使えば良かったんだよ。
// Web APIからデータを取得する public string FetchData() { HttpClient hc = new HttpClient(); // Taskなんて意味わかんないし、これでいいや。 // でも何でこんなの使うの? WebClientでよくね? string body; Thread thread = new Thread(() => body = hc.GetStringAsync("https://example.com/api/data").Result); thread.Start(); thread.Join(); return body; }
起きうる結果について:
- GUIアプリケーション(WinForms, WPF, Xamarinなど): 一応動作はする。但し、Web APIアクセス中は固まる
- サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2と同じ
わけがわからないよ。どうしてみんなはそんなにHttpClientにこだわるんだい?
Day 6 – 真実
Sync氏「HttpClientだと固まったり性能が上がりません。HttpClientは動作が怪しいので多分バグってます。WebClientを使う方が安全です」
レビュアー「駄目です、HttpClientを使ってください。こう書きます」
// Web APIからデータを取得する public async Task<string> FetchDataAsync() { var hc = new HttpClient(); return await hc.GetStringAsync("https://example.com/api/data"); }
レビュアー「非同期処理を行うメソッドは、必ずTaskクラスを返すようにします。また、実際にはHttpClientはstaticで保持る必要があります。とりあえずこんな感じで他のも全部直してください」
Sync氏「え、でもこれだと、メソッド名も戻り値も変わっちゃってるので、呼び出し元にも修正が入ってしまいます」
レビュアー「呼び出し元も直してください。他にうまくやる方法はありません。実際には、メソッド名は直さなくても良いですが、直しておいた方が後々困らないです」
Sync氏「(絶望…)」
(補足: 文字通りです。大げさな話ではなく、他に方法はありません。試行錯誤は時間の無駄になります。警告しましたよ?)
Day 7 – GUIの真実
Sync氏は、必死にGUIのコードを直していた。
// コンストラクタ public FoolishForm() { // エラー: 型が合わない button.Click += this.Button_ClickAsync; } // ボタンがクリックされたら、データを取得して反映 public async Task Button_ClickAsync(object sender, EventArgs e) { // データを得る var data = await this.FetchDataAsync(); // テキストボックスに反映 this.textBox.Text = data; }
良い所まで来ていたが、Button_ClickAsyncメソッドをイベントハンドラとして登録するところで、型のエラーが発生して悩んでいた。
数時間後…
Sync氏「イベントハンドラの部分はどう書けばいいんですか?型が合わないと言われてしまいます」
レビュアー「こう書きます」
// コンストラクタ public FoolishForm() { // 問題ない button.Click += this.Button_Click; } // voidでいい / Task返さないので~Asyncも不要 public async void Button_Click(object sender, EventArgs e) { // データを得る var data = await this.FetchDataAsync(); // テキストボックスに反映 this.textBox.Text = data; }
Sync氏「え、でも非同期処理ではTaskを返さなければならないのでは?」
レビュアー「この場合だけ、つまりイベントハンドラだけ、async voidを使えます。この場合だけと考えてほぼOKです。他では使わないでください」
数分後…
Sync氏「試してみたら固まらなくなりました!ただ、動いているときにボタンを連打すると、何度もサーバーにアクセスしてしまうのですが」
レビュアー「古典的なJavaScriptの問題と同じで、アクセス中にボタンをクリックできないように仕様変更して下さい。実装はこんな感じにすれば良いと思います」
// ボタンがクリックされたら、データを取得して反映 public async void Button_Click(object sender, EventArgs e) { // まずボタンをクリックできなくする this.button.IsEnabled = false; try { // データを得る var data = await this.FetchDataAsync(); // テキストボックスに反映 this.textBox.Text = data; } finally { // ボタンをクリック可能にする this.button.IsEnabled = true; } }
Sync氏「ああなるほど! そうか、ロード中くるくる回るやつを追加で表示したほうが良いかもしれないですね」
レビュアー「そうですね。あと、async voidとする場合は、必ず例外をcatchして下さい」
Sync氏「何故ですか? 今はApplicationクラスの共通例外ハンドラで処理しているので問題ないと思いますが…」
レビュアー「その例外は、共通例外ハンドラでは処理できません。見たことの無い例外がデバッガのログに出てませんでしたか?」
Sync氏「ああ… UnobservedTaskExceptionというのが出てました。それも変なタイミングで表示されていた気がします」
レビュアー「私はこれから出張があるので詳しくは省きますが、メソッド内でcatchして処理するのがベストです」
(補足: この事については、別に記事を書きましたので、興味があれば参照してください)
Day 8 – サーバーサイドの真実
さらに数日後…
Sync氏「サーバー側の実装もわかりました、前は呼び出し元はこうしていたのですが」
[HttpPost] public string GetData() { // データを得る var data = this.FetchData(); // テキストボックスに反映 this.textBox.Text = data; }
Sync氏「こうすれば良いという事ですね?」
// APIエンドポイント名にAsyncとついていても、自動的に無視される [HttpPost] public async Task<string> GetDataAsync() { // データを得る var data = await this.FetchDataAsync(); // 返却する return data; }
レビュアー「はい、それで良いと思います」
Sync氏「GUI側と違って、サーバー側の実装は特に変わったことは無いんですね… もう一つだけ、わからない事があるんですが」
レビュアー「なんですか?」
Sync氏「今回の非同期処理対応で、結局Task.Runメソッドを使わなかったのですが、あれはいつ使うものなのですか? C#の非同期処理についてググってみると、大体Task.Runを使う例が載っているので使おうとしてああいうコード書いてしまったんですが…」
レビュアー「大量の計算だけを行う処理を並行して同時に処理させたい時だけに使います。我々が普段書いているコードで、そういう処理は殆ど無いですよね? なので、知らなくても良いレベルで使わない筈です」
(注意: 初稿ではHttpPost属性でAsyncを付けていない名前を明示していましたが、不要でした。Ovisさん、ありがとう)
輝かしい未来への第一歩
Sync氏に横たわっていた暗い霧は、今晴れわたった。そこにはまだ何も存在しないが、やっとスタート地点に立った。そう感じたのである…
お約束の…
ここで挙げた例は、どうしてTask.RunやTask.Resultを使いたがってしまうのかという所に焦点を当てているため、実際にはもっと効率の良い実装テクニックが色々ありますが、最低限このように書けば、Taskをいじり倒して溶岩地帯に突っ込んで死亡する、ということは無くなると思います。なお、ASP.NET (Core), WinForms, WPF, Xamarinについて取り上げましたが、これらだけに適用される手法ではなく、コマンドラインプログラムやUnity、独自の.NET環境など、すべての.NETの非同期処理で適用できる手法です。
もう少しかっちりした解説が必要なら、こちらを参照してください。
では、いつものやつ、行ってみましょう:
Task.Run()も、Task.Factory.StartNew()も、Task.Start()も、Task.ContinueWith()も、Task.Wait()も、Task.Resultも出ませんでしたね? これらを使う事はまずありません(断言)。
今、記事冒頭の「結論」の項を読み返せば、納得できると思います。「C# 非同期処理」とか「C# async await」などでググってみると、Task.Run()やTask.Resultを最初に説明したり引き合いに出す記事の何と多い事か… これらは本当に、知らなくて良いんですよ。
もし、自信が無くなってきたら、JavaScriptにもPromiseとasync-await構文があるのに、これらに該当するものは無くても実用出来ているということを思い出すと良いと思います。
(更に補足すると、.NET/C#の場合は、async-awaitよりも先にTaskが生まれてしまった事が、不幸の始まりだったのです)
(≒構造上の欠陥という認識
— Kouji Matsui (@kekyo2) February 21, 2022
この記事で少しでも救われる人が増えますように。
それではまた。