c# と非同期処理ついての初歩的なFAQ

c# でサーバを書くのおすすめです。処理の並列化がとてもやりやすいです。

golangが登場したとき、小さなコード辺をスレッドを意識せずに投げまくれる上、タイマーやI/Oをスレッドをブロックせずに使える、そんなお手頃価格な並列処理に注目が集まっていたように思いますが、それ、c# もできます。昔から。Taskとそのスケジューラ、さらにそれらを文法に統合する基盤である async/await 。

c# も、.NET Core の登場でブランドイメージが一新されようとしており、linuxでも普通に使えるしDockerコンテナも公式から配布されているし、開発もGithubで公開、ドキュメントの日本語も流暢になってる。 ふと気がついたら、サーバと名のつくものは全てc#でまかなえる時代がいつのまにか来てます。

ところで、c# でサーバを書く場合、非同期処理の挙動を意識する機会が多かったりするので、自分が入門するときに持った疑問点をまとめてみようと思います。

疑問1 awaitで非同期処理を待つと、続きは元のスレッド再開する?

答え: 場合による

c# には、デフォルトでは「メインスレッド」という概念があるわけではありません。await文も、いずれかのスレッドを特別扱いする機能ではありません。

await で非同期処理を待った後、どのスレッドで再開するかは、フレームワークに依存します。 もうちょっと正確に言うと、実行コンテキストの設定に依存します。

関連してくる設定は主に2つです。

  • TaskScheduler → Task がどのように実行されるかスケジュールする
  • SynchronizationContext → await 後の続きの処理がどのように実行されるかスケジュールする

特に明示しない限り、スレッドごとに設定されている TaskScheduler.CurrentSynchronizationContext.Current の値が自動的に使用されます。 (ちなみに、こんなかんじで、スレッドごとにどこからでも参照可能な設定に挙動が依存するパターンを Ambient Context と呼んだりします)

Task とは?

Task は、処理のかたまりを表現したオブジェクトです。 多言語でいうところの Promise や Future と呼ばれているものと思ってもらって差し支えないと思います。 「未来に実行されるかもしれないし、実行中かもしれないし、完了してるかもしれない」そんな抽象的な処理のカタマリをオブジェクトとして表現することで、非同期処理のチェインや、エラー伝搬なんかがわかりやすくなる強力なパターンです。

このTask ですが、それ自体は、どのスレッドでどのようにスケジュールされるのかはとくに決まっていません。 それを決めるのは TaskSchedulerです。

TaskScheduler.Current は デフォルトでは ThreadPoolTaskScheduler が使用されます。 これは、Task.Run(..) した場合、.NET の共通言語ランタイム(CLR) が持っているスレッドプールで実行されるというスケジューラです。(めっちゃ性能良い

ちなみにこのスケジューラは、完了の順番は保証されません。

Task.Run(() => Console.WriteLine("Im task1"));
Task.Run(() => Console.WriteLine("Im task2"));
Task.Run(() => Console.WriteLine("Im task3"));

// 出力例:
// Im task 1
// Im task 3
// Im task 2

SynchronizationContext とは?

SynchronizationContext を使うと、並行に走る処理たちを、ある一定のポイントで同期させることができます。

たとえば、別のスレッドからの要求であったり、そもそも別のネットワークのマシンからの要求みたいなものを処理するために、一定の間隔で要求が到着してないかチェックし、あれば実行してあげる、みたいなことが必要になってきますが、SynchronizationContext を使うとこうした振舞いを表現することができます。

SynchronizationContextは、 .NET 2.0 時代から提供されていて、await とは分離されているものですが、await の再開時に、継続行がどのようにスケジュールされるかを決めるものとして使われてるので、そのように紹介されてることが多い印象です。

SynchronizationContext.Current のデフォルト値ですが、 nullです。

これが何を意味するかというと、 c# の await は、デフォルトでは 同期を取ったりはしない、ということ。

つまり、別スレッドで走ったら走りっぱなし。メインスレッドには帰ってこない。

Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

await Task.Run(() => Console.WriteLine("Im a task1"));
Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

await Task.Run(() => Console.WriteLine("Im a task2"));
Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

await Task.Run(() => Console.WriteLine("Im a task3"));
Console.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");

// 出力例:
// Thread: 3
// Im a task1
// Thread: 5
// Im a task2
// Thread: 4
// Im a task3
// Thread: 5

await で継続するたびに、実行スレッドがかわりました。

await は、内部的にはTaskとTaskのチェインなので、実行順番だけは保証されます。

しかし、どのスレッドに割り当てられるかは特に期待できないかんじです。

基本的には排他制御は必要ないわけですが、スレッドが変わってしまうということは覚えておくのが良さそうです。

疑問2. でも ASP.NET Core 環境では await すると元のスレッドで継続されるんでしょ?

答え: されません。もちろん処理の順番は保証されますが、元のスレッドで継続されるわけではありません。

現在の ASP.NET Core環境では、SynchronizationContext.Current はnullです。

ASP.NET Core SynchronizationContext この辺の説明に寄ると、パフォーマンスのためにリクエストスレッドでの同期というやりかたをやめたようです。

そもそも、ASP.NET Core は、1プロセスを多重化して 並行にリクエストを捌けるので、リクエスト時に、めちゃめちゃスコープが広いオブジェクトの状態を書き換えるようなことをするときは、排他制御が必要。ということになります。まあ、普通は状態を持つのは RDBMSとかRedisとかのちゃんとしたライブラリに任せるはずなので、意識する機会はほぼないかもいれませんが、知らないで シングルトンに書き込み可能な状態をつくってしまうとバグります。

疑問3. じゃあ await するとメインスレッドで継続される環境はあるのか?

答え: UI

UIはメインスレッドのメインループで描画が更新されるものなので、そういった環境では SynchronizationContextによって、メインスレッドから起動した非同期処理は継続時にメインスレッドに戻ってくるといった実装がなされているはずです。

つづく

c#の getter/setter はスレッドセーフか? とか、色々と書きたいことがまだあるんですが、ここからはTPLの話ではなくなってくるのでまた次回にしてみようとおもいます