スコープとキャンセル
FlowTask では、CancellationToken を手で配りません。フローはスコープの木を作り、親が終わると子が巻き戻ります。このページでは、スコープの木、キャンセルと巻き戻し、後始末の書き方(using、finally、Flow.AddCleanup、Flow.Own、Flow.NonCancelable)、キャンセルを受けた catch の書き方を説明します。
スコープの木
Section titled “スコープの木”FlowTask メソッド 1 回の実行を、スコープと呼びます。始めたタスクは、始めた文脈のスコープの子になります。
async FlowTask Game(){ _ = Flow.Spawn(PlayBgm()); // a child of Game await Title(); // a child of Game await InGame(); // a child of Game; InGame's own awaits are its children}Title が終わり、InGame が Battle を await しているときの木は、次のとおりです。終わったスコープ(Title)は木から外れます。
Game├─ PlayBgm└─ InGame └─ Battle親が終わると、まだ動いている子孫はすべて巻き戻ります。Game が return すれば PlayBgm も止まります。どのフローがどこまで生きるかは、コードの形から決まります。
- 寿命を持つのはこの木だけです。ここでいうスコープは FlowTask のスコープで、VContainer の
LifetimeScopeなどとは関係ありません。 world.Runで始めたフローは World のルートの子です(フローと World)。- 今の木は
world.Dump()で見られます(デバッグ)。
キャンセルは巻き戻し
Section titled “キャンセルは巻き戻し”キャンセルされたスコープでは、止まっている await から FlowCanceledException が送出され、using と finally を走らせながらメソッドを抜けます。これを巻き戻しと呼びます。ゲームの状態を元に戻す(ロールバックする)わけではありません。
async FlowTask Hud(){ Flow.Own(new InputLock()); // disposed when Hud ends, however it ends var view = OpenHud(); try { while (true) { await FlowTask.NextFrame(); view.Refresh(); } } finally { view.Close(); // runs on a cancel too }}
var hud = world.Run(Hud());// ...hud.Cancel(); // Hud unwinds: the finally runs, then the InputLock is disposedスコープが取り消されるのは、次のときです。理由はハンドルの CancelCause で分かります。
| きっかけ | CancelCause |
|---|---|
親が return した(Flow.Spawn の子) |
ParentEnded |
FlowTask.Race で負けた |
RaceLost |
FlowHandle.Cancel() |
Explicit |
| 失敗(Race や WhenAll の兄弟の例外、Spawn した子の例外、親が例外で終わった、誤用の検出) | Fault |
FlowWorld.Dispose() |
WorldDisposed |
取り消されたスコープの下のスコープは、そのスコープの理由を引き継ぎます。
巻き戻しの順序
Section titled “巻き戻しの順序”巻き戻しは、コールスタックを戻るのと同じ順に進みます。
- 子孫が先で、根に向かいます。スコープに
FlowCanceledExceptionが届くのは、子がすべて(後始末の await を含めて)終わってからです。 - 兄弟の間では、後から始めたものから巻き戻し始めます。
- 1 つのスコープの中では、
finallyとusingを内側から外側へ走らせた後、そのスコープのFlow.AddCleanupとFlow.Ownを、登録と逆の順(LIFO)に走らせます。
いつ巻き戻るか
Section titled “いつ巻き戻るか”フローのコードの中で handle.Cancel() を呼ぶと、相手はその場で巻き戻り始めます。次の行に進む前に、相手の finally が(await を含むなら最初の await まで)走ります。フローの外(ゲームのコード、UI のコールバック)で取り消したときは、次の Tick か Flush の最初に巻き戻ります。それまで Status は Running のままです。別のスレッドから取り消したときと、自分自身を取り消したときの時期は 実行モデル にあります。
同期の後始末:using、finally、AddCleanup、Own
Section titled “同期の後始末:using、finally、AddCleanup、Own”後始末は、キャンセルでも例外でも普通の return でも走ります。
async FlowTask Shop(){ var view = Flow.Own(OpenShopView()); // disposed when Shop ends Flow.AddCleanup(() => analytics.Log("shop closed")); // runs when Shop ends using var pause = gameClock.Pause(); // released at the end of this block await RunShop(view);}Flow.Own(x)は、渡したIDisposableをスコープの終わりに Dispose し、xをそのまま返します。フローの外で呼ぶと何も所有せず、呼んだ側が Dispose します。Flow.AddCleanup(action)は、スコープの終わりに走る処理を登録します。フローの外ではFlowMisuseExceptionになります。クロージャを避けたいときはFlow.AddCleanup(state, s => …)を使います。Clock.Pause()、Signal.Subscribe、FlowBridge.FromCallbackが返すハンドルも、作ったスコープが所有し、スコープの終わりに解放されます。早く解放したいときはusingを付けます。戻り値を捨てると、スコープが終わるまで生き続けるので、アナライザの FLOW004 が警告します。
後始末の await
Section titled “後始末の await”キャンセルが届いて入った finally と catch の中の await は、最後まで走ります。閉じるアニメーションや保存を、そのまま await で書けます。
async FlowTask Dialog(){ var view = Open(); try { await FlowTask.Never(); // left only by the cancel } finally { await view.PlayClose(); // entered by the cancel: runs to its end view.Destroy(); }}await using の DisposeAsync も同じく最後まで走ります。
- 後始末が終わるまで、スコープは
Runningのままです(CancelCauseで閉じかけと分かり、ダンプの行に<canceling: Explicit>のように出ます)。 - 周りはすべて、その終わりを待ちます。親の終了、Race の呼び出し元(負けた枝の後始末が終わってから再開する)、失敗した WhenAll(兄弟の後始末が終わってから例外を伝える)が待ちます。
- 後始末の await が始めたタスクは、後から祖先が取り消されても取り消されません。届くのは
World.Disposeだけです。 - 後始末の await は、ほかの再開と同じく Pause に従います。ゲームのポーズ中にも終えたい後始末は、UI の Clock で走らせます(時間と Clock)。
注意:後始末の await に時間の上限はありません。終わらない後始末(入力待ち、終わらないループ)は、フローとそれを待つ周りを止めたままにします。上限が要るなら Race にします:
await FlowTask.Race(Save(), FlowTask.WaitForSeconds(2));(2 秒で終わらなければSaveは巻き戻ります)。取り消されたスコープが後始末で倍率のかからない時間(UnscaledClock)の 10 秒を過ぎても待ち続けると、LongCleanupの警告が World で 1 回出ます(デバッグ)。
Race の呼び出し元を負けた枝の後始末で待たせたくないときは、後始末を FlowWorld.Current.Run(…) で別のフローとして始めます。
キャンセルの前に入った finally:Flow.NonCancelable
Section titled “キャンセルの前に入った finally:Flow.NonCancelable”return、try ブロックの終わり、例外で入った finally は、まだ生きているスコープのコードです。ライブラリには、その await が本体のものか finally のものかが分かりません。そのため、その finally の await の間にキャンセルが来ると、そこで FlowCanceledException が投げられ、finally の残りは飛び、運んでいた例外もそれに置き換わって消えます。
最後まで走らせたい await は Flow.NonCancelable で包みます。
try{ await ShowResult();}finally{ // Entered by the return above. An ancestor's cancel does not reach these awaits. await Flow.NonCancelable(view.PlayClose()); await Flow.NonCancelable(FlowTask.Race(Save(), FlowTask.WaitForSeconds(5))); // gives up after 5 s view.Destroy(); // runs after them, even if canceled meanwhile}- 印の付いた await は、スコープが取り消されていても(その前でも最中でも)タスクを始め、終わるまで待ち、結果か例外を受け取ります。キャンセルは、印のない次の await でスコープに届きます。
- 運んでいた例外は、印の付いた await の後にもう一度投げられます。スコープはキャンセルで終わるので、その例外は
Undeliveredとして報告されます(失敗の扱い)。消えはしません。 - 印は、FlowTask メソッドの中で直接 await したときだけ効きます。
Flow.Spawn、FlowWorld.Run、合成(Race、WhenAll)に渡すとFlowMisuseExceptionになります。上限を付けるなら、上の例のように印の中で Race にします。 - 印の付いた await も Pause に従い、時間の上限はありません。入力待ちやほかのフローの待ちには付けないでください。取り消せなくなります。
- キャンセルが届いた後に入った
finallyとcatchでは、印は要りません(付けても変わりません)。 World.Disposeは、印の付いた await も終わらせます。- アナライザの FLOW010 が、印のない
finallyの await を知らせます(アナライザ)。上のDialogのように、キャンセルでしか入らないfinallyでも知らせます。そこでは印を付けても動きは変わらないので、付けるか、抑制します。
取り消されたスコープの Spawn
Section titled “取り消されたスコープの Spawn”取り消しが確定したスコープでは、FlowCanceledException がコードに届くまで、Flow.Spawn はタスクを始めず、Canceled で終わったハンドルを返します。Flow.NonCancelable の await の後に続くコードも、まだキャンセルが届いていないので、ここに入ります。キャンセルが届いた後の finally や catch の中では始まり、そのスコープが終わるときに巻き戻ります。
巻き戻しは状態を戻しません。キャンセルされても残したい進捗は、スコープより長く生きる場所(FlowProperty、セーブデータ)に、進むたびに書きます。
async FlowTask Download(FlowProperty<int> done){ for (var i = done.Value; i < chunkCount; i++) { await FetchChunk(i); done.Set(i + 1); // kept when the download is canceled: the next run starts here }}終わりに一度だけ書けばよいなら、Flow.AddCleanup で登録します。
catch の書き方
Section titled “catch の書き方”FlowCanceledException は OperationCanceledException の派生です。そのため、キャンセルをまとめて捕まえる catch は、FlowTask のキャンセルも捕まえます。キャンセルを受け取った catch は、必ず throw; で終えます。
// OK: everything except the cancellationcatch (Exception e) when (e is not FlowCanceledException) { ShowError(e); }
// OK: cleanup that runs only on a cancel, then let it go oncatch (FlowCanceledException) { Undo(); throw; }
// OK: only an external cancellation (an HttpClient timeout, a canceled Task)catch (OperationCanceledException e) when (e is not FlowCanceledException) { ShowTimeout(); }
// Wrong: swallows the flow's cancellation (FLOW001, an error)catch (OperationCanceledException) { return; }catch (Exception e) { Log(e); }- キャンセルを受け取った後に return したり先へ進んだりすることを、握りつぶしと呼びます。UniTask でよく書く
catch (OperationCanceledException) { return; }も握りつぶしです。アナライザの FLOW001 がコンパイル時に止めます(アナライザ)。 - 実行時には、キャンセルを受け取った後に return したスコープを
SwallowedCancellationとして 1 回報告し、キャンセルで終えます。巻き戻しはそのスコープより外へは広がらず、呼び出し元は続きます。 - ループの中で握りつぶす
catchは、キャンセルの後の await も後始末の await として走るので、回り続けます。return するまで報告もされません。 when (e is not OperationCanceledException)と書くと、外部のキャンセル(Task のタイムアウトなど)もハンドラを素通りし、捕まえなければ未処理の例外になります。外部のキャンセルは、FlowTask のキャンセルではなく例外として届きます(失敗の扱い)。FlowCanceledExceptionのCancellationTokenはNoneです。when (e.CancellationToken == token)はどのトークンにも一致しません。FlowCanceledExceptionを作れるのはライブラリだけです。受け取ったキャンセルは、そのcatchの中でthrow;で投げ直すだけにします。取っておいて、取り消されていないスコープで投げ直すと、それを InnerException に持つFlowMisuseExceptionでスコープが終わります。
キャンセルされてもやり遂げたい処理は finally で await し、途中でやめて切り替えたい処理は FlowTask.Race で書きます(合成)。
規則と落とし穴
Section titled “規則と落とし穴”- 終わったタスクの
Cancelは何もしません。Cancelはどのスレッドからでも呼べるので、外部のCancellationTokenにct.Register(handle.Cancel)で登録できます(Task / ValueTask)。 - 取り消すと、止まっている async メソッド 1 個ごとに
FlowCanceledExceptionを 1 回投げます。葉の待ち(Next、WaitForSeconds、WaitUntilなど)は投げません。毎フレーム負ける Race の枝は、async メソッドではなく葉の待ちにします(性能)。 - デバッガが
FlowCanceledExceptionのたびに止まるときの設定は デバッグ にあります。 World.Disposeの間の後始末は最初の await までしか走りません。最後まで走らせたい後始末があるときの閉じ方は 失敗の扱い にあります。- Unity の GameObject や Godot のノードの寿命にフローを結び付けるには、Unity の寿命、Godot の寿命 を見てください。