async/await と SynchronizationContext (2)

前回SynchronizationContext の説明だけで async/await に絡んだ話が出来ませんでした。
今回はその続きになります。

まずは復習。
以前の記事で詳しくやりましたが、async/await は、以下のような特徴を持つ非同期処理の方式です。

  • コード上に明示的にコールバック関数が現れない
  • ほとんど同期処理と似た見た目を実現できる

コールバック関数が見当たらないのは、メソッドを分割して、自分自身をコールバック関数にしているからでしたね。

さて、同期処理とほとんど同じように書けるというからには、前回やった APM のように、現在のスレッドが UI スレッドなのかワーカー スレッドなのかを気にするようではいけません。
というわけで、当然ながら SynchronizationContext が一枚噛んでいて、「いい感じ」に処理されるようになっています。

以前のコードを再掲します(コメントは変えています)。

async Task AsyncSample()
{
  var client = new WebClient();
  
  var task1 = client.DownloadStringTaskAsync(url1);
  // ここまでは元のスレッドで実行される
  // ここから先は SynchronizationContext に従って実行される
  string url2 = await task1;
  
  var task2 = client.DownloadStringTaskAsync(url2);
  string result = await task2;
}

Task に対して await するコードは、コンパイラーによって

  1. GetAwaiter して
  2. OnCompleted でコールバック関数を登録して
  3. GetResult で結果を取得

という処理に変換されるのでした。
この時、GetAwaiter で元のスレッドの SynchronizationContext がキャプチャーされ、コールバック関数は SynchronizationContext.Post 経由で実行されます(TaskAwaiter がそういう実装をしています)。
つまり、WPFWindows Forms であれば UI スレッドで実行されますし、ASP.NET であれば HttpContext.Current が正常に設定されたワーカー スレッド上で実行されます。
これによって、後続の処理は、今どのスレッドで動いているかということを意識せずに済むようになっているのです。

ConfigureAwait

このように SynchronizationContext は大変便利なものですが、いいことばかりでもありません。
WPFWindows Forms では実行するスレッドが切り替わりますので、UI スレッドがそのとき何か別の処理をしていれば待たされることになります。
ASP.NET ではそれほどではないにせよ、追加の処理が入りますので、多少なりともパフォーマンスに影響が出ます。

そこで、SynchronizationContext 上で実行されなくてもよい処理については、そのままワーカー スレッドで継続することで、余計な処理を挟むことなく、トップ スピードで後続の処理を行う方法が用意されています。
それが Task.ConfigureAwait です。

先ほどのコードをこのように書き換えると

async Task AsyncSample()
{
  var client = new WebClient();
  
  var task1 = client.DownloadStringTaskAsync(url1).ConfigureAwait(false);
  // ここまでは元のスレッドで実行される
  // ここから先はダウンロードに使用したワーカー スレッドでそのまま実行される
  string url2 = await task1;

  // ... 
}

WebClient.DownloadStringTaskAsync は非同期処理ですから、裏でワーカー スレッドが作られて*1います。
ConfigureAwait(false) した Task に対して await すると、SynchronizationContext をキャプチャーせず、後続の処理をワーカー スレッドでそのまま実行します。

当然ですが、そうした場合、後続の処理の中で UI コントロールを操作したり、HttpContext.Current を使ったりすることはできません。
そのため、ConfigureAwait(false) は、プラットフォームの機能を一切利用しない、純粋なロジックのみを含んだクラス ライブラリで使うべきです。
使わなくても動くのですが、適切に使ったほうが若干速くなります。

さて、ここで問題です。
以下のようなコードを書くと、2 回目の await はどういう動きをするでしょうか?

async Task AsyncSample()
{
  var client = new WebClient();
  
  var task1 = client.DownloadStringTaskAsync(url1).ConfigureAwait(false);
  // ここまでは元のスレッドで実行される
  // ここから先はダウンロードに使用したワーカー スレッドでそのまま実行される
  string url2 = await task1;
  
  var task2 = client.DownloadStringTaskAsync(url2).ConfigureAwait(true);
  // ここは?
  string result = await task2;
}

ConfigureAwait(true) としているので、再び SynchronizationContext の影響下に戻るでしょうか?
いいえ、そうではありません。
既に一度 ConfigureAwait(false) をしているので、その部分はワーカー スレッドで実行されています。
ワーカー スレッドには SynchronizationContext がありませんから、ConfigureAwait(true) としても、SynchronizationContext をキャプチャーできないのです。
従って、そのままワーカー スレッドで動作し続けます。

そのため、クラス ライブラリでは、外部に公開しているメソッド中の、一番最初の非同期処理にだけ ConfigureAwait(false) をつければ十分です。

なお、この AsyncSample を呼び出した呼び出し元では、SynchronizationContext をキャプチャーしていますから、呼び出し元に書かれた後続処理は SynchronizationContext で動きます。
ConfigureAwait(false) の影響が及ぶのは、それを呼び出したメソッド以下のスコープに限られます。

参考資料

今回、デッドロック等については採り上げませんでした。
より深い理解のためには、以下のような記事をお読み頂くと良いと思います。

qwerty2501.hatenablog.com

async/awaitと同時実行制御ufcpp.wordpress.com

*1:実際にはスレッド プールから取得されます