2019年9月12日木曜日

C#のasync/awaitを克服する

async/awaitって簡単な記述に見えて意外と難しい。。
ということでブログにてメモをまとめておく。

まず、async/awaitの挙動について。
関数の頭にasyncを付けて、Task処理をawaitつけると
コンパイルする段階でステートマシン生成して非同期処理を構築してくれる仕組みである。

1つ工夫があって、awaitしたTask処理を別スレッドで動かせて、戻ってくるときはスレッドを戻せるように作られている。

ここがすごく重要ポイントで、
①awaitしたTask処理を別スレッドで動かせる
②戻ってくるときはスレッドを戻せる

ここをしっかり理解しておかないと意図しない動きになってしまいます。

一体どういうことなのかというと、

Task.Runしていないタスク関数は別スレッドで実行するようになっていない
→ハマりポイント①
 async/await使ってるのになぜか同じスレッドを継続してしまう場合は大体これ。
 例えばGUIのイベント関数をasyncした場合、最初はUIスレッドで動いている。
 awaitポイントに到着した時にタスク処理を実行するんだけど、
 なぜかUIスレッドのままの場合があって、それが原因でデッドロックに陥る事がある。
 その時は大体Task.Runを使って明示的に別スレッドで実行します宣言すれば良い。

タスク処理が終わったあとの挙動だけど、
awaitするタスク関数の後ろにConfigureAwait(false)を付けると、
呼び出し元のスレッドには戻らずにタスク処理したスレッドを継続する。
→ハマりポイント②
 コンテキストスイッチを行うかどうかの設定。
 主に処理速度を優先したい場合は使っておくと早く動作してくれる。

上記内容で、async/await,Task.Runの仕組みが分かったと思う。

次にWinRT系、Win10とかUWP、.Net core界隈の技術を使うと新しく出てくるインターフェイス
 IAsyncOperationWithProgress
 IAsyncOperation
 IAcynsActionWithProgress
 IAsyncAction

これらは非同期ステートマシンで実装されているけど、Task.Runをしていないのでイベント関数をasyncしてawaitしちゃったりするとデッドロックする。なんでデッドロックするのは最初の説明で分かると思うけど、
イベント関数はUIスレッドで処理され
IAsyncOperation/IAsyncAction系のタスクをawaitで実行した先もUIスレッドだから。

つまり自分自身はawaitでWait状態になっているところにUIスレッドで実行したい処理をPostしたりするからデッドロックなんだろうって事ですね。。

この辺の実装でうまく回避するならば、TaskAwaiterを自作してWait処理の所を定期的にawait Task.Run()とかで処理を逃がしてあげるか、UIスレッドに触れる部分は1つのAwaitだけにしておいて、UIスレッドと実行するタスク処理を明確に切り分けておく必要がありそう。
WinFormのような方式ならApplication.DoEvents()関数をコールすれば済むんだけどね!


追記:
上記の事を守っているのに、await厨なところがすり抜けてしまう現象がある。
これはawaitってセマフォでWaitしているんだって思えば理屈は分かるだろうか。

Async/Awaitの視点がプログラムフロー視点ではなくスレッド視点だから起こりうる現象で、1スレッドあたり1セマフォ。つまり1回に限りコンテキストスイッチ切替が許されているわけ。

現象のメカニズムは大体こんな感じで
1.UIスレッドがawait中に別スレッド内でUIスレッドを呼び出す。
2.その別スレッド内で動かしたUIスレッド内でawaitするとすり抜け現象が起きる。
3.UIスレッド内で2重awaitが発生する。

2重awaitが発生すると最初のawaitした部分が解除されてしまい、処理が動き出してしまうのである。

2019年9月10日火曜日

【Winform】別スレッドからGUIを操作する

意味が分かっていても上手く動いてくれずにデッドロックするケースが多い問題である
「別スレッドからGUIの操作」のお話ですが、ようやく腑に落ちる対処方法が分かったのでメモ。

【問題の背景】

基本ですが、アプリは必ずメインスレッドが存在していてWinformだとスレッドID=1になる。
これがProgram.csの先頭から実行してGUIを表示したりする。

んで、画面上のボタンを押したら別スレッドが起動して重い処理を実行する。

その別スレッド内部でエラーが発生した場合、
例外を投げてtry~catch先でエラーハンドリングするなら特に問題なし。

別スレッド内部でエラーハンドリングを行ってしまおうってするとデッドロックしてしまうケースがある。

【ピンポイントな原因】

なぜデッドロックしてしまうかというと、メインスレッドが別スレッドの完了待ち状態でロックしているから。

これに尽きる。

言い換えれば、
メインスレッドは別スレッドの完了待ち状態で、
別スレッドはメインスレッドにGUI操作を要求したからデッドロックに陥っている。。

ここまで分かればひとまずOK。

~~余談~~
非同期に処理して、別スレッドから完了通知を受け取る方式なら回避できる。
この実装はいわゆるasync/awaitになるのだけど、理解するのに結構手間取るんだけどね。。

【デッドロック確認方法】
デッドロックした場合、デバッガ上で確認する事が出来る方法があって、
デバッガ上のスレッド一覧にあるメインスレッドがソース上のどの位置にいるかで判断が付く。

現在のスレッドが1以外の別スレッドで
メインスレッドの現在位置が
 Application.Run(new Form());

ならば問題なし!

もし、Wait(); とかそういう場所で止まっているなら必ずデッドロックが発生します。

【対処法】
そのデッドロックポイントを見つけたらInvokeRequireとかTaskのasync/awaitを用いて回避するのが一番簡単。

意外とこの仕組みを理解しておかないと色んなプログラムで同じようなハマりポイントでつまずくと思うので、ぜひとも押さえておきたいポイントです。

Androider