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した部分が解除されてしまい、処理が動き出してしまうのである。

0 件のコメント:

Androider