設計の背景
FlowTask の主な設計の判断について、なぜそうしたかと、その代償をまとめます。挙動そのものは各ページに書いてあります。ここでは「なぜこうなっているのか」が気になったときに、その理由を確かめられるようにします。
キャンセルを例外で巻き戻す
Section titled “キャンセルを例外で巻き戻す”取り消されたフローは、止まっている await から FlowCanceledException を投げ、finally と using を走らせながら抜けます(スコープとキャンセル)。
理由
- C# で、async メソッドを途中の await から
finallyとusingを走らせながら抜ける手段は、例外の送出しかありません。 FlowCanceledExceptionはOperationCanceledExceptionの派生です。.NET と UniTask で定番の書き方(catch (OperationCanceledException) { 後始末; throw; }やwhen (e is not OperationCanceledException))が、そのままキャンセルに効くようにするためです。C# には、catch (Exception)に捕まらない例外の階層を作る方法がありません。そうした言語では、標準のキャンセル例外にそろえるのが普通です(Kotlin のCancellationExceptionも JVM の標準の例外です)。- 巻き戻しのたびに新しいインスタンスを投げます。先に捕まえた例外の
StackTraceとDataを、後の巻き戻しが書き換えないようにするためです。 - どのランタイムでも同じ方法で巻き戻します。ランタイムによって例外を投げたり投げなかったりすると、デバッガーで例外の送出を止める設定の効き方や、ホットリロードで後から足した
finallyが走るかどうかが変わるためです。
代償
- 取り消すたびに、止まっている async メソッド 1 個につき例外を 1 回投げます。費用は 性能とメモリ にあります。葉の待ちは例外を使いません。
catch (OperationCanceledException) { }で続けると、キャンセルの握りつぶしになります(次の節)。FlowCanceledException.CancellationTokenはNoneなので、when (e.CancellationToken == token)はどのトークンにも一致しません。
例外を投げない await を足さない理由:UniTask の SuppressCancellationThrow のように「キャンセルされたか」を返す await は、公開 API を増やし、深い呼び出しではすべての段で使わないと効きません。.NET の JIT では、500 スコープの巻き戻しは 1 ms 未満です。IL2CPP では、デスクトップの PC の開発用プレイヤーで 6〜12 ms でした(性能とメモリ)。判断の基準は IL2CPP の実機の値で、中位の端末で 500 スコープの巻き戻しが 8 ms を超えるか、毎フレーム 50 回のキャンセルが 1 ms を超えたら足します。スマートフォンなどの実機ではまだ測っていません。デスクトップの値が基準に近いので、実機で測った値で決めます。それまでは「毎フレーム負ける枝は葉の待ちにする」を指針にします。なお、呼び出し元は今でも、キャンセルを例外なしに Race の結果(Index)で受け取れます。例外で巻き戻るのは、負けた枝の中の async メソッドだけです。
キャンセルの握りつぶしは、そのスコープだけで止める
Section titled “キャンセルの握りつぶしは、そのスコープだけで止める”取り消されたスコープが FlowCanceledException を捕まえて return すると、FlowTask はそれを握りつぶしとして OnUnhandledException に報告し、そのスコープをキャンセルで終えます。呼び出し元は続きます。アナライザの FLOW001 は、この書き方を既定のエラーとして止めます。
理由
FlowCanceledExceptionはOperationCanceledExceptionの派生なので、UniTask で普通のcatch (OperationCanceledException) { パネルを隠す; return; }も握りつぶしになります。握りつぶしを見つけたら外側まで巻き戻す形にすると、「戻る」を 1 回押しただけでメインメニュー全体が巻き戻ります。- 握りつぶした catch の中身(エラー表示、リトライ)の副作用は、どう扱ってもすでに走っています。外側まで巻き戻しても何も守らず、巻き戻す範囲が広がるだけです。
- 取り消された部分木の中で起きたことは、生きているコードの流れを変えずに報告だけにする、という受け手のいない例外(後述)と同じ考え方です。
代償:ループの中でキャンセルを握りつぶす catch は、キャンセルの後も回り続けます。実行時には、そのスコープが return するまで報告されません。FLOW001 がコンパイル時に止めます。
外部の await はブリッジに限る
Section titled “外部の await はブリッジに限る”FlowTask メソッドの中で、ブリッジしていない Task、UniTask、Unity の Awaitable などを await して止まると、その時点で誤用(FlowMisuseException)になります。FlowTask メソッドでない async メソッド(async Task など)が FlowTask を await して止まった場合も同じです(フローと World、Task / ValueTask)。
理由
- 外部の awaitable を待っている間は、ライブラリがその await に例外を差し込めないので、キャンセルを届けられません。
- 構造化並行性をとる実装は、「親が子の終わりを待つ」(Kotlin、Swift)か、「外部の awaitable を禁止して明示の橋渡しを求める」(Trio)かのどちらかです。「親が先に終わり、子が後から走る」ものはありません。
- 親が待つ形では、外部の処理が終わらないと親や Race が永遠に終わりません。その症状はキャンセルと停止の時機が重なったときだけ出るので、テストで見つけにくいです。止まった時点で誤用にすれば、最初に止まったときに必ず見え、キャンセルの時機によらず開発中に気づけます。
async Taskのメソッドはスコープではないので、フローと一緒に取り消されず、巻き戻りません。同じ規則にすれば、「フローの中で待てるのは FlowTask メソッドだけ」という 1 つの規則で済みます。- 主な外部の awaitable には、すでにブリッジがあります。ブリッジを通せば、外部の処理もスコープの寿命に従います。スコープが終わるとトークンが取り消され、受け取られなかった結果は
onDiscardで解放できるので、読み込みとキャンセルボタンの Race も書けます。
代償
- 同期に完了した外部の await は止まらないので、実行時には見つかりません。テストで同期に完了する偽の Task を直接 await すると、テストでは通り、製品でだけ誤用になります。アナライザの FLOW002 と FLOW005 がコンパイル時に同じ await を報告します。
- 止まった
async Taskのメソッドは再開せず、その Task は完了しません。Task のコードからフローの終わりを待つなら、world.Run(x).AsTask()を await します。 - キャンセルされなければ動いていた書き方も、すべて誤用になります。
外部でキャンセルされた Task は例外
Section titled “外部でキャンセルされた Task は例外”ブリッジした Task が OperationCanceledException で終わったら(HttpClient のタイムアウトなど)、FlowTask はそれをフローのキャンセルではなく、普通の例外として await で投げます。
理由:ブリッジのスコープが取り消されたときは、ブリッジが結果を読まずに捨てます。そのため、ブリッジに届く OperationCanceledException の原因は、必ずスコープの外にあります。それをフローのキャンセルと同じに扱うと、外部のタイムアウトが黙ってフローを止め、catch もできません。
代償:捕まえなかったとき、Task なら Canceled で終わりますが、FlowTask では未処理の例外になります。外部のキャンセルだけを受けるには catch (OperationCanceledException e) when (e is not FlowCanceledException) と書きます。
遅延実行と GetAwaiter
Section titled “遅延実行と GetAwaiter”FlowTask は、呼んだだけでは始まらず、awaiter を作る時点(GetAwaiter)で始まります。
理由:遅延実行の awaitable は、どこかで始める必要があります。IsCompleted の getter で始めると、getter が副作用を持ちます。OnCompleted で始めると、同期に完了するタスクも一度止まることになり、同期完了の速い経路を失います。.NET にも前例があり、Rx の await observable は GetAwaiter の中で購読します。
代償:Task の GetAwaiter() は何も始めないので、同じつもりで呼ぶと黙ってタスクが動きます。アナライザの FLOW007 が、コードに書いた GetAwaiter() を警告します。awaiter を手で使えるのは同期に完了したとき(IsCompleted が true)だけで、そうしたコードは #pragma で警告を抑制します。
Signal.Next はエッジ
Section titled “Signal.Next はエッジ”Signal<T>.Next() は、待っていない間の Emit を捨てます。取りこぼしたくない入力は Subscribe(BufferPolicy) で購読します(シグナル)。
理由
- エッジを既定にするのは、ほかのライブラリと同じです。UniTask の
button.OnClickAsync()も、待ち始める前のクリックを受け取りません。 - 既定をバッファにすると、連打の対策(購入中の連打を捨てる)と衝突します。購入中の連打を後の
Nextが拾うと、二重購入になりえます。 - 落ちた入力が「意図した捨て」か「取りこぼし」かは、ライブラリには分かりません。推測で警告すると、正しいコード(わざと連打を捨てる処理、共有の入力を使う Race)でも出て、範囲を狭めれば本当の取りこぼしを見逃します。そのため実行時の警告は持たず、エッジの意味として文書に書きます。
- ループの中の
Next()は取りこぼしが起きやすいので、アナライザの FLOW006 が知らせます。意図どおりのエッジのループ(アニメーション中のタップは捨てたい)もあるので、情報にとどめています。
代償:待ち始める前の入力が捨てられても、実行時には何も知らされません。拾いたい入力は、画面を開くとき(演出の前)に購読します。
Race の枝の順と、負けた枝の値
Section titled “Race の枝の順と、負けた枝の値”Race は、最初に完了が処理された枝が勝ちます。枝は書いた順に始め、同期に勝者が決まったら残りは始めません。同時に完了したら、処理の順(多くは書いた順)で決まります(合成)。
理由:ほかの案は、どれも別の驚きか性能の悪化を持ち込みます。
- 先に葉の待ちを調べる案では、前に書いた async メソッドが同期に完了しても、後ろの葉が勝ちます。
- すべての枝を始めてから決める案では、同期に決着しても全部の枝の同期の部分が走り、負けた枝の巻き戻しのたびに例外の費用を払います。
- 偏りを明記するのは、Kotlin の
selectと同じ扱いです。 - 1 回の大きな dt で複数の期限が過ぎたときも、期限の順ではなく待ち始めた順に決まります。期限の順に並べ替えると、すべてのフローの実行順が変わり、費用も増えるためです。
負けた枝の値:負けた購読の待ちが受け取っていた値は、購読の先頭に戻します。被弾の購読を Race の枝にしたとき、被弾が消えないようにするためです。値を戻しても、Race の決着の規則と公開 API は変わらず、決定論と割り当てにも影響しません。
- 戻すのは葉の待ちが受け取った値だけです。合成が値を自分の結果に取り込んだら、その値は受け取られたとみなします。入れ子の合成の値まで段ごとに戻す仕組みは、実装と規則の量に比べて効く場面が狭いので持ちません。
- エッジの
Signal.Nextの値は戻しません。戻すと、次に待ち始めたNextが、待つ前の値を受け取ることになり、エッジの意味が変わるためです。
Emit は同期に再開しない
Section titled “Emit は同期に再開しない”Emit も完了も、待っているフローをその場では再開せず、再開を予約して FIFO のキューで処理します(実行モデル)。
理由
- 再開がすべて 1 本のキューを決まった順に通るので、同じ入力から毎回同じ順で実行されます。テストと不具合の再現が、この決定論に頼っています。
Emitした側のコードは、次の await まで、ほかのフローに割り込まれずに続きます。AsTask()の Task も同じ考えで、フラッシュの途中ではなく、Tick、Flush、Run、Dispose の最後に完了させます。外部のコードをフラッシュの中で走らせず、再開の時期を決めておくためです。
代償:Emit の直後に、受け手のフローが走った結果を同じ呼び出しの中で読むことはできません。読むのは次のフラッシュの後です。フローの外から Emit したなら、続けて Flush() を呼べば、その場で受け手を走らせられます。
失敗の経路をキャンセルと例外の 2 つにする
Section titled “失敗の経路をキャンセルと例外の 2 つにする”失敗は、例外とキャンセルの 2 つの経路を通ります(失敗の扱い)。想定内の失敗を値で返すための型は持ちません。
理由
- 例外は、Task と同じく、await した子の例外をその await で投げ直します。
try { await Load(); } catch (IOException) { … }という普通の C# の書き方が、そのまま効きます。 - 例外をフローの外側の境界へ直接届ける経路や、途中の catch を素通りする中断の経路は持ちません。C# では、同じことを例外の型と try/catch で書けます。経路と境界の規則が減り、例外の扱いが Task と同じになります。
- ライブラリが見つけた誤用(ブリッジしていない await など)も、スコープの普通の例外として待ち手の await で投げます。誤用だけ catch できない別の経路にすると、利用者はほかの失敗と違う経路を覚える必要があります。ブリッジしていない await はスコープを終えてから待ち手に投げるので、catch で受けて続けても、漏れる続きはありません。
- 結果を値で返すための汎用の型(Result、Maybe)は持ちません。C# には標準のものがなく、使うなら利用者が自分の型か好みのライブラリを選びます。ライブラリが同じ名前の型を持つと、利用者のコードで名前がぶつかります(R3 は
Resultを、CSharpFunctionalExtensions はMaybe<T>を公開しています)。戻り値はどの経路も通らない普通の値なので、利用者の型でも同じように返せて、例外の届く先の規則(次の項)にも左右されません。値がないことのある API(NextOrClosed()など)は、名前付きのタプル(bool …, T Value)を返します。 - 並べた処理を最初の失敗で止めるのは、例外と
WhenAllで書きます。書き方は Task のWhenAllと同じですが、Task と違って、残りの枝は止まります。
受け手のいない例外:例外が届いた時点で、受け手がもう取り消されていたり、Race がもう決着していたりすることがあります。その例外は運ばずに、FlowExceptionKind.Undelivered として OnUnhandledException に報告し、結果を変えません。
- 次の生きている待ち手まで運ぶと、運ぶ先は取り消した側になり、取り消した側や決着した呼び出し元を後から止めるか、例外が黙って消えます。報告だけにすれば、そのどちらも起きません。
- 名前の Undelivered は、事実(届かなかった)を述べ、軽重の判断を含みません。並列の 2 つ目の失敗のように、ふつうの遊びでも起きるので、Unity と Godot の既定のハンドラはエラーではなく警告としてログに出します。
報告の順:報告は見つけた順にすぐ OnUnhandledException へ渡します。原因の例外を先に届けるために報告を溜めると、溜める仕組みと、ハンドラの中から World を進めたときの特別な規則が要るためです。代わりに、World の実行中(フローのコード、後始末、OnUnhandledException と OnWarning のハンドラ、Post の処理)は Tick、Flush、Dispose を一律に拒みます(FlowMisuseException)。
代償
- 処理を途中でやめて外側へ戻るために投げる自分の例外も、途中の catch-all に捕まります。戻り先より内側の catch-all は、投げ直すか
whenで型を除きます。 - 誤用の例外も、catch-all で受けて続けられます。
- 例外のスタックトレースには、最初に投げた場所と catch した場所しか入りません。途中の await の段は
FlowExceptionInfo.ScopePathで見ます。捕まえた例外からはフローのパスは分かりません。 - 後始末の失敗(Cleanup)が、それを引き起こした例外より先に届くことがあります。
OnUnhandledExceptionがないときに投げるFlowUnhandledExceptionの先頭が、原因ではなく結果になることもあります。 - 例外で書いた想定内の失敗も、受け手がいなければ catch に届かず Undelivered になります(並べた通信が同じフレームに 2 つ失敗したときの 2 つ目など)。避けるには値で返し、最初の失敗で残りを止める処理を
Once<T>と Race で書きます(下の「組み合わせで書くもの」)。
Spawn した子の寿命と例外
Section titled “Spawn した子の寿命と例外”Flow.Spawn の子は、親のスコープが終わると(正常に終わっても)巻き戻ります。子の例外は、親を取り消し、親を await している呼び出し元で投げ直します(フローと World)。
理由
- 寿命を包むことで、画面や状態が終われば、そこから派生した処理も止まります。止め忘れた処理を書けないようにするのが目的です。親より長く走らせる手段は
FlowWorld.Runで、別にあります。 - 子の例外の行き先は、Kotlin の
coroutineScope、Trio の nursery、Swift のタスクグループと同じ形です。await FlowTask.WhenAll(EnemyAI(), PlayerTurns())を_ = Flow.Spawn(EnemyAI()); await PlayerTurns();に書き換えても、呼び出し元のcatch (IOException)が同じように受けます。 Flow.Spawn(x);とハンドルを捨てる書き方は、UniTask のForget()の癖で書くと、親が戻ったときに子が黙って止まります。アナライザの FLOW008 がコンパイル時に警告し、_ = Flow.Spawn(x)(親と一緒に終わる)かFlowWorld.Run(親より長く動く)を選ばせます。実行時に警告すると、子が親と一緒に終わる正しい使い方(画面と一緒に終わるループ)でも出てしまいます。コンパイル時なら、書き手が_ =で意図を 1 回書けば済みます。_ =は、C# で値を分かって捨てるときの普通の書き方です。FlowHandleは awaitable にしません。awaitable にするとコンパイラの CS4014 が出ますが、CS4014 は async メソッドの中でしか出ず、勧める直し方のawaitは親に子を待たせて意味を変えます。
代償
- 同じメソッドの try の中で Spawn した子の例外は、そのメソッドの catch では受けられません。その範囲をメソッドに抽出すると、呼び出し元として受けられます。
- 呼び出し元の catch-all が、Spawn した子のバグも受けます。
_ =で黙らせた、長く動くつもりの子は見つけられません。
別の World の Run:フローの中から別の World の Run で始めたフローは、呼んだスコープと結び付きません。自動で結び付けると、同じ World の Run(親から切り離す書き方)と意味が逆になり、呼び出し元の World で挙動が変わるためです。結び付けたいときは Flow.AddCleanup(handle.Cancel) の 1 行で書けます。
後始末の await と Flow.NonCancelable
Section titled “後始末の await と Flow.NonCancelable”取り消されたスコープでも、FlowCanceledException がコードに届いた後の catch と finally の await は、最後まで走ります。キャンセルの前に入った finally の await を守るには、Flow.NonCancelable で包みます(スコープとキャンセル)。
理由
finally { await FadeOut(); }が UniTask と同じに動きます。後始末ごとに秒数の上限を選ぶ必要がありません。- 周り(親、Race の呼び出し元、失敗した WhenAll)は、後始末の終わりを待ちます。コールスタックと同じく、内側の後始末が終わってから外側が走ります。
- 後始末に既定の時間の上限は付けません。上限を付けると、秒数の選び方と、切った後の扱いがまた要ります。上限が要る後始末は
Raceで書けます。 - キャンセルの前に(
return、try の終わり、例外で)入ったfinallyの await は、実行時には本体の await と見分けられません。C# のコンパイラが try/finally の例外を状態機械の中に持つので、ライブラリには見えないためです。守る場所は書く人が示すしかないので、印を付ける形にしました。キャンセルが暗黙の構造化並行性のライブラリは、どれも明示の印を持っています(Kotlin のwithContext(NonCancellable)、Trio のCancelScope(shield=True))。 - 印は直接の await でだけ効き、
Flow.Spawn、FlowWorld.Run、合成に渡すと誤用になります。「印を付けたのに効かない」形と「取り消せないフロー」を作れないようにするためです。 World.Disposeは Tick を回さず、1 回の巻き戻しでフローを終えます。Dispose の中で Tick を回すと、Dispose がいつ戻るかが後始末しだいになるためです。Dispose の間の後始末が新しいフローを始めないのも、Dispose の後に何も残さないためです。Dispose に猶予を持たせる API は、取り消し、上限付きの Tick、Dispose の組み合わせで書けるので持ちません。
代償
- 終わらない後始末は、フローを止めたままにします。取り消されたスコープが後始末で
UnscaledClockの 10 秒を超えて待つと、LongCleanupを World で 1 回警告します。 Raceの負けた枝で、印を付けた await が走っていると、Raceはその終わりまで戻りません。- ゲームのポーズ中は後始末も止まります。止めたくない後始末は UI の Clock で走らせます。
World.Disposeでは、印で守った保存も最初の await で切れます。最後まで走らせたい処理は、Dispose の前に終えます。- FLOW010 は、キャンセルの後にしか入らない
finallyの await にも出ます。静的には見分けられないためです。そうした await に印を付けても動きは変わらないので、印を付けるか、警告を抑制します。 - 印を付けた入力待ちは取り消せず、フローを止めます。
World は 1 つのスレッドで、Tick で進む
Section titled “World は 1 つのスレッドで、Tick で進む”フローのコードは、World を作ったスレッドで、Tick、Flush、Run、Dispose の中だけで走ります。時間の待ちは Tick に渡した時間で進み、別のスレッドからの完了は Tick か Flush の取り込みで受け取ります(スレッド)。
理由
- フローのコードにロックが要りません。FlowTask の内部も、スコープの木と再開のキューにはロックを使わず、使うのは別のスレッドから受け取る受信箱などに限られます。ゲームのコードの多くは、エンジンのメインスレッドでしか呼べない API を使います。
- 再開の順序が、スレッドの都合ではなく、1 本のキューの順で決まります。同じ入力なら同じ順で動き、テストで再現できます(実行モデル)。
- ゲームの時間(Pause、倍率、テストの仮想時間)を、Tick に渡す 1 つの値から作れます。
代償と、代わりの書き方
- I/O や重い計算は、フローの中では走らせません。Task で書いてブリッジで待ちます(前の「外部の await はブリッジに限る」)。
- World のスレッドでは、フローの終わりを止まって待てません。止まると Tick も止まるためです。終わりは await するか、Tick を回して待ちます。別のスレッドからなら止まって待てます。
- エンジンのないプログラムでは、Tick を呼ぶループが要ります。ループは World のスレッドから離れないように書きます。
- フレームと関係のない処理だけのプログラムでは、FlowTask を使う利点は小さく、Task をそのまま使うほうが素直です。
ExecutionContext を捕まえない
Section titled “ExecutionContext を捕まえない”FlowTask のビルダーは ExecutionContext を捕まえず、フローは Tick などを呼んだ側の ExecutionContext で走ります。フローの中で設定した AsyncLocal<T> の値はそのフローのものにならず、await の後に同じ値が見える保証もありません(実行モデル)。
理由
- 捕まえると、止まる await のたびに ExecutionContext を捕まえ、再開のたびに戻す費用を、すべてのフローが払います。毎フレームの待ちは、最も多い await です。
- 再開は Tick や Flush の中でまとめて起きます。Spawn した子、Emit で再開したフロー、巻き戻しの
finallyがどの時点の文脈で走るかを、それぞれ決める必要が出ます。 - ゲームのフローで
AsyncLocal<T>を使う場面は少なく、UniTask のビルダーも捕まえません。
代償:AsyncLocal<T> で作る仕組み(ILogger.BeginScope、Activity.Current など)を、フローの中で await をまたいで使えません。フローごとの値は引数で渡し、ログにフローの位置を出すなら Flow.CurrentScopePath を使います。ブリッジした Task の中では、普通の .NET と同じく使えます。
World のスレッド束縛とセッション
Section titled “World のスレッド束縛とセッション”Signal、FlowProperty、Once は、最初に使った World とそのスレッドに束縛されます。束縛先の World が Dispose された後に別の World から使うと、FlowMisuseException になります(スレッド)。
理由
- static な状態を持たず、オブジェクトの寿命をセッション(World)に結び付けて、誰が作ったかをコードで追えるようにします。
- 束縛し直す形にすると、前のセッションに残った状態(閉じた印、フローの外で作った購読)を黙って引き継ぎます。Unity でドメインリロードを切って static に置いた Signal は、次の再生の最初の待ちで例外になり、セッションごとに作って渡す直し方が文面に出ます。
- どの World もまだ使っていないオブジェクトは検査しません。普通の .NET のオブジェクトと同じく、1 つのスレッドから使います。規則が 1 つになり、読み込みのスレッドで初期値を入れてから World に渡す書き方もそのまま使えます。
- どの World にも束縛されていない Signal への
EmitFromAnyThreadは、FlowMisuseExceptionにします。値を積む先がなく、必ず捨てられる呼び出しだからです。束縛先の World が Dispose された後の送信は、終了の途中で別のスレッドから届く正しい使い方でも起きるので、例外にせずに捨てます。 - 判定は束縛の遅い経路(World が違うとき)だけで行うので、ふだんの経路の費用は増えません。
代償
- 同じスレッドの複数の World で 1 つのオブジェクトを共有すると、最初に使った World が先に Dispose された後は、ほかの World からも新しく使えません。最初に使う World を、ほかより長く生かします。
- World が使う前のオブジェクトを 2 つのスレッドから書くと競合します。
- 別のスレッドから送る Signal は、
new Signal<T>(world)で作るか、先に World のスレッドで使う必要があります。
Clock と時間
Section titled “Clock と時間”時間を注入する API を持たない:コアの時間の入口は FlowWorld.Tick(dt) の引数だけで、コアは実時間を読みません。エンジンの統合にも差し替え口があります(Unity は自動の Tick を切って自分で Tick する、Godot は GetDeltaTime を override する)。注入の API を足しても、何も設定しない利用者の既定は変わりません。Godot の autoload は引数のないコンストラクタで作られるので、注入してもサブクラスに書くことになり、override と書く量は同じです。
Godot の既定の時間:Godot の FlowWorldNode は、Godot の process の delta から Engine.TimeScale を除いた値(上限で切り詰め。倍率が 0 の間だけ実時間)を時間にします。固定のフレームレートでの録画(--fixed-fps、Movie Maker)が正しくなり、同じゲームの物理用の World とそろい、--fixed-fps で決定論になります。代償として、実時間より速く回る固定のフレームレートでは、UnscaledClock の待ちも速く進みます。実時間で外部の処理に上限をかけるなら、ブリッジする Task の側で CancellationTokenSource.CancelAfter を使います。また、固定のフレームレートで倍率が 0 の間は、UnscaledClock が Godot の ignore_time_scale のタイマーとずれるので、ゲームの一時停止は倍率 0 ではなく Clock.Pause() で行います(Godot のセットアップ)。
CreateClock の親を省いた形:world.CreateClock(name) は DefaultClock の子を、Flow.CreateClock(name) は今の Clock の子を作ります。省いた形はいちばんよく書かれ、読み手は「今の時間の下に作る」と読むので、既定をその読みに合わせました。ゲームのポーズ中も進む独立した Clock は、UnscaledClock を親にして作ります(world.CreateClock("UI", world.UnscaledClock))。「非スケールの時間に従う」と読め、新しい API も要りません。
大きな dt と時間の比較:同じ Tick に満たされた時間待ちは、待ち始めた順に再開します。WaitForSeconds は誤差を許して比べません。どちらも、実行順を変えないためです(実行モデル)。
同期完了の上限
Section titled “同期完了の上限”1 つのスコープが 1 回の Tick、Flush、Run(外から呼んだもの)の中で同期に完了させる await が 1,000,000 を超えたら、無限ループとみなしてスコープを終えます。
理由:上限がないと、同期に完了し続けるループでゲームごと固まり、原因も探しにくくなります。上限は本当の無限ループからゲームを守る安全網で、正しいフローが当たる数ではないので、大きくとり、当たったらスコープを終えます。続きを次の Tick に送る形にしないのは、実行の順序が入れ替わり、ほかの規則との組み合わせごとに規則が要るためです。数える単位を Tick だけでなく Flush と Run の 1 回ごとにもするのは、Flush だけで回す World で、数が生きている間ずっと積み上がらないようにするためです。
完了済みの値を数えない理由:FromResult と CompletedTask の await を数えると、その検査の費用を、完了済みの値を await しないコードも含めてすべての await が払います。Task、ValueTask、UniTask も、完了済みの値の await はその場で続けるので、C# の利用者が知っている挙動に合わせました。
代償:完了済みの値だけを await するループは、それを走らせた Tick、Flush、Run から戻らず、上限にも当たりません。条件を待つループには、NextFrame を挟むか FlowTask.WaitUntil を使います。1 回の Tick、Flush、Run の中で 1,000,000 回を超えて同期に完了させる正しいフローも、無限ループとして終わります。
診断と安全網を小さく保つ
Section titled “診断と安全網を小さく保つ”- Debug だけの挙動を持たない:ビルドの構成によらず同じに動きます。テストが Debug と Release で分かれず、Release でだけ起きる異常にも気づけます。
- 警告は異常のときだけ、種類ごとに World で 1 回:警告は異常の分岐の中だけで動くので、ふだんの経路に費用がかかりません。1 回にするのは、毎フレーム起きる異常でログが埋まらないようにするためです。
- 正しいコードで出る警告は持たない:実行時の警告は、正しいコードでは出ないものだけにします。Spawn のハンドルの捨て忘れは FLOW008 がコンパイル時に見つけます。受け取られなかったブリッジの結果は、解放が要るなら
onDiscardを渡して扱い、onDiscardのない結果は、誰も await しない Task の結果と同じく黙って捨てます。 - 決して終わらない Join は、自分か祖先を待つものだけを見つける:フローどうしが互いに待つ循環は、Task や Kotlin のコルーチンと同じく待ち続けます。
Dumpで探します。 - 待ちを作った場所は、呼び出し元の属性で記録する:待ちと合成を作る API は、末尾の省略できる引数で
[CallerFilePath]と[CallerLineNumber]を受け、ノードには文字列の参照と整数を持つだけです。値はコンパイル時に決まる定数なので割り当てがなく、IL2CPP と NativeAOT でも同じに取れます。スタックトレース(System.Diagnostics.StackTrace)は、取るたびに割り当て、IL2CPP と AOT ではファイルと行が取れないことがあるので使いません。引数なので、待ちを包むゲームの関数は、自分の呼び出し元の場所をそのまま渡せます。FlowTask メソッドの呼び出しは場所を持ちません。呼び出しはコンパイラが作る状態機械の開始で、引数を足せないためです。 - 設定を持たない:上限(フラッシュの再開、プール、同期完了)は定数です。設定の組み合わせを検証しなくて済みます。
代償:実行の記録(トレーサ)や、作ったまま始めていない FlowTask の実行時の検出はありません。詰まったフローは Dump と Diagnostics.Walk で探し、始め忘れは FLOW003 がコンパイル時に知らせます(デバッグと診断)。待ちを作った場所のパスは、ビルドした機械のフォルダ(ユーザー名を含むことがあります)ごと、利用者のアセンブリに文字列として入り、リリースビルドにも残ります。避けるなら、PathMap(ContinuousIntegrationBuild)でパスを置き換えます。
公開 API を小さく保つ
Section titled “公開 API を小さく保つ”公開 API(型、メンバー、設定)を足すのは、実際の利用者がいて、既存の API の組み合わせでは書けない場合だけです。
理由:API は覚える量と保守の量を増やし、同じことの書き方を 2 通りにします。FlowTask は寿命、時間、順序だけを受け持ち、UI、入力、戻るキーは持ちません。それらは、シグナルと、スコープの終わりに後始末を走らせる Flow.Own や Flow.AddCleanup を組み合わせて書きます。戻るキーの例は、サンプルの BackKeyRouter です(Unity のサンプル)。
組み合わせで書くもの
| 場面 | 書き方 |
|---|---|
| WhenAny、タイムアウト | FlowTask.Race |
| 値で返す失敗で、並べた処理を最初の失敗で止める | 失敗した枝は失敗を Once<T> に入れて(IsSet なら入れずに)FlowTask.Never() を待ち、全体を FlowTask.Race(FlowTask.WhenAll(…), failed.Wait()) で待つ |
| 例外の境界 | try/catch |
| 終わり方を問わず待つ Join | FlowTask.WaitUntil(h, x => x.IsCompleted) の後に Status を見る |
| 別の World のフローを今のスコープと一緒に止める | Flow.AddCleanup(handle.Cancel) |
| 後始末の時間の上限 | await FlowTask.Race(Save(), FlowTask.WaitForSeconds(2)) |
| Dispose の前に後始末を終える | 取り消し、上限付きの Tick、Dispose |
| 独立した Clock | world.CreateClock(name, world.UnscaledClock) |
| 寿命付きの優先順位(戻るキーを誰が受けるか) | 要素のリストに積み、要素を Flow.Own で積んだスコープに持たせて、スコープの終わりに外す。届けるのは要素ごとの Signal |
組み合わせで書けても足したもの:FlowBridge.FromTask の onDiscard(受け取られなかった結果を、World のスレッドで確実に解放できる)、別のスレッドからの FlowHandle.Cancel(ct.Register(handle.Cancel) で外部のトークンをつなげる)、Flow.CreateClock(スコープの終わりで消える Clock と、消えた後の使用の検出はコアでしか書けない)。
- FlowTask:ほかのライブラリやエンジンの資産と重ならない名前です。
- 名前空間
Katout.FlowTask:.NET の名前空間の指針(<Company>.<Product>、PascalCase)に合わせました。型FlowTaskと名前空間の最後が同じなので、利用者のコードがKatout.*の名前空間にあるとFlowTaskが名前空間を指します(CS0118)。この少数の場合は別名で避けられるので、受け入れました。製品名と同じ名前の型を製品名の名前空間に置くライブラリは、珍しくありません(Microsoft.Extensions.OptionsのOptionsなど)。複数形の名前空間は、製品名やパッケージ名と 1 文字だけ違い、書き間違えやすいので採りませんでした。 - アセンブリ名
FlowTask:パッケージの名前とアセンブリの名前を同じにするのは、UniTask や R3 などと同じ普通の形です。 - 黙って誤動作しない名前:秒で待つ API は
WaitForSecondsです(UniTask の癖でDelay(500)と書くと 500 秒待つことになるため)。ほかのライブラリやエンジンと衝突する名前は避け(FlowWorld、FlowUnit、BufferOverflow)、同じ語の意味が他と逆になる名前も避けました(始めていない状態はUnstarted、成功はSucceeded。UniTask の Pending は実行中の意味です)。 Discard:開始せずに捨てる操作で、Forget()(走らせたまま捨てる)とは違います。X().Discard()は FLOW003 が警告します。FlowTaskUnity:Unity の統合(既定の World と、その設定)の入口です。フローを始めるたびにFlowTaskUnity.World.Run(...)と書くので、動かし方(PlayerLoop)ではなく、何の入口かで名付けました。FlowUnityは、コアのFlowUnitと 1 文字だけ違い、補完で取り違えやすいので採りませんでした。既定の World がないとき(Edit Mode、Shutdownの後、再生中の再コンパイルの後)、Worldは null ではなく、原因を書いた例外を投げます。null を返すと、FlowTaskUnity.World.Run(...)が原因の分からないNullReferenceExceptionになるためです。Godot のFlowWorldNode.Defaultも同じです。あるかどうかは、Unity ではFlowTaskUnity.IsInstalled、Godot ではFlowWorldNode.Instanceが null でないかで調べます。- 未処理の例外:どこでも捕まえられなかった例外の名前は、Rust や Go の「panic」ではなく .NET に合わせました(
OnUnhandledExceptionはAppDomain.UnhandledExceptionに、FlowStatus.Faultedは Task の Faulted に合わせた名前です)。C# の利用者が、すでに知っている語で読めるようにするためです。
C# 10 と Unity 2023.1
Section titled “C# 10 と Unity 2023.1”ライブラリは C# 10 で書き、Unity の最低バージョンは 2023.1 です。
理由
- Unity と Godot の両方で使える、いちばん新しい C# が 10 です。Unity 6(6000.3)のコンパイラ(Roslyn 4.3.1)で使える最も新しい確定の版が C# 10 で、Unity 2022.2 以降は同じ Roslyn 4 系です。Godot 4.4 のゲームは .NET 8 SDK 以降でビルドするので、C# 12 まで使えます。
- Unity は自分のアセンブリを C# 9 でコンパイルします。パッケージの各アセンブリの隣に置いた
csc.rspが、そのアセンブリだけ言語バージョンを上げます。利用者のプロジェクトの言語バージョンは変えません。利用者のコードは C# 9 のままで構いません。 - 最低バージョンを 2023.1 にして、Awaitable のブリッジをバージョンで分ける必要をなくしました。
- 構文だけの機能を使い、ランタイムの支援が要る機能(インターフェイスの static abstract メンバー、ref フィールド)は使いません。Mono と IL2CPP で動かないためです。
-langversion:latestやpreviewではなく版を書いて固定するのは、Unity のバージョンによって通る構文が変わらないようにするためです。
代償
- Unity 2021.3 と 2022.x では使えません。
- IDE 用に Unity が生成する csproj に
csc.rspの言語バージョンを反映しない IDE では、パッケージのソースに C# 10 の構文の誤りが表示されます。Unity のコンパイルには影響しません(Unity のセットアップ)。
プールの上限
Section titled “プールの上限”状態機械とノードは型ごとのプールで使い回し、上限は型ごとに 1,024 個です(性能とメモリ)。
理由:同じ型のノードを数百以上まとめて解放する場面(敵の群れの退場など)でも、割り当てが続かないようにします。プールを空にする API は、要る場面がなく、効果も測っていないので持ちません。上限は定数で、設定はありません(前の「診断と安全網を小さく保つ」)。FlowWorld.Run と Flow.Spawn のルートは、ハンドルからいつでも状態を読めるように、プールしません。
代償:最悪の残量(使ったノードの型の数 × 1,024 × ノードの大きさ)が、プロセス(Unity ではドメイン)の終わりまで残ります。
エンジンのオブジェクトの寿命
Section titled “エンジンのオブジェクトの寿命”Unity の RunWhileActive は、GameObject が無効になるか破棄されたらフローを巻き戻し、どこから呼んでも World のルートのフローとして始めます。Godot の RunWhileInTree も、どこから呼んでもルートのフローです。どちらにも、フローの中で await する、すぐには始めない版(Unity の WhileActive、Godot の WhileInTree)があります(Unity の寿命、Godot の寿命)。
理由
- オブジェクトプールは Unity で最も普通の再利用の形です。
SetActive(false)で止まらないと、書いた人の予想に反して黙って動き続けます。無効化で止めれば、コルーチンと同じ感覚で書けます。 - 無効化の後に再び有効になっても、フローは再開しません。無効の間に状態が変わっても、再開したフローは古い前提で続くためです。プールから別の用途で取り出したときに、前の続きが走るのは誤りです。コルーチンも再開しません。
- ルートで始めるのは、寿命の持ち主を 1 つにするためです。呼んだスコープの子にすると、オブジェクトと呼んだスコープの 2 つが寿命を持ち、どこから呼ばれたかで挙動が変わります。
- 呼んだスコープとも一緒に終わらせたいとき(フローの中で結果を待つとき)は、
WhileActive/WhileInTreeを await します。await した時点で始まり、await したスコープの子になることがコードから読めます。2 つのエンジンで同じ形にしたので、片方で覚えた書き方がもう片方でも使えます。 - 2 番目の引数は Clock です。Clock は自分の World を持つので、Clock だけで動かす World も決まり、
FlowWorld.Run(task, clock)と同じ形になります。World を受ける形では、ゲームの Clock で動かすたびにFlow.WithClockで包む必要がありました。 this.RunWhileActive(...)(コンポーネントから呼ぶ形)は持ちません。結び付くのは GameObject の有効・無効なので、thisと書くと、コンポーネントのenabledやDestroy(this)で止まると読めてしまいます。gameObject.が持ち主を示します。- 再生中の再コンパイルでフローを復旧する仕組みは持ちません。再コンパイルの後は
AwakeとStartが呼ばれ直さず、どのライブラリの実行中の状態も消えるので、FlowTask だけ復旧しても効果が小さいためです。代わりに警告を出し、原因を例外の文面に書きます。
代償
RunWhileActiveで始めたフローの例外は、呼んだスコープの呼び出し元ではなくOnUnhandledExceptionへ届きます。- 無効化をまたいで動かしたいフローは、
FlowTask.Race(task, go.WaitForDestroy())で破棄だけに結びます。 - フローが最後の文で自分の GameObject を無効にするか
Destroyすると、そのフローはSucceededではなくCanceledで終わります。 - 無効化と
Destroyがその World の Flush ポイントになり、結んだフローはその呼び出しの中で巻き戻ります。リストを列挙しながらDestroyし、finallyがそのリストを変えると、列挙が例外になります。コピーを列挙してください。 - フローのコードの中で GameObject を無効にしたとき、
WhileActiveは、その呼び出しの中ではなく、その World が次に再開を処理するとき(Tick や Flush の中ならその続き、外から呼んだFlowWorld.Runの中なら次の Flush ポイント)に決着します。Godot のWhileInTreeと同じです。