メインコンテンツまでスキップ

自動キャンセルライフサイクル紐付け

Unityの非同期コードで最も多いバグの一つが、MonoBehaviourからタスクを起動して、オブジェクトが破棄されたときにキャンセルするのを忘れることです。タスクは実行を続け、破棄されたUnityオブジェクトにアクセスしようとしてMissingReferenceExceptionをスローするか、さらに悪い場合は状態を暗黙的に破損します。

ValkarnTasksは、非同期メソッドをオブジェクトの破棄ライフタイムに自動的に紐付けるRoslynソースジェネレーターを通じてこのバグのクラスを排除します。

問題点

インフラストラクチャなしでは、MonoBehaviour内のすべての非同期メソッドに対して、開発者が手動でCancellationTokenを渡し続ける必要があります:

// 手動アプローチ — 忘れやすく、メンテナンスが面倒
public class EnemyAI : MonoBehaviour
{
CancellationTokenSource _cts;

void OnEnable()
{
_cts = new CancellationTokenSource();
RunPatrolLoop(_cts.Token).Forget();
}

void OnDestroy()
{
_cts?.Cancel();
_cts?.Dispose();
}

async ValkarnTask RunPatrolLoop(CancellationToken ct)
{
while (true)
{
await ValkarnTask.Delay(2000, ct);
MoveToNextWaypoint();
}
}
}

非同期メソッドが増えるにつれてボイラープレートも増えます。OnDestroyをスキップしたり、間違った順序でdisposeしたりすると、上記のリークが発生します。

生成されたアプローチ

クラスをpartialとして宣言するだけで、ValkarnTasksが残りを処理します:

// 変更後 — partialを宣言するとジェネレーターが配線を行う
public partial class EnemyAI : MonoBehaviour
{
void OnEnable()
{
RunPatrolLoop().Forget();
}

async ValkarnTask RunPatrolLoop()
{
while (true)
{
await ValkarnTask.Delay(2000, __ValkarnTaskLifetimeToken);
MoveToNextWaypoint();
}
}
}

CancellationTokenSourceなし、OnDestroyなし、disposeなし。生成されたトークンはUnityがオブジェクトを破棄すると自動的にキャンセルされます。

ソースジェネレーターの仕組み

ジェネレーター(LifecycleBindingGenerator)はコンパイル時に実行されるインクリメンタルRoslynジェネレーターです。そのパイプラインには3つのステージがあります。

ステージ1 — 構文フィルター

ジェネレーターはプロジェクト内のすべてのクラス宣言を検査します。クラスは以下の場合に候補と見なされます:

  • partialキーワードで宣言されている。
  • 基底クラスリストを持つ(つまり、何かを継承している)。

このフィルターは純粋に構文的で非常に高速です。このステージでは意味解析は実行されません。

ステージ2 — 意味変換

各候補クラスに対して、ジェネレーターはRoslynのセマンティックモデルを使用して:

  1. クラスがUnityEngine.MonoBehaviourから派生していることを確認します(完全な継承チェーンを辿ります)。
  2. すべてのメンバーを列挙します。各メンバーについて以下を確認します:
    • asyncメソッドである。
    • UnaPartidaMas.Valkarn.Tasks.ValkarnTask(またはValkarnTask<T>)を返す。
    • [NoAutoCancel]を持たない。
  3. 適格なメソッドが見つからない場合、クラスは静かにスキップされます — 何も生成されません。
  4. 最初のpartial宣言のみが処理されます。クラスが複数のファイルに分割されている場合、ジェネレーターはコードを一度だけ、重複するメンバーを避けるために最初の宣言に紐付けて出力します。

ステージ3 — コード出力

ステージ1と2を通過した各クラスに対して、ジェネレーターは新しい.g.csファイルを書き出します。名前空間Game.EnemiesEnemyAIという名前のクラスの生成コードは次のようになります:

// <auto-generated/>
#nullable disable
using System.Threading;

namespace Game.Enemies
{
partial class EnemyAI
{
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
CancellationTokenSource __valkarnTaskLifetimeCts;

/// <summary>
/// このMonoBehaviourが破棄されたときにトリガーされるキャンセルトークン。
/// ValkarnTaskソースジェネレーターによって自動生成。
/// </summary>
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
protected CancellationToken __ValkarnTaskLifetimeToken
{
get
{
if (__valkarnTaskLifetimeCts == null)
__valkarnTaskLifetimeCts =
CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
return __valkarnTaskLifetimeCts.Token;
}
}
}
}

主要な詳細:

  • CancellationTokenSource遅延です — __ValkarnTaskLifetimeTokenが最初にアクセスされたときのみアロケーションされます。
  • Unityの組み込みdestroyCancellationToken(Unity 2022以降で利用可能なMonoBehaviour.destroyCancellationToken)にリンクされています。Unityがオブジェクトを破棄すると、destroyCancellationTokenが起動し、それが__valkarnTaskLifetimeCtsにカスケードされ、__ValkarnTaskLifetimeTokenがキャンセルされます。
  • フィールドとプロパティはどちらもEditorBrowsable(Never)とマークされているため、クラスのユーザーのIntelliSenseを汚染しません。
  • プロパティはprotectedなので、サブクラスも同じトークンを使用できます。

[NoAutoCancel]属性

[NoAutoCancel]を任意の非同期ValkarnTaskメソッドに適用すると、オブジェクトのライフタイムを超えて意図的に実行し続けます。一般的なシナリオ:

  • トリガーオブジェクトが破棄されても完了しなければならない、ディスクへのデータ保存メソッド。
  • 別のシステムが所有する共有リソースを管理するメソッド。
  • 開始したオブジェクトのライフタイムを意図的に超えるトランジションエフェクト。
public partial class SaveManager : MonoBehaviour
{
// このメソッドはDestroy時に自動キャンセルされる
async ValkarnTask AutoSaveLoop()
{
while (true)
{
await ValkarnTask.Delay(30_000, __ValkarnTaskLifetimeToken);
await WriteSaveToDisk();
}
}

// このメソッドは自動キャンセルされない — 書き込みを完了しなければならない
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}
}

[NoAutoCancel]メソッドレベルの属性です。ジェネレーターはそのメソッドを適格なメソッド数から単純に除外します。クラス内のすべてのメソッドが[NoAutoCancel]を持つ場合、そのクラスには何も生成されません。

アナライザー: TT014 — CancellationTokenパラメーターなしの[NoAutoCancel]

コンパニオンアナライザー(NoAutoCancelAnalyzer)はCancellationTokenパラメーターのないメソッドに[NoAutoCancel]を適用した場合に診断TT014を報告します。トークンパラメーターがない場合、メソッドにはキャンセルを観測する方法がありません — つまり[NoAutoCancel]は存在するが実質的な効果がありません。これは通常、パラメーターを追加し忘れたことを意味します:

// TT014: [NoAutoCancel]が適用されているがCancellationTokenパラメーターがない
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk() // <-- ctパラメーターが欠けている
{
await FileSystem.WriteAsync(_saveData);
}

CancellationTokenパラメーターを追加することで修正:

[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}

[FireAndForget]属性

[FireAndForget]は非同期メソッドを意図的にawaitしないとマークする別の補完的な属性です。2つの目的があります:

  1. 警告を抑制します VTASKS-TASK002とVTASKS-TASK013、これらは呼び出し元がValkarnTaskの戻り値をawaitしない場合に発生します。
  2. 意図を示します — コードの将来の読者はdiscardが意図的であることを知ります。

ソースジェネレーターは[FireAndForget]メソッドをラップして、未処理の例外がValkarnTasksの未観測例外ハンドラーを通じて公開され、暗黙的に失われないようにします。

public partial class SpawnManager : MonoBehaviour
{
void OnPlayerDied()
{
// 警告なし、意図が明確
ShowDeathScreenAsync();
}

[FireAndForget]
async ValkarnTask ShowDeathScreenAsync()
{
await ValkarnTask.Delay(500, __ValkarnTaskLifetimeToken);
_deathScreen.SetActive(true);
}
}

[FireAndForget][NoAutoCancel]は独立しており、組み合わせることができます:

[FireAndForget]
[NoAutoCancel]
async ValkarnTask PlayGlobalMusicAsync(CancellationToken ct = default) { ... }

アナライザー: TT010 — Auto-Cancelがアクティブ

AutoCancelInfoAnalyzerは、auto-cancelされる(つまり[NoAutoCancel]を持たない)MonoBehaviour内のすべての非同期ValkarnTaskメソッドに情報診断TT010を報告します。これはエラーや警告ではありません — どのメソッドがライフサイクルに紐付けられているかを一目で確認できるよう、意図的な透明性です。

TT010はメソッドごとに[NoAutoCancel]で抑制するか、表示したくない場合はプロジェクト全体で.editorconfigで無効化できます。

制限事項

クラスはpartialとして宣言する必要があります。 ソースジェネレーターはpartialでないクラスにメンバーを追加できません。MonoBehaviourpartialでない場合、ジェネレーターは静かにスキップし、バインディングは作成されません。[NoAutoCancel][FireAndForget]属性はドキュメントとアナライザー向けに引き続き機能しますが、__ValkarnTaskLifetimeTokenは利用できません。

ネストされたクラス。 MonoBehaviourが別のクラス内にネストされたクラスとして宣言されている場合、外側と内側のクラス宣言の両方がpartialでなければなりません。Roslynは生成されたメンバーが正しくコンパイルされるために、すべての囲む型がpartialであることを要求します。

基底クラス。 生成された__ValkarnTaskLifetimeTokenプロパティはprotectedです。サブクラスは自動的にアクセス権を継承します。ジェネレーターは階層の各クラスに対して独立して実行されます;基底クラスと派生クラスの両方が非同期メソッドを持つpartial MonoBehaviourである場合、それぞれに生成されたpartialが作成されますが、destroyCancellationTokenMonoBehaviour基底クラスから継承されるため、同じ基礎トークンを共有します。

多重継承。 C#はクラスの多重継承をサポートしません。MonoBehaviourは1つのクラス基底のみを持てるため、どのdestroyCancellationTokenにリンクするかについての曖昧さはありません。

ScriptableObjects。 ジェネレーターは現在MonoBehaviourのみをターゲットとします。ScriptableObjectはUnity APIにdestroyCancellationToken相当がないため、自動キャンセル生成は利用できません。