asyncメソッドと動作スレッド変化
async/awaitを採用した場合、awaitの前後で動作スレッドがたびたび変化する。この影響のうちOpenGL操作やPollEvent関数呼び出しのメインスレッド制約についてはSwitchToで対応できるので問題無い。しかし複数のasyncメソッドを実行した場合の実行順番について不安が残るので問題点洗い出しおよび対策検討する。
前提
コンソールプロジェクトベース。SynchronizationContextは利用しない。SynchronizationContext.Currentは常にnullになっているはず。
不安なところ
前回記事でも実行順番についていくつかは検討した。具体的には以下のもの。
- イベントハンドラ呼び出しをawaitで1つ1つ待機すること
- 定期更新処理を同期関数のままにすること
- 両者を同一のメインループ内で呼ぶこと
これらによってasyncメソッド実行中に他の処理がはさまれないようになった。しかしこれらはメインループ内の処理に限って有効なもの。メインループ前に起動されawaitされないままのasyncメソッドについては野放しになっていた。前回記事中のコードでいうとSomeTaskA/B/Cがそれにあたる。
メインループ用asyncメソッドとメインループ外の制御用途asyncメソッド、これらは基本的には並列で動きつつも狙った範囲は排他的に動かせるとよい。
案1,lock
排他制御と言えばlock. しかしlockは性能上の観点から利用できない。複数のawaitをはさむような長時間かかるかもしれない用途で使うとスレッドブロッキングの悪影響が大きい。
案2,動作スレッド切り替えによる排他制御
排他的に動かしたい処理を常にメインスレッドで動かすことで排他制御する案。たとえawait前後でスレッドが変化するとしても改めてSwitchToでメインスレッドに切り替え直せば問題ないという考え方。awaitから次のawait間またはメソッドの終わりまでの間はこれで対応できる。難点としては逆に複数のawaitを含む処理を排他できない。
性能的にはすこし非効率。例えば3つのasyncメソッドA/B/CがあってAとB、BとCがそれぞれ異なる排他制御区間を扱う場合、どちらもメインスレッドを取り合うことになる。これは lock (_lockObj) {...}
で言えば無関係なすべてのlockで1つのロックオブジェクトを共用するようなもの。かといって小分けするためにスレッドを大量に作るのもよくない。
ファイバー切り替え
スレッドの代わりにファイバーを使う方がよいかもしれない。 Queue<Action>
とそのActionのコンシューマスレッド処理をラッピングしたもの。中身は単なるQueueなので大量に作れる。
ファイバー作成とそこへのSwitchToイメージ:
var fiber = new PoolFiberSlim();
...
await fiber.SwitchTo();
ファイバー実装コード例: https://github.com/tosh-coding/AsyncFiberWorks/blob/b51ccb07796792f84998a919cdd17f2c6ac58983/src/Retlang/Fibers/PoolFiberSlim.cs
※もともとRetlangというライブラリにあったPoolFiberクラスを私が勝手に機能削減したバージョン。
ただ、性能面で改善したといってもawaitをはさめないことには変わりがない。SwitchToはスレッド切り替えのためだけのものと考えるのがよさそうに思える。
一時停止可能なファイバーへの切り替え
ファイバーおよびSwitchToを拡張して、指定asyncメソッド開始前にファイバーの消費処理を一時停止、await完了後にファイバー消費再開をすることを考える。これはファイバーに一時停止機能があれば実現できる。
一時停止機能を持つファイバー利用コード例:
※Pauseが一時停止メソッド、Resumeが再開メソッド。
※このようなコードを SwitchTo(Func<Task>)
などとしてでラッピングできるはず。
ただ、これは複雑すぎる気がする。無理にSwitchToやファイバーなどを遠用しようとするのではなく、async/awaitに適したものを新たに用意した方がよいのかもしれない。
案3,定期処理の購読
前回記事でイベントハンドラ呼び出しを1つずつawaitしたときの実装を流用することを考える。前回記事の該当コードからハンドラー関数の引数を無くすと下記のようなコードになる。
LinkedList<Func<Task>> TaskList;
async Task ExecuteTasks()
{
// 登録リストを複製する。ループ処理中にリストが追加削除されることがありえるため。
Func<Task>[] copiedList;
lock (this.TaskList)
{
copiedList = this.TaskList.ToArray();
}
foreach (var func in copiedList)
{
// 1つずつタスク完了を待つ。
await func();
}
}
コード実装例:
- https://github.com/tosh-coding/AsyncFiberWorks/blob/010cc68dbedda83dc6f58d75665d143a933dc5b5/src/AsyncFiberWorks/Procedures/AsyncActionDriver.cs
- https://github.com/tosh-coding/AsyncFiberWorks/blob/010cc68dbedda83dc6f58d75665d143a933dc5b5/src/AsyncFiberWorks/Procedures/AsyncActionList.cs
想定する動き方:このExecuteTasksメソッドをメインループで定期的に呼ぶ。メインループ外の制御用途asyncメソッドは排他制御したい処理をTaskListに登録する。するとメインループ側で他処理が実行されていないことを保証した安全なタイミングで登録処理を実行してくれる。呼び出しを終えたい場合はTaskListから登録を削除する。呼び出されたら削除することで1回限りの呼び出しにも対応できる。前回記事の EventRegister クラスを流用して定期的なawait待機もできるはず。
async/awaitには購読の仕組みが適しているのかもしれない。
awaitの連鎖について
asyncメソッド呼び出し時にawaitを付けると新たな呼び出し先が完了するまでは呼び出し元の進行が止まる。止まった方の処理は変数読み書きが起きないので、新たなメソッドで同じ変数を読み書きしても競合は起きない。たとえ下記のコードのようにasyncメソッドがネストしたとしても問題無し。
// これが最初に呼ばれたとする。
async Task func0()
{
await func1();
await func4();
}
async Task func1()
{
await func2();
await func3();
}
async Task func2()
{
...
}
async Task func3()
{
...
}
async Task func4()
{
await func5();
}
async Task func5()
{
...
}
動作スレッド中心の考え方とは異なる、動作asyncコンテキスト中心の考え方。標準クラスにThreadLocalクラスとAsyncLocalクラスの2つがあることからどちらの考え方もC#的には想定されたもの。ただし後者の方が新しく、よりasync/awaitとともに使うのに適している。
呼び出しタイミング
ExecuteTasks
メソッドをメインループ中のどこで呼ぶべきか。メインループ外の制御用途asyncメソッドでどのような処理を排他制御したいかしだいで変わるので、ひとまず可能な候補を洗い出してみる。
前回記事のメインループにExecuteTasks呼び出し候補を挿入:
void MainLoop()
{
...
ExecuteTasks1();
while (...)
{
ExecuteTasks2();
// イベント取得。
while (PollEvent(out var ev))
{
ExecuteTasks3();
// イベントハンドラ処理
OnEvent(ev);
ExecuteTasks4();
}
// ゲームループの次フレームまでの残り時間を求める。
var remainMs = GetTimeRemainingUntilNextFrame();
// 残り時間が0になったら定期処理実行。
if (GameLoopEnabled && (remainMs <= 0))
{
// 定期更新処理
OnEnterFrame();
// 残り時間更新。
ResetRemainTime();
ExecuteTasks9();
}
ExecuteTasks10();
// 何も処理が無かった場合に備えてちょっと休む。
Thread.Sleep(1);
}
...
}
// 定期更新処理
void OnEnterFrame()
{
ExecuteTasks5();
// 定期更新処理。
Update();
ExecuteTasks6();
// GUI描画部品の更新
UpdateGUIComponentAnimations();
ExecuteTasks7();
// グラフィクスリソースハンドルのフラッシュとレンダリング
FlushHandleAndRender();
ExecuteTasks8();
}
10箇所あった。それぞれで呼び出し頻度や特定処理の実行有無が異なるなど特色があり1つにしぼりきれない。とりあえずすべて採用してしまって都合の良いところを適宜利用するのがよいかもしれない。
UnityではAwake/Start/Updateなどタイミング別のハンドラーメソッドが利用できる。この方式なら似たようなことも再現できそうに思える。 Update();
をタイミング別に分割してExecuteTasksを挟み込んだり、そもそもUpdate処理自体をTaskList購読制に置き換えるなどして。
複数のTaskListを扱う時はそれ専用のタイミングで一括購読や一括購読解除した方がよいはず。呼び出し有無の挙動が安定する。
メインループ内のすべての処理を購読制に置き換えることもできそうだが、あまり変えすぎてもわけがわからなくなる。利点が思いつくまではできそうだの段階にとどめておくことにする。