ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) async/await を理解する

この記事は Qiita C# Advent Calendar 2021 の5日目の記事です。

はじめに

C# で async/await が登場してからずいぶんと時間がたち、モダンな C# においてはほぼ必須となりました。Unity でも UniTask などのライブラリもあり、簡単に非同期処理が書けます。この記事では C# での非同期処理の歴史にも触れつつ async/await の動作原理について書きます。

Unity C# の話を書いた方が需要が高そうなので Unity および UniTask を前提にした説明とコードが多く出てきますが、async/await は C# の言語機能であるため、動作原理自体は .NET でも同じです。非 Unity の文脈では適宜読み替えてください。

また、詳細を完璧に説明するよりもわかりやすさを重視したため、一部正確さを欠いた説明をしています。ご了承ください。

async/await の登場以前

GUI アプリケーションで重い計算処理をそのまま行うと、UI がフリーズしてしまうためバックグラウンドで処理を行う必要があります。今では async/await を使った非同期処理で記述することができますが、async/await が登場する以前はどのように行っていたのでしょうか?方法はいくつかありますが、原理的には以下のようにスレッドを立ててその完了をコールバック処理を登録する形で実行していました。

f:id:ikorin2:20211205070514j:plain

実際にはスレッドを自分で作るのはかなり非効率なのでスレッドプールを用いたり、Windows Forms では主に BackgroundWorker というクラスを使っていたりといろいろありますが、それらは全てレガシーな C# なのでここでは解説しません。重要なのは async/await 登場以前はデリゲートによるコールバックによって非同期処理 (バックグラウンドスレッドでの処理) を行っていたということです。

このコールバックによる非同期処理にはいくつか問題がありました。多段に非同期処理を書くと、完了コールバックの中で別の非同期処理を書き、さらにその中で完了コールバックを書くことになり、いわゆるコールバック地獄になり可読性が悪くなります。また、例外処理にも問題があり、非同期処理の呼び出し先 (別スレッド) で発生した例外を呼び出し元のスレッドまで伝播するのは困難でした。

これが async/await 構文の登場によって図の右側のように書けるようになり、上記の問題が非常に簡単になりました。

非同期処理とバックグラウンド処理

async/await が導入された当時、その主なモチベーションは重い処理をバックグラウンドスレッドで実行するのを簡単に記述したいということでした。Task クラスによる async/await が導入されてから、今のように Unity で UniTask が登場するまでにはやや時間があります。というのも、Unity で最も使用頻度の高い async/await の使い道はメインスレッド内での非同期処理だからです。

よく C#Task クラスの解説で言及されていることですが、「非同期処理」と「別スレッドで処理を行う」ことは異なります。

f:id:ikorin2:20211205043229j:plain

上記の説明で、何を言っているのかさっぱりわからない方は読み飛ばして大丈夫です。このあとの解説を読んで、再度この説明に戻ってくれば理解できると思います。

Unity の PlayerLoop 上での処理の流れ

ここからはしばらく Unity を前提とした話になります。以下の図は Unity の PlayerLoop (フレーム毎のループ) を簡易的に表した図です。

f:id:ikorin2:20211205044906j:plain

簡単のため、PlayerLoop には EarlyUpdate, Update, LateUpdate の3つしかタイミングがないとし、オブジェクトは3つだけしかないとしています。

青の矢印は、プログラムが実行される順番を表しており、1フレーム内では常に縦方向に処理が進んでいきます。一方、プログラマが実装する処理は、特定のオブジェクトが時間軸に沿って何を行うのかを記述したい場合がほとんどで、これは図の横方向の流れになります。プログラマが実装したい処理は横方向であるのに、プログラムは縦方向に書かざるを得ないために時間軸に沿った処理を実装するのが難しいのです。

しかし、async/await を使うとこれを横方向にプログラムを記述することができるようになります。

f:id:ikorin2:20211205044927j:plain

たとえば、await UniTask.Yield() はプログラムの処理を次のフレームにジャンプさせることができます。これを使うと、フレーム毎に処理を実行しつつ横軸方向にプログラムを記述することができます。また、await UniTask.DelayFrame(a) は指定したフレーム数だけ処理をジャンプすることができます。

ここで、await と async はそれぞれ以下のように考えることができます。

  • await: await した位置でプログラムの処理の流れを特定の時間までジャンプする
  • async: このメソッドの中で時間をジャンプしていることを示すマーク

このように考えると、少しは async/await が何を意味しているのか分かってきたような気がします。

コルーチンについて ここでは詳説しませんが、Unity での時間軸方向の処理は Unity のコルーチンというシステムによって async/await を使わずに書くことができます。上記の図のような処理はコルーチンでも同じことが実装できます。しかし、このあと解説する別スレッドとの連携や並行処理など、コルーチンでは記述できないものも async/await では記述できるため、上位互換として考えることができます。

await によるスレッド間移動

async/await は非同期処理はスレッドを移動することもできます。UniTask の場合は await UniTask.SwitchToThreadPool() によって別のスレッドにジャンプすることができます。

f:id:ikorin2:20211205052038j:plain

await の意味について「await した位置でプログラムの処理の流れを特定の時間までジャンプする」と前述しましたが、特定の時間というのは別スレッドの時間にすることも可能ということです。

同期コンテキスト

Unity を含め GUI のアプリケーションではメインスレッドは特別扱いされます。GUI に関するオブジェクトやデータは、パフォーマンス上の理由やフレームワークの実装上の都合でメインスレッドからしか扱えないように設計されているのが一般的です。つまり、バックグラウンド処理を行った後 UI を更新するためには、メインスレッドに戻ってくる必要があります。これを簡単に行うための仕組みとして、同期コンテキスト (System.Threading.SynchronizationContext) というものがあります。

ここで、UniTask と Task では同期コンテキストの扱いが異なります。

f:id:ikorin2:20211205061306p:plain

同期コンテキストは、スレッドに1つ存在するか存在しないかのどちらかです。Task は同期コンテキストをキャプチャし、UniTask はキャプチャしません。

[Task]

まず、上の図で、 Task を使ってメインスレッドから別スレッドで処理を開始する await Task.Run(action) について見ていきます。指定された action は別スレッド上で実行されますが、その後 await した呼び出し元に戻るときに必ずメインスレッドに戻ります。これが、同期コンテキストをキャプチャするということです。(図の左上)

ただし、await Task.Run(action) を開始したスレッドがメインスレッドでない場合、通常は同期コンテキストはメインスレッドにしか存在していないためキャプチャが起こりません。(図の左下)

また、メインスレッドから開始した場合でも同期コンテキストをキャプチャしない (=メインスレッドに戻らない) ように明示的に指定することもできます。(図の右上)

[UniTask]

一方で、UniTask は同期コンテキストをキャプチャしません。つまり、await しても勝手にメインスレッドに戻ったりすることはなく、メインスレッドに戻りたいときは自分で await UniTask.SwitchToMainThread() などを呼び出す必要があります。

async メソッドの戻り値

await をしているメソッドは、メソッドの定義に async とつける必要があります。この時、async なメソッドの戻り値の型として TaskUniTask と書きますが、この時実際に戻り値として関数から返されているオブジェクトは何なのでしょうか?

f:id:ikorin2:20211205061042j:plain

async はメソッド内で await によって処理がジャンプしていることを示すマークなのですが、それと同時にコンパイラがメソッドの内部を書き換えるということも示しています。

たとえば、上記の図の左側で、戻り値が async UniTask なメソッドについて考えます。これは引数で与えられた bool 値によって処理がジャンプする場合とジャンプしない場合があります。

  • true の場合: ジャンプせず、何も処理を行わずにメソッドを終了する。
  • falseの場合: 別スレッドにジャンプし、DoSomething()を実行する。

この時、コンパイラは async なメソッドを async を使わない形に変換するため、この二つの条件を満たすような UniTask を自動生成し、この UniTask を返すようなメソッドに書き換えて、async を消します。

一方、上記の図の右側のように、戻り値を async Task にしたメソッドの場合は以下のようになります。

  • true の場合: ジャンプせず、何も処理を行わずにメソッドを終了する。
  • falseの場合: 別スレッドにジャンプし、DoSomething()を実行する。その後、呼び出し元がメインスレッドならメインスレッドに戻る。

条件を満たす Taskコンパイラが自動生成し、async を使わない形に書き換えられることは同じですが、その Task の条件が異なります。Task は同期コンテキストをキャプチャするため、「呼び出し元がメインスレッドならメインスレッドに戻る」という処理が発生します。

await によるジャンプの実現方法

await によって時間軸をジャンプできることはわかりました。これは UniTask や Task の内部でどのように実現されているのでしょうか。

f:id:ikorin2:20211205063850j:plain

await による時間のジャンプはデリゲートをキューに入れ、ジャンプ先でデリゲートを取り出して実行することによって実現されています。ここで、一番最初に説明した async/await 登場以前の非同期処理を思い出してください。スレッドを越える時も、スレッドから戻ってくる時もデリゲートを渡し、デリゲートを実行することで非同期処理を行っていました。

つまり、Task や UniTask によって高度にラップされているものの、本質的には async/await と async/await を使わないコールバックによる従来の非同期処理は同じなのです。

async void はなぜ非推奨か

最後に、async void がなぜ非推奨なのかについて触れておきます。

f:id:ikorin2:20211205064913j:plain

async void Hoge() { ... } は「Hoge() メソッドの中で await はできるが、Hoge() メソッド自体は待機 (await) 出来ない」ことを表します。メソッドを待機できないとは、

  • その中で発生した例外をその外側に伝播できない
  • そのメソッドがいつ終わったか、正常に終わったかを検知する手段がない

ことを意味します。通常、これらの状態は避けなければなりません。逆に、これらを許容できる場合には async void と書いてもよいということになります。