0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

awaitした後、なぜそのままUIを更新できるの?

0
Posted at

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が少しだけ違って見えてきます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?