LoginSignup
81
94

More than 1 year has passed since last update.

[C#]await利用時の同期コンテキストと実行スレッドの動きについてコードを動かして見ていく

Last updated at Posted at 2021-03-02
private async Task MethodAsync()
{
    Print($"1:Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() => Print($"2:In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"));

    Print($"3:After await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}

いきなりですが問題です。

上のコードには3つのPrint出力があり、それぞれの出力処理が行われる実行スレッドを出力します。
ではこのコードを実行した場合、それぞれの出力の実行スレッドはどうなるでしょうか?

①1~3全て同じスレッドになる
②1~3全て別のスレッドになる
③1,2が同じスレッドで、3だけ別スレッド
④1,3が同じスレッドで、2だけ別スレッド

ただし、WPFアプリケーションでの実行+このメソッドはメインスレッドから呼ばれるとする。

この記事を読むと、この問題の答えは何か?なぜそうなるのか?が理解できます。

はじめに

C#の非同期処理について調べだすと、「同期コンテキストが存在する場合、awaitは処理後に自動でスレッドを戻してくれる」、という説明をよく見ます。

自分はその説明を読んだだけではしっかり意味がわからなかったので、実際にコードを書いてみてその辺りの動きを確認しようと思いました。

その確認した内容を、自分の備忘録、兼、他の人の理解の助けにもなるだろうと思いまとめたのがこの記事です。

またawait利用時の同期コンテキストがわかれば、副産物としてTask.Wait, Task.Resultの利用が推奨されていない理由も理解しやすくるなるので、そちらについても書いています。

想定してる読者

  • C#の基本文法はわかっている
  • async/await, Taskの存在ぐらいは知っている
  • 記事の冒頭にある問題がわからなかった

環境

記事の中ででてくるコードはWPFで動かしています。
ただWindowsFormやUnityなどの"C#+GUI"のものなら理屈はほぼ同じなはずなので、その辺を普段使っている人にも読んでもらえると思います。

※CUIアプリケーションでは、この記事に書いてある内容が一部通じないので注意してください。

  • WPF (C#のGUIフレームワーク)
  • .NET Core 3.1

同期コンテキストとは何か?

「同期コンテキスト ≒ 複数スレッドに跨る処理を安全に行うための仕組み」です。

非同期処理は、うっかりすると、デッドロックや再現性の低いバグを起こしてしまいます。
このようなバグはほとんどがマルチスレッドの処理が原因で起きるものです。
同期コンテキストは、スレッド間の処理の受け渡しなどを上手く管理してくれる仕組みです。

つまり、同期コンテキストというのは、マルチスレッド処理が原因の不具合を避けC#の非同期処理を安全に使いやすくしてくれるための仕組みです。

C#では同期コンテキストの仕組みを扱うためにSystem.Threading.SynchronizationContextクラスとうものが用意されています。実は、awaitを利用している裏ではこのSynchronizationContextが良い感じにスレッド間の動きを調整してくれているのです。

awaitと同期コンテキスト

awaitを使ったときの同期コンテキストがどうなっているか見ていきます。

下のコードは、単純にawaitを使った場合について実行スレッドがどうなっているかを確かめるもので、記事の冒頭に書いた問題に、メソッドを呼び出すButton_Clickがついただけのコードです。
(画面上にButtonが一つ置いており、それを押すとButton_Clickが呼ばれるという状況です)

private async void Button_Click(object sender, RoutedEventArgs e)
{
    Print($"Button click. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await MethodAsync();
}

private async Task MethodAsync()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"));

    Print($"After await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}
実行結果
Button click. Thread Id: 1
Before await. Thread Id: 1
In task run. Thread Id: 5
After await. Thread Id: 1

awaitしているTask.Run()の中では、スレッドIdが異なっています。逆にawaitの前後では、スレッドIdが一致しています。
(なので、記事冒頭の問題の答えは「④1,3が同じスレッドで、2だけ別スレッド」でした。)

つまり、awaitには、「awaitする前後で実行スレッドを保存しておく機能」があります。
もっと正確にいうと、awaitには「System.Threading.SynchronizationContext.Currentがnullでない場合に、awaitの前後で実行スレッドを自動で保存してくれる機能」があります。

詳しくは後述しますが、WPFなどではメインスレッドに対して自動でSynchronizationContext.Currentがセットされています。そして上のコードではメインスレッド上でawait Task.Run()が実行されているため、その後にスレッドが元に戻っています。

自動でセットされているSynchronizationContext.Currentを、意図的にnullにした場合も見ておきましょう。

SynchronizationContext.Currentがnullの場合
private async void Button_Click(object sender, RoutedEventArgs e)
{
    Print($"ButtonClick. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await MethodAsync();
}

private async Task MethodAsync()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    // SynchronizationContext.Currentをnullに設定する。ここではメインスレッドに対して設定している。
    SynchronizationContext.SetSynchronizationContext(null);

    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"));

    Print($"After await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}
実行結果
ButtonClick. Thread Id: 1
Before await. Thread Id: 1
In task run. Thread Id: 5
After await. Thread Id: 5

await後にスレッドが元に戻らずTask.Run()の中と同じスレッドIdとなっています。
これは、awaitする前のスレッド(ここではメインスレッド)で、SynchronizationContext.Currentnullが代入されたためです。
※通常このような処理をする事はないと思います。ここでは説明のため、わざとnullを代入しています。

awaitにはその前後で実行スレッドを自動で保存してくれる機能があり、それは"SynchronizationContext.Current != null"の場合のみ働く。

この辺りの働きをさらに詳しく知りたければ、以下のページなどが参考になると思います。

そもそもSynchronizationContext.Currentはどこでセットされているのか

上でWPFなどではメインスレッドに対して自動で同期コンテキスト(SynchronizationContext.Current)が設定されていると書きました。
では、メインスレッドの同期コンテキストはどこで設定されているのでしょうか?

WPFの場合は、System.Windows.Threading.DispatcherSynchronizationContextSystem.Threading.SynchronizationContextクラスを継承)が、メインスレッドのSynchronizationContext.Currentとして自動で設定されています。

WindowsFormやUnityの場合もそれぞれ適当なクラスがメインスレッドの同期コンテキストとして自動で設定されています。そのため、メインスレッドでawaitを使った場合には、自動でスレッドが元に戻るようになっています。

CUIとGUIだと何故挙動が変わるか

環境の項目で、次のように書きました。

※CUIアプリケーションでは、この記事に書いてある内容が一部通じないので注意してください。

これは、上述したようにGUIアプリケーションではメインスレッド(UIスレッド)に対する同期コンテキストが自動で設定されているのに対し、CUIアプリケーションではそれが行われていないためです。
逆に言えば、根本的なawaitの働き自体にはGUIとCUIで差がありません。
CUIでも同期コンテキストが設定されていれば、await後にスレッドが元に戻ります。

await自体には、別スレッドに切り替える働きがないことに注意

ここまで見てきたコードで一つ勘違いしやすいポイントがあります。
それは、awaitには別スレッドに切り替える働きはないということです。

上のコード例では、awaitしている部分でスレッドIdが変化していますが、これはawaitの働きではなくTask.Run()の働きによるものです。Task.Run()は、その引き数に与えられたデリゲートをスレッドプール上で実行します。この働きによって実行スレッドが変わっています。

上のコードもよく見てもらうと、Button_ClickからawaitをつけてMethodAsyncが実行されていますが、Button_Click内と、MethodAsyncのawait前の部分で実行スレッドが変化していません。

スレッドプールについても一応簡単に説明しておきます。
スレッドは必要になったときにその都度新しくつくるより、最初にいくつかつくっておきそれを使いまわす方が効率が良いです。その使いまわしの仕組みがスレッドプールです。
C#にはTask.Runなどスレッドプールを簡単に使うために提供されている仕組みがあるので、基本的にスレッドプールも自分で直接操作する必要はなく、それらを使えば大丈夫です。
スレッドプールの働きなどについてもう少し知りたければ以下の記事がわかりやすいです。

awaitと同期コンテキストの働きのまとめ

単純にawaitを利用した場合の同期コンテキストの動きについてまとめておきます。

  • awaitには、その前後で実行スレッドを保存してくれる働きがある
    • 正確には実行スレッドではなく同期コンテキストを保存している
    • この働きは、System.Threading.SynchronizationContext.Currentnullでない場合にのみ働く
  • WPFなどではメインスレッドに対して自動で実行コンテキスト(SynchronizationContext.Current)が設定されている
  • awaitしただけでは、その処理の実行スレッドは変わらない
    • 実行スレッドが変化するのはTask.Run()などの機能

Task.Wait, Task.Resultが推奨でない理由

C#の非同期処理について調べると、「Task.Wait, Task.Resultは使ってはいけない」という記述がよく見られます。この理由は、awaitを使う際の同期コンテキストの働きを知っていれば、容易に理解できます。

Task.Wait, Task.Resultが推奨でない理由は、デッドロックが簡単に起きてしまうからです。
まず、Task.Waitを使ってデッドロックが起きてしまう例を見てみます。
例の中で行っているのは、

  • TaskをWaitすること
  • WaitされているTaskの中ではawait Task.Run()が使うこと

だけです。

Task.Waitでデッドロックするパターン
private void Button_Click(object sender, RoutedEventArgs e)
{
    MethodWait();
}

// awaitを使っていないのでasync不要。
private void MethodWait()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    var task = MethodAsync();

    // 結果待ちをするためにスレッドをロックして他のスレッドから触れなくしてしまう。
    task.Wait();

    Print($"After wait. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}

private async Task MethodAsync()
{
    // 処理終了後に元のスレッドに戻そうとするが、元のスレッドがTask.Wait()によりロックされており戻せない。
    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"));
}
実行結果
Before await. Thread Id: 1
In task run.Thread Id: 4
// (After wait.~は出力されずUIもフリーズする)

MethodAsync()内の処理まで実行された後、task.Wait()の部分で処理がフリーズしてしまっています。
これは、以下のようにお互いの処理待ちをしてしまうからです。

  • task.Wait側は、タスクの完了までスレッドをロックする
  • await Task.Run()~側は、処理終了し元のスレッドに戻して完了させたいが、元のスレッドがロックされているので戻せず完了できない
    • Task.Run()の働きにより別スレッドで実行されている。awaitはその後スレッドを元に戻そうとする。
    • 元のスレッドに戻すとこまでやって、このTask(MethodAsync()の戻り値)は完了となる

このように、awaitが自動で行ってくれている同期コンテキストの保存と、Task.Wait(Task.Result)によるスレッドのロックが組み合わさることによりデッドロック起きてしまいます。
そのため、awaitTask.Waitのどちらかの利用を避けたいところです。
ここで、awaitは有用な仕組みのため、Task.Wait(Task.Result)の利用を抑えます。
これが、Task.Wait, Task.Resultの利用が推奨されない理由です。

(Task.Resultがやっていることは、Task.Wait+結果取り出しです。つまり、デッドロックが起きる理由はTask.Waitと同様なため、ここでは解説を省略します。)

呼び出し元から非同期化することによってWaitを使わない。

Task.Waitは利用したくないと書きましたが、その一番シンプルな代替方法はawaitを使うことです。

Task.Waitではなくawaitを使う
private async void Button_Click(object sender, RoutedEventArgs e)
{
    await MethodAwaitAsync();
}

private async Task MethodAwaitAsync()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    var task = MethodAsync();
    await task;

    Print($"After wait. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}

private async Task MethodAsync()
{
    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"));
}
実行結果
Before await. Thread Id: 1
In task run.Thread Id: 4
After wait. Thread Id: 1

きちんとフリーズすることなく実行されました。
Task.Waitの代わりにawaitで処理完了を待っています。また、awaitを使うためにメソッドにasyncキーワードがついています。(asyncを使う場合は戻り値をvoidではなくTaskあるいはTask<TResult>としましょう。その唯一の例外は、上コードのButton_ClickのようにUIイベントにデリゲートを登録する場合だけです。)

この例のように、Task.Waitではなくawaitを使うようにしていくと、呼び出し元のメソッドもasync/awaitを使う必要があり、自然と一連の処理が全て非同期メソッドとなっていきます。
「呼び出し元まで全て非同期メソッドにしていってよいのか?」と迷うかもしれませんが、呼び出し元から非同期コードで統一することはMicrosoftのベストプラクティスでも推奨されています。 ぜひやりましょう。

Waitを使いたいならConfigureAwaitを使う

また、await後に元のスレッドに戻そうとすることでデッドロックが起きるなら、その働きをなくすことでもデッドロックを防ぐことができます。
やり方は、awaitしているTaskの後ろにConfigureAwait(false)をつけるだけです。

ConfigureAwait(false)を使ってデッドロックを防ぐ
private void Button_Click(object sender, RoutedEventArgs e)
{
    MethodWait();
}

// awaitを使っていないのでasync不要。
private void MethodWait()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    var task = MethodAsync();

    // 結果待ちをするためにスレッドをロックして他のスレッドから触れなくしてしまう。
    task.Wait();

    Print($"After wait. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}

private async Task MethodAsync()
{
    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"))
        .ConfigureAwait(false);

    // ConfigureAwait(false)の後なのでメインスレッドに戻らず実行される。
    Print($"After configure await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}
実行結果
Before await. Thread Id: 1
In task run.Thread Id: 4
After configure await. Thread Id: 4
After wait. Thread Id: 1

詳しくは下で説明しますが、ConfigureAwait(false)を利用することで、await後に元のスレッドに戻る働きをなくし、デッドロックを防ぐことができます。
(元のスレッドに戻らないのはConfigureAwait(false)を使ったasyncメソッド内だけです。)

基本的には上で紹介したように、そもそもTask.Wait(Task.Result)を使わずに全てawaitに置き換えることが理想です。
しかし、ライブラリ作成者と利用者が一致しない場合など、利用者側にそれを徹底できない場合があります。
そのため、同期コンテキストを保持する必要がない場合はConfigureAwait(false)をつけ、コンテキストに依存しないコードを書くようにしておくのが無難です。(特に誰が利用するかわからないコードを書く時は)

Microsoftのベストプラクティスでも以下のように書かれています。

... 可能な場合は常に ConfigureAwait を使用すべきであるということになります。コンテキストに依存しないコードは、GUI アプリケーションのパフォーマンスを向上し、部分的に非同期のコードベースに取り組む際のデッドロックを回避するのに役立ちます。この指針の例外は、コンテキストが必要なメソッドです。

awaitで同期コンテキストを保持しない

上で、Task.Waitawaitの併用によるデッドロックを避けるため、ConfigureAwait(false)オプションを使うと書きました。
これは、オプションをつけることにより、同期コンテキストを意図的に保持しないという選択になります。
この章では、その「同期コンテキストを保持しない」というところについて、もう少し掘り下げていきます。

※説明する人によっては、同期コンテキストを「保持しない」ではなく「キャプチャしない」、「拾わない」と表現している場合もありますが、同じ意味です。

ConfigureAwait(false)

まずは、先ほどもでてきたConfigureAwait(false)についてです。
凄くシンプルにConfigureAwait(false)を使った場合を見てみます。

ConfigureAwait(false)をつけると実行コンテキストを拾わない
private async void Button_Click(object sender, RoutedEventArgs e)
{
    await MethodAsync();
}

private async Task MethodAsync()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}")).ConfigureAwait(false);

    Print($"After await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}
実行結果
Before await. Thread Id: 1
In task run.Thread Id: 5
After await. Thread Id: 5

awaitしているTaskConfigureAwait(false)オプションをつけることで、await後に実行スレッドがメインスレッドに戻らなくなっています。
このように、awaitするTaskにConfigureAwait(false)オプションをつけると実行コンテキストが保持されなくなります。
(実行コンテキストを拾わない、キャプチャしないと言ったりもします。)

先ほども紹介したように、デッドロックなどを防ぐためには、このConfigureAwait(false)などを使って、実行コンテキストを保持しないことがとても重要になってきます。 C#で非同期処理をするならぜひ覚えておきましょう。

ConfigureAwaitの注意事項

同期コンテキストを保持しなくなるConfigureAwait(false)オプションですが、一点気を付けたい部分があります。
それは、あるasyncメソッド内で一度ConfigureAwait(false)を使うと、もとの同期コンテキストを復活できないことです。
コード例で見てみましょう。

ConfigureAwait(false)で同期コンテキストを捨てると復活できない
private async void Button_Click(object sender, RoutedEventArgs e)
{
    await MethodAsync();
}

private async Task MethodAsync()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() => Print($"In task run1. Thread Id: {Thread.CurrentThread.ManagedThreadId}")).ConfigureAwait(false);

    Print($"After await1. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() => Print($"In task run2. Thread Id: {Thread.CurrentThread.ManagedThreadId}")).ConfigureAwait(true);

    Print($"After await2. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
}
実行結果
Before await. Thread Id: 1
In task run1.Thread Id: 4
After await1. Thread Id: 4
In task run2.Thread Id: 5
After await2. Thread Id: 5

一つめのawait Task.Run()ではConfigureAwait(false)を使っているため、"After await1"のスレッドIdがメインスレッドと異なっているのは先ほどまでと同じです。
ただ、二つめのawait Task.Run()ではConfigureAwait(true)としているのに、await後の処理がメインスレッドに戻っていません。

asyncメソッド内にawaitが複数回ある場合、途中で同期コンテキストを捨ててしまって大丈夫か注意してください。

ContinueWith

上でConfigureAwait(false)を使うことによって同期コンテキストを保持しない方法について紹介しましたが、ContinueWithオプションでも似たようなことができます。

ContinueWithをオプションなしで使う
private async void Button_Click(object sender, RoutedEventArgs e)
{
    await MethodAsync();
}

private async Task MethodAsync()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"))
        .ContinueWith(_ =>
        {
            Print($"Continue. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
        },
            TaskScheduler.Default
        );
}
実行結果
Before await. Thread Id: 1
In task run.Thread Id: 5
Continue.Thread Id: 10

ContinueWithの第二引数にTaskScheduler.Defaultを渡すと、第一引き数で渡したデリゲートがスレッドプール上で実行されます。(ContinueWithの第二引数を省略した場合でも同じようにスレッドプール上で実行されます。TaskScheduler.Defaultが規定値なため)
ConfigureAwait(false)と違って同期コンテキストを捨てているわけではないですが、このようにして、await前のメインスレッドとは違うスレッドで実行することもできます。

逆にContinueWithを使って、await前のスレッドに明示的に処理を戻すこともできます。

ContinueWithをつかってawait前のスレッドに戻す
private async void Button_Click(object sender, RoutedEventArgs e)
{
    await MethodAsync();
}

private async Task MethodAsync()
{
    Print($"Before await. Thread Id: {Thread.CurrentThread.ManagedThreadId}");

    var currentSynchronizationContextScheduler = TaskScheduler.FromCurrentSynchronizationContext();
    await Task.Run(() => Print($"In task run. Thread Id: {Thread.CurrentThread.ManagedThreadId}"))
        .ContinueWith(_ =>
        {
            Print($"Continue. Thread Id: {Thread.CurrentThread.ManagedThreadId}");
        },
            currentSynchronizationContextScheduler
        );
}
実行結果
Before await. Thread Id: 1
In task run.Thread Id: 4
Continue.Thread Id: 1

await前に現在の同期コンテキストからTaskSchedulerをつくっておきContinueWithの引数として渡すことで、await前のスレッドで実行できていますね。

ContinueWithでは、このようにTaskSchedulerを使った柔軟なスレッドコントロールや、TaskContinuationOptionsを使った処理フローのコントロールが可能です。
同期コンテキストを捨てるだけならConfigureAwait(false)で十分ですが、複雑な非同期処理フローが必要になる場合にはこちらを使うと良いでしょう。

おわりに、まとめ

この記事では、awaitを使う際の同期コンテキストの働きについて、実際のコード例とともにまとめました。

記事冒頭の問題の答えは、「④1,3が同じスレッドで、2だけ別スレッド」です。
その理由は、以下です。

  • WPFではメインスレッド(UIスレッド)に対して同期コンテキスト(SynchronizaitonContext.Current)が自動で設定される
  • 同期コンテキストが設定されているスレッドでawaitを使った場合、await以後の処理に戻る際に同期コンテキストを保持する(≒実行スレッドを元に戻してくれる)
  • Task.Run()での処理はスレッドプール上で行われる(なので2は別スレッドで出力される)

また、GUIアプリケーションでTask.Wait, Task.Resultの利用が推奨されない理由は以下です。

  • Task.Wait(Task.Result)のTaskの処理が完了するまでスレッドをロックする働き」と「awaitの処理終了後に実行スレッドを元に戻して処理を完了させる働き」がぶつかり、デッドロックを起こすため

参考文献

この記事は、await利用時の同期コンテキストの働きという点に注目してまとめました。

非同期処理をさらに理解して使いこなすには、複数の人の解説を読んで、色んな角度から見てみるのが良いと思いますので、自分が参考にさせてもらった記事のリンクをいくつか貼らせてもらいます。

81
94
2

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
81
94