C#で非同期処理を書いていると、こんなコードをよく見かけます。
private async void Button_Click(object sender, RoutedEventArgs e)
{
await LoadDataAsync();
StatusText.Text = "完了";
}
LoadDataAsync()の完了をawaitで待って、そのあと画面を更新する。
特に違和感のないコードです。
僕も普通にこう書いていました。
でも、async / awaitについて調べていくうちに、ふと疑問に思いました。
これ、なんで普通にUIを更新できるんだろう?
WPFでは、UI要素は基本的にUIスレッドから操作する必要があります。
別のスレッドから、
StatusText.Text = "完了";
のようにUIを直接操作することはできません。
一方でawaitは、処理が終わるまでUIスレッドを止めて待っているわけではありません。
では、
await LoadDataAsync();
StatusText.Text = "完了";
このawaitの後の処理は、
いったいどのスレッドで動いているのでしょうか?
今回は、実際にスレッドIDを確認しながら見てみます。
まず、awaitの前後を調べてみる
今回はWPFのイベントハンドラーで確認してみます。
private async void Button_Click(object sender, RoutedEventArgs e)
{
Debug.WriteLine(
$"await前 : {Environment.CurrentManagedThreadId}");
await Task.Delay(3000);
Debug.WriteLine(
$"await後 : {Environment.CurrentManagedThreadId}");
StatusText.Text = "完了";
}
例えば、実行結果がこうなったとします。
await前 : 1
await後 : 1
同じスレッドIDになりました。
でも、awaitしている間、UIスレッドを3秒間止めていたわけではありません。
Task.Delay(3000)の完了を待っている間も、UIスレッドは他の処理を行うことができます。
それなのに、awaitの後では再びUIスレッド上で処理が続いています。
だから、
StatusText.Text = "完了";
もそのまま実行できます。
ここで注意したいのは、
「awaitすると、必ず同じスレッドIDに戻る」
という意味ではないことです。
今回のWPFでは、awaitの前にUIスレッド上で動いており、そのときのUI側の実行環境に戻って続きを実行しているため、このような結果になります。
では、どうやってUI側へ戻っているのでしょうか?
awaitの後には「続き」がある
ここで、少し見方を変えてみます。
await LoadDataAsync();
StatusText.Text = "完了";
僕たちから見ると、
1.LoadDataAsync()を待つ
2.終わったらStatusTextを更新する
という、ごく普通の上から下へのコードに見えます。
でもawaitでは、
StatusText.Text = "完了";
というawaitより後の処理が、「非同期処理が終わったあとに実行する続き」として扱われます。
この「続き」を、よく継続(continuation)と呼びます。
では、この継続をどこで実行するのでしょうか?
そこで出てくるのが、
SynchronizationContext
です。
WPFではSynchronizationContextが使われている
名前だけ見ると、急に難しくなった感じがします(笑)
でも、今回は必要なところだけ見ていきます。
WPFでは、UIスレッド上でDispatcherSynchronizationContextというSynchronizationContextが使われます。
今回の記事では、
「awaitの後の続きを、UI側へ戻して実行するための仕組み」
くらいのイメージで考えてみます。
UIスレッド上でawaitすると、通常はそのときのSynchronizationContextが捕捉されます。
そして、待っていた非同期処理が完了すると、そのコンテキストを使って続きを実行します。
イメージとしては、こんな流れです。
WPFのUIスレッド
↓
await LoadDataAsync()
↓
いったん処理を抜ける
↓
UIスレッドは他の仕事ができる
↓
LoadDataAsync() 完了
↓
DispatcherSynchronizationContextを通して
UI側へ続きを渡す
↓
StatusText.Text = "完了";
つまり重要なのは、
「同じスレッドIDになる」ことそのものではなく、await後の処理がUI側のコンテキストで再開される
ということです。
そのためWPFでは、awaitの後でそのままUIを更新できます。
「awaitすると別スレッドへ移動する」わけではない
ここは僕が混乱しやすかったところです。
非同期処理という言葉から、
UIスレッド
↓
await
↓
別スレッド
↓
そのまま別スレッドで続く
というイメージを持っていました。
でも、必ずこうなるわけではありません。
そもそも、
await = 別スレッドへ処理を移す
ではありません。
awaitしている間にUIスレッドを占有し続けないことと、
awaitの後を別スレッドで実行することは、
別の話です。
ここを分けて考えると、かなり分かりやすくなりました。
Task.Runを入れてみる
では、Task.Runの場合はどうなるでしょうか。
private async void Button_Click(object sender, RoutedEventArgs e)
{
Debug.WriteLine(
$"await前 : {Environment.CurrentManagedThreadId}");
await Task.Run(() =>
{
Debug.WriteLine(
$"Task.Run内 : {Environment.CurrentManagedThreadId}");
Thread.Sleep(3000);
});
Debug.WriteLine(
$"await後 : {Environment.CurrentManagedThreadId}");
StatusText.Text = "完了";
}
例えば、実行結果がこうなったとします。
await前 : 1
Task.Run内 : 6
await後 : 1
ここが面白いところです。
Task.Runの中は、スレッドプールのスレッドで実行されています。
今回の例では、
Task.Run内 : 6
になっています。
ところが、
await Task.Run(...);
が終わった後は、再びUIスレッド上で続きが実行されています。
イメージすると、
UIスレッド
↓
await Task.Run(...)
↓
スレッドプールで処理
↓
処理完了
↓
UI側のコンテキストへ継続を渡す
↓
await後の処理
という流れです。
だから、
StatusText.Text = "完了";
をそのまま書くことができます。
ではTask.Runの中からUIを触ったら?
では、これはどうでしょう。
await Task.Run(() =>
{
StatusText.Text = "処理中";
});
これは問題になります。
Task.Runの中はUIスレッドではなく、スレッドプールのスレッドで実行されるからです。
WPFのUI要素は、それを作成したUIスレッド以外から直接操作することはできません。
必要であればDispatcherなどを使って、UIスレッド側へ処理を渡します。
例えば、
await Task.Run(() =>
{
Dispatcher.Invoke(() =>
{
StatusText.Text = "処理中";
});
});
のように書くことはできます。
ただ、
そもそもTask.Runの中からUIを更新する必要があるのか?
は別途考えた方がよいと思います。
今回は、
「Task.Runの中はUIスレッドではないので、UIを直接操作できない」
という点だけ押さえておきます。
では、awaitの後は必ずUIスレッド?
ここまで読むと、
awaitの後は必ずUIスレッドに戻る
と覚えたくなります。
でも、これは少し違います。
今回そのままUIを更新できたのは、
WPFのUIスレッド上でawaitし、UI側のSynchronizationContextが捕捉されていたから
です。
SynchronizationContextが存在しない環境もありますし、常に同じようにUIスレッドへ戻るとは限りません。
なので、
「awaitしたら必ず元のスレッドに戻る」
と覚えるのではなく、
「WPFのUIスレッド上では、通常、await後の続きがUI側で実行される仕組みがある」
くらいに理解しておくのがよさそうです。
もう少し深く調べていくと、
「では、awaitの後に元のコンテキストへ戻りたくない場合はどうするのか?」
という話も出てきます。
それは、また別の機会に整理したいと思います。
最初のコードをもう一度見てみる
最初のコードに戻ります。
private async void Button_Click(object sender, RoutedEventArgs e)
{
await LoadDataAsync();
StatusText.Text = "完了";
}
最初は単純に、
非同期処理が終わったから、そのあとUIを更新している
くらいに見えていました。
でも今回の内容を踏まえると、少し違って見えてきます。
1. UIスレッドで処理開始
2. LoadDataAsync()をawait
3. 完了を待っている間、
UIスレッドを占有し続けない
4. LoadDataAsync()が完了
5. UI側のコンテキストで
await後の続きを実行
6. StatusText.Textを更新
だから僕たちは普段、
await LoadDataAsync();
StatusText.Text = "完了";
と自然に書くことができます。
まとめ
今回は、
「awaitした後、なぜそのままUIを更新できるのか?」
について整理しました。
ポイントをまとめると、
・awaitは「別スレッドへ移動する」という意味ではない
・awaitより後の処理は、完了後に実行する「続き」として扱われる
・WPFではUIスレッド上でDispatcherSynchronizationContextが使われる
・UIスレッド上でawaitすると、通常はそのコンテキストが捕捉される
・非同期処理が完了すると、UI側で続きを実行できる
・そのためawait後では、そのままUIを更新できる
・Task.Runの中からUIを直接操作することはできない
・「awaitしたら必ず同じスレッドに戻る」と覚えるのは正確ではない
僕自身、
「awaitしている間、UIスレッドを止めていない」
というところまでは理解していても、
「じゃあ、なぜawaitの後で普通にUIを触れるんだろう?」
というところまでは、最初は考えていませんでした。
普段何気なく書いている、
await SomeProcessAsync();
StatusText.Text = "完了";
にも、ちゃんと仕組みがある。
そこが分かると、async / awaitが少しだけ違って見えてきます。