背景
以前Taskでのスレッドプール切り替え方法を軽く調べたが、状況を限定すればもっと他の方法もあるとわかってきたのでまとめた。今回も案出しレベル、参考資料まとめまで。
以前の記事:
前提
シングルスレッド動作もしくはタスクキュー(Fiber)を採用したゲームループ処理とawaitを使った非同期処理とを共存させる方法を検討する。プログラムはCUIベースでSDLとOpenGLを動かしていて、UnityやMonoGameは使っていない。
案1、SynchronizationContext.Currentを差し替える
C#でawaitをすると基本的にThreadPool上で動くが、UnityやWPFのようなGUI付きのものだとメインスレッドで動くらしい。このスレッド指定はTaskクラスが参照するSynchronizationContext.Currentという変数で実現されている。ここを差し替えるとawaitから戻るときのスレッドを指定できる。
自分がやりたいのはFiber(タスクキュー)指定であってスレッド指定ではないのでこの方法だとだめそう。
参考
https://qiita.com/NumAniCloud/items/6c99ab1d4ec8b8e1c8f8
「コルーチン用途にawaitを使うためのSynchronizationContext」
案2、独自のTaskクラスを作ってasyncメソッドの戻り値型として指定する。
独自のTaskクラスを作るのはUniTaskというライブラリが参考になりそう。あれはUnityに依存しているのでそのままは使えないが、参考にしてがんばれないだろうか。
参考
https://ufcpp.net/study/csharp/sp5_async.html
「非同期メソッドの内部実装」
https://qiita.com/inasync/items/6417933e258b53b5bbd3
「Generalized async return types (Task-like) の最小実装」
https://www.buildinsider.net/column/iwanaga-nobuyuki/009
「C# 7、そしてその先へ: 非同期処理(前編) - Task-like」
https://qiita.com/yaegaki/items/29ae5d01483a6d979e6a
「[C#]async/awaitとUniRx.Asyncの実装を追う」
https://ufcpp.net/blog/2016/12/tipscontextfreetask/
「小ネタ 同期コンテキストを拾わないTask型」
案3,SwitchToで切り替える
UniTaskでは await UniTask.SwitchToThreadPool();
という感じで動作スレッドを切り替えられるらしい。似たような記述方法で await fiber.SwitchToFiber();
とかやって切り替えられないだろうか。
参考
https://tech.cygames.co.jp/archives/3256/
「UniTask – Unityでasync/awaitを最高のパフォーマンスで実現するライブラリ」
https://stackoverflow.com/questions/15363413/why-was-switchto-removed-from-async-ctp-release
「Why was "SwitchTo" removed from Async CTP / Release?」
https://social.msdn.microsoft.com/Forums/en-US/642ffef6-d3ce-4010-978d-bc5d8b65c00f/where-are-the-switchto-extensions?forum=async
「Where are the SwitchTo() extensions?」
https://speakerdeck.com/torisoup/unitaskru-men?slide=43
「UniTask入門 」
https://qiita.com/tatsunoru/items/ec113ac9381032533268
「async/awaitでメモリ確保の起きないUpdate Loopを書く」
https://qiita.com/toRisouP/items/0463099d0441d2ffbe3d
「Unity UniRxとasync/awaitでフレーム管理」
https://qiita.com/toRisouP/items/45fb1dd09b80bd47a8a8
「Unityのコルーチンをasync/awaitで待機できるように変換してみる」
案4,Fiberに処理を投げてそれが完了するまでawaitで待つ
Fiber側が動いている間、awaitする側を止めてはどうか。こんな感じで。
var tcs = new TaskCompletionSource<int>();
fiber.Enqueue(() => {...; tcs.SetResult(1);});
await tcs.Task;
参考
https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcompletionsource-1?view=netframework-4.8
「TaskCompletionSource Class」
案5,FiberにSleep処理を投げてブロッキングさせる。await側処理が終わったらSleepを解除させる
awaitする側が動いている間、Fiber側を止めてはどうか。こんな感じで。
var tcsStart = new TaskCompletionSource<int>();
var tcsEnd = new TaskCompletionSource<int>();
var taskEnd = tcsEnd.Task;
fiber.Enqueue(() => { tcsStart.SetResult(1); taskEnd.Wait(); });
await tcsStart.Task;...
tcsEnd.SetResult(1);
usingブロックでラッピングすればより安全にできそう。
案6、await対象のタスク群にゲームループ実行処理を埋め込む。
C++のboost::asio::io_serviceがアイデア源。io_service.run関数を呼んだ時だけ処理が動くのを真似して、run関数を呼んだ時だけゲームループを動かすものを用意する。Updateを呼ぶなりFiberを消費するなり。run関数を呼ぶタイミングはawait中。run関数を実際に呼ぶのはあちこちのラッパークラス側。
まずはあちこちの非同期な待機処理の中でゲームループを動かしても大丈夫なものを探す。たとえばTask.Delay、Httpアクセス中。それらの待機処理と並列にio_service風クラスのrun関数を呼び出す。あとはawaitするだけで勝手にゲームループが呼ばれる、はず。
// これを
await Task.Delay(...);
// こうする
await io_service.Run(Task.Delay(...));
// ラッパークラスがあるならコンストラクタだけ変える。メソッド呼び出しは変更なし。
var hogehogeApi = new HogeHogeApi(io_service);
var response = await hogehogeApi.ReadHogeAsync();
...
// メソッド定義内部でio_service.runを呼ぶ。
public async Task<string> ReadHogeAsync()
{
var t = ReadHogeAsyncOriginal();
await this.io_service.Run(t);
return await t;
}
public async Task ReadHogeAsyncOriginal() { ... }
// io_serivce風クラスのメンバメソッド。・・・io_serviceという感じがしない。GameLoopクラスかもしれない。
public async Task Run(Task t)
{
// フレームレートがたがたになりそうなので、前回からの経過時間の考慮も必要になるはず。
while (!t.IsCompleted)
{
Update();
await WhenAny(Task.Delay(16), t);
}
}