前回の記事では、
awaitは待っているのに、なぜ画面は止まらないのか?
について紹介しました。
例えば、
await Task.Delay(5000);
では、5秒間待っていても、その間ずっとUIスレッドを占有しているわけではありません。
そのため、待っている間も画面の描画やユーザー操作など、UIスレッドは別の仕事をすることができます。
では、
async
await
を使えば、時間のかかる処理でも画面は固まらないのでしょうか?
実際にコードを書いてみると、
asyncを付けたのに画面が固まる……
awaitも使ってるのに……
ということがあります。
今回は、
非同期処理を書いたのに、なぜ画面が固まるのか?
について、処理の流れから考えてみます。
まず、こんなコードを見てみる
例えば、ボタンを押したときに時間のかかる計算をするとします。
private async void Button_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "処理中...";
HeavyCalculation();
await Task.Delay(1000);
StatusText.Text = "完了しました";
}
asyncがあります。
awaitもあります。
一見すると、
非同期処理になっている
ように見えます。
でも、HeavyCalculation()がUIスレッド上で長時間かかる処理だったらどうなるでしょうか。
処理の流れを追ってみます。
ボタンを押す
↓
「処理中...」
↓
HeavyCalculation()
↓
重い計算を実行
↓
計算が終わるまでUIスレッドを使い続ける
↓
await Task.Delay(1000)
↓
「完了しました」
問題なのは、
HeavyCalculation();
です。
awaitまで到達する前に、UIスレッドで重い処理を実行しています。
つまり、
asyncを付けたからといって、そのメソッドの処理が最初から全部非同期になるわけではありません。
asyncは「別スレッドで実行して」という意味ではない
ここは、async / awaitで勘違いしやすいポイントです。
例えば、
private async void Button_Click(object sender, RoutedEventArgs e)
{
HeavyCalculation();
await Task.Delay(1000);
}
と書いても、
Button_Click
↓
自動的に別スレッドへ移動
↓
HeavyCalculation
となるわけではありません。
asyncは、
メソッド全体を別スレッドで実行するための指定ではありません。
前回の記事でも触れましたが、最初のうちは、
このメソッドではawaitを使って、非同期処理の続きを書ける
くらいに考えると分かりやすいと思います。
そのため、awaitに到達するまでの処理は、通常そのまま呼び出したスレッドで実行されます。
WPFのボタン操作から呼ばれたのであれば、そこはUIスレッドです。
awaitがあっても、その前で止めたら画面は固まる
例えば、
private async void Button_Click(object sender, RoutedEventArgs e)
{
Thread.Sleep(5000);
await Task.Delay(1000);
}
これもasync / awaitを使っています。
でも、
Thread.Sleep(5000);
でUIスレッドを5秒間止めています。
その後に、
await Task.Delay(1000);
があったとしても、
最初の5秒間がなかったことにはなりません。
Thread.Sleep
↓
UIスレッドを5秒止める
↓
画面が固まる
↓
5秒後
↓
await Task.Delay
↓
ここではUIスレッドを占有せず待てる
ということです。
つまり、
コードの中にawaitがあるかどうかだけでは、画面が固まるかどうかは判断できません。
「待つ処理」と「計算する処理」は少し違う
ここで前回の記事を思い出してみます。
前回は、
Thread.Sleep(5000);
と、
await Task.Delay(5000);
を比較しました。
Task.Delayは、
時間が経過するのを待つ処理
です。
ほかにも、
・Web APIから結果が返ってくるのを待つ
・ファイルの読み書きが終わるのを待つ
・データベースから結果が返ってくるのを待つ
などがあります。
こうした処理では、
待っている間ずっとCPUを使って計算する必要はありません。
一方、
HeavyCalculation();
のような重い計算は違います。
例えば、
100万件のデータを計算する
画像を変換する
大量のデータを解析する
といった処理では、
CPUが実際に仕事をし続ける
必要があります。
だから、
待っている時間をどうするか
と、
重い計算をどこで実行するか
は、分けて考える必要があります。
コックさんで考えてみる
前回は、コックさんとオーブンでasync / awaitを考えました。
お肉をオーブンに入れたら、
お肉をオーブンに入れる
↓
焼き上がるまで待つ
↓
その間、コックさんは拘束されない
↓
厨房では別の仕事ができる
↓
焼き上がったら料理の続きをする
という話でした。
これは、
待ち時間
がある仕事です。
では、こんな仕事だったらどうでしょう。
玉ねぎ100個をみじん切りにする
コックさん自身が、
切る
切る
切る
切る
切る……
と作業し続けなければ終わりません。
ここで、
「これ、asyncな仕事だから!」
と言っても、玉ねぎは勝手に切れてくれません(笑)
コックさんがその仕事をしている間、ほかの仕事はできません。
プログラムでも似ています。
待っているだけの処理と、
CPUを使って実際に計算し続ける処理では、
考え方が違います。
重い計算はどうすればいい?
では、UIスレッドで重い計算をしたくない場合はどうするのでしょうか。
一つの方法として、
private async void Button_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "処理中...";
await Task.Run(() =>
{
HeavyCalculation();
});
StatusText.Text = "完了しました";
}
のように、Task.Runを使う方法があります。
この場合、
UIスレッド
↓
Task.Run
↓
HeavyCalculationを別のスレッドプール上で実行
↓
UI側はawait
↓
UIスレッドを占有せず待つ
↓
HeavyCalculation完了
↓
awaitの続きへ
↓
「完了しました」
という流れになります。
これなら重い計算をしている間、UIスレッドがその計算に占有されません。
そのため、画面が応答できるようになります。
何でもTask.Runにすればいい?
ここでもう一つ注意があります。
では、
時間がかかる処理は全部Task.Runで囲めばいいの?
というと、そういうわけではありません。
例えば、もともと非同期APIが用意されているなら、
var data = await GetDataAsync();
のように、その非同期APIをそのままawaitするのが基本です。
それをわざわざ、
await Task.Run(async () =>
{
await GetDataAsync();
});
とする必要は通常ありません。
ざっくり分けると、
通信・ファイルアクセスなど
「結果を待つ時間」が長い
↓
用意されている非同期APIをawait
重い計算など
「CPUが仕事する時間」が長い
↓
必要に応じてTask.Runなどで
UIスレッドから切り離す
と考えると整理しやすいと思います。
もう一つ注意:UIはUIスレッドから操作する
重い処理をUIスレッドから切り離せばいいなら、
こんなコードを書きたくなるかもしれません。
await Task.Run(() =>
{
HeavyCalculation();
StatusText.Text = "完了しました";
});
でも、WPFなどのUIでは、
UI要素は基本的にUIスレッドから操作する必要があります。
そのため、
await Task.Run(() =>
{
HeavyCalculation();
});
StatusText.Text = "完了しました";
のように、
重い計算を終えてawaitから戻ったあとに、UIを更新する形の方が分かりやすいです。
つまり、
UIの仕事
↓
重い処理を別の場所へ
↓
await
↓
処理完了
↓
UIの仕事へ戻る
というように、どの処理をどこで実行しているのかを意識することが大切です。
デバッグするときは「awaitを探す」のではなく流れを見る
例えば、
async / awaitを使っているのに画面が固まる
という問題が起きたとします。
そんなときは、
async
や、
await
があるかどうかだけを見るのではなく、
処理の流れを追ってみます。
ボタン押下
↓
何を実行している?
↓
どこで時間がかかっている?
↓
その処理は「待ち」?
それとも「重い計算」?
↓
どのスレッドで実行している?
↓
どこでawaitしている?
こうやって見ていくと、
awaitより前に重い処理をしていた
とか、
Thread.SleepでUIスレッドを止めていた
とか、
CPUを使う処理をUIスレッドで実行していた
といった原因が見えてきます。
大切なのは、
「async / awaitを使っているか」ではなく、
「UIスレッドが何をしているか」を見ること。
です。
まとめ
今回は、
非同期処理を書いたのに、なぜ画面が固まるのか?
について紹介しました。
覚えておきたいポイントは、
・asyncを付けてもメソッド全体が自動的に別スレッドになるわけではない
・awaitに到達する前の重い処理でもUIは固まる
・awaitがあっても、UIスレッドをブロックする処理があれば画面は固まる
・「結果を待つ処理」と「CPUを使って計算する処理」は分けて考える
・重い計算では、必要に応じてTask.RunなどでUIスレッドから切り離す
・UIの更新はUIスレッドで行う
です。
async / awaitを見ると、
非同期処理だから画面は固まらない
と思ってしまうかもしれません。
でも、見るべきなのはasyncという文字ではありません。
その処理は、どのスレッドで何をしているのか?
ここを見ることが大切です。
非同期処理で画面が固まったときも、
処理の流れを一本の線で追っていく。
そうすると、原因を見つけやすくなると思います。
関連記事
前回
awaitは待つのに、なぜ画面は止まらないの?|C#のasync / awaitを理解しよう
今回の記事の前提になる内容です。
その前
処理は順番に動くとは限らない?|新人・初級PG向け 同期・非同期の基本
同期処理と非同期処理の違いから整理しています。
処理の流れを追う考え方
ソースは読めるのに処理が追えないのはなぜ?|フローを追っていたはずなのに迷子になる原因
処理は追えるのに「なぜそれをしているのか」が分からない人へ | コードから目的を読み解く考え方