非同期処理を含む終了処理
特にアンマネージリソースを扱う場合において、終了処理(キャンセルを含みます)では非同期処理を行うことがよくあります。
-
IAsyncDisposableインターフェイスを実装するクラス -
System.Threading.ChannelsクラスのCompleteAsync()メソッドのような、適切なタイミングで明示的に実行する終了処理 - アンマネージリソースを使って終了時のログを書き込む場合に1回だけ行う
WriteAsync的な普通の非同期メソッドの実行 - RDB処理が終わったときに行うトランザクションの後片付け
最近のAPIにはAsyncメソッドが標準搭載されているものも多いため、終了イベントに行うメソッドをasyncにしてawaitしたくなります。しかし、一部の終了処理ではawaitすると処理の完了を待たずに終了してしまうこともあり、ハマることがあります。
本稿ではawaitしてはいけない処理を2つ紹介します。
WinFormsのFormClosingイベントおよびFormClosedイベント
WinFormsには画面を閉じるときのイベントとして以下があります。
-
FormClosingイベント- 画面を閉じる直前に発動します。
- キャンセルして閉じないことも可能です。
-
FormClosingイベントを抜けるまでに閉じるのをキャンセルされていなければ画面を閉じてFormClosedイベントに進みます。
-
FormClosedイベント- 画面が閉じたあとに発動します。
- もう閉じているのでキャンセルはできません。
- スタートアップオブジェクトの場合、
FormClosedイベントを抜けるとアプリケーションが終了します1。
イベントハンドラは当然ですがasyncにできます。asyncにして内部でawaitした場合、メソッドを一回抜けて非同期で処理を待ちます。
・・・おわかりでしょうか?FormClosingイベントもFormClosedイベントも、メソッドを抜けた段階で次のイベントに進んでしまいます。そのため、この2つのイベントハンドラでawaitするとその行以降の処理が行われずにそのまま画面が閉じます。スタートアップオブジェクトでこれをやると、awaitしているTaskが未完了でもアプリケーションが即終了します。
このため、FormClosingおよびFormClosedでは非同期処理をawaitしてはいけません。
コンソールアプリのCancelKeyPressイベント
コンソールアプリはCtrl+Cキーでアプリケーションを終了させることが可能です。このCtrl+Cキーが押されたときになにか処理をしたい場合、Console.CancelKeyPressイベントにイベントハンドラを登録できます。このイベントハンドラで終了をキャンセルすることもできます。なお、このイベントが抜けるとコンソールアプリは終了します。
・・・もうお気づきですね?WinFormsの場合と同様に、CancelKeyPressイベントのハンドラでawaitすると完了を待たずにアプリケーションが即終了します。
まとめ
以上、2つのケースを紹介しました。私はこの他にも、ほとんどFire-And-Forgetな長命のスレッド処理(アプリケーションと寿命がほぼ同じになるように作る1個だけのスレッドループ)をCancellationToeknSourceでキャンセルする場合に、Cancel()メソッド実行後に長命スレッドのTaskをWaitすることが多いです。このようなTaskはアプリを終了させるときとかリトライで長命スレッドを即復活させるときにしかキャンセルしないため、キャンセル処理全体が同期処理になりがちなのでWaitしてOKなためです。
「TaskはWait()やResultプロパティにアクセスしてはいけない」という説明はよくありますし私も全面的に同意です。ただしそれは通常の場合であってよくわかって使うならOKです。それが適切なケースならば適切なやり方を行いましょう。ただし、急にTask.Wait()されるとぎょっとするので**「終了処理のため同期的に行う」みたいなコメントがあると大変助かります**。
そもそも終了時に行わなければいけない処理なら、それが完了するまで正常終了させることはできません。終了時に行う処理は同期処理として実装するかTaskが完全に終わるまでWait()で同期的に待ちましょう。複数のTaskを終了させる場合はTask.WhenAll(~~).Wait()すれば並列したうえで同期的に待てばOKです。
ただしIsCancellationRequestedでキャンセルを判定してキャンセルされていたら終了処理する、みたいな場合にその中でawaitしているとデッドロックを起こす可能性があります。初期化処理や終了時処理はセンシティブなのでよく考えてプログラミングしましょう。
-
正確にはエントリポイントの
Runメソッドの次に行く ↩