コンテンツにスキップ

設計の背景

FlowTask の主な設計の判断について、なぜそうしたかと、その代償をまとめます。挙動そのものは各ページに書いてあります。ここでは「なぜこうなっているのか」が気になったときに、その理由を確かめられるようにします。

取り消されたフローは、止まっている 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 がコンパイル時に止めます。

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) と書きます。

FlowTask は、呼んだだけでは始まらず、awaiter を作る時点(GetAwaiter)で始まります。

理由:遅延実行の awaitable は、どこかで始める必要があります。IsCompleted の getter で始めると、getter が副作用を持ちます。OnCompleted で始めると、同期に完了するタスクも一度止まることになり、同期完了の速い経路を失います。.NET にも前例があり、Rx の await observable は GetAwaiter の中で購読します。

代償:Task の GetAwaiter() は何も始めないので、同じつもりで呼ぶと黙ってタスクが動きます。アナライザの FLOW007 が、コードに書いた GetAwaiter() を警告します。awaiter を手で使えるのは同期に完了したとき(IsCompleted が true)だけで、そうしたコードは #pragma で警告を抑制します。

Signal<T>.Next() は、待っていない間の Emit を捨てます。取りこぼしたくない入力は Subscribe(BufferPolicy) で購読します(シグナル)。

理由

  • エッジを既定にするのは、ほかのライブラリと同じです。UniTask の button.OnClickAsync() も、待ち始める前のクリックを受け取りません。
  • 既定をバッファにすると、連打の対策(購入中の連打を捨てる)と衝突します。購入中の連打を後の Next が拾うと、二重購入になりえます。
  • 落ちた入力が「意図した捨て」か「取りこぼし」かは、ライブラリには分かりません。推測で警告すると、正しいコード(わざと連打を捨てる処理、共有の入力を使う Race)でも出て、範囲を狭めれば本当の取りこぼしを見逃します。そのため実行時の警告は持たず、エッジの意味として文書に書きます。
  • ループの中の Next() は取りこぼしが起きやすいので、アナライザの FLOW006 が知らせます。意図どおりのエッジのループ(アニメーション中のタップは捨てたい)もあるので、情報にとどめています。

代償:待ち始める前の入力が捨てられても、実行時には何も知らされません。拾いたい入力は、画面を開くとき(演出の前)に購読します。

Race は、最初に完了が処理された枝が勝ちます。枝は書いた順に始め、同期に勝者が決まったら残りは始めません。同時に完了したら、処理の順(多くは書いた順)で決まります(合成)。

理由:ほかの案は、どれも別の驚きか性能の悪化を持ち込みます。

  • 先に葉の待ちを調べる案では、前に書いた async メソッドが同期に完了しても、後ろの葉が勝ちます。
  • すべての枝を始めてから決める案では、同期に決着しても全部の枝の同期の部分が走り、負けた枝の巻き戻しのたびに例外の費用を払います。
  • 偏りを明記するのは、Kotlin の select と同じ扱いです。
  • 1 回の大きな dt で複数の期限が過ぎたときも、期限の順ではなく待ち始めた順に決まります。期限の順に並べ替えると、すべてのフローの実行順が変わり、費用も増えるためです。

負けた枝の値:負けた購読の待ちが受け取っていた値は、購読の先頭に戻します。被弾の購読を Race の枝にしたとき、被弾が消えないようにするためです。値を戻しても、Race の決着の規則と公開 API は変わらず、決定論と割り当てにも影響しません。

  • 戻すのは葉の待ちが受け取った値だけです。合成が値を自分の結果に取り込んだら、その値は受け取られたとみなします。入れ子の合成の値まで段ごとに戻す仕組みは、実装と規則の量に比べて効く場面が狭いので持ちません。
  • エッジの Signal.Next の値は戻しません。戻すと、次に待ち始めた Next が、待つ前の値を受け取ることになり、エッジの意味が変わるためです。

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 で書きます(下の「組み合わせで書くもの」)。

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 行で書けます。

取り消されたスコープでも、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 をそのまま使うほうが素直です。

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 のスレッドで使う必要があります。

時間を注入する 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 は誤差を許して比べません。どちらも、実行順を変えないためです(実行モデル)。

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 回を超えて同期に完了させる正しいフローも、無限ループとして終わります。

  • 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(型、メンバー、設定)を足すのは、実際の利用者がいて、既存の 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 です。

理由

  • 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 のセットアップ)。

状態機械とノードは型ごとのプールで使い回し、上限は型ごとに 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 と同じです。