構造体タスク
ValkarnTaskとValkarnTask<T>はValkarn Tasksのコアとなる非同期戻り値型です。常にヒープ上にアロケーションされる参照型であるSystem.Threading.Tasks.Taskとは異なり、両方のValkarnタスク型はreadonly struct値です。このページでは、それが実際に何を意味するか、ゼロアロケーション正常系パスがどのように機能するか、そしてコンパイラーがasync/await機構とどのように統合するかを説明します。
なぜreadonly structなのか?
Task<T>のようなクラスベースのタスクは、同期的に完了するメソッドであっても、非同期メソッドが呼び出されるたびにヒープ上にアロケーションされなければなりません。60 fpsで動作するUnityゲームループでは、フレームごとに数百の小さな非同期操作が積み重なり、測定可能なGC負荷につながります。
ValkarnTaskとValkarnTask<T>はreadonly partial structとして宣言されています:
[AsyncMethodBuilder(typeof(CompilerServices.AsyncValkarnTaskMethodBuilder))]
[StructLayout(LayoutKind.Auto)]
public readonly partial struct ValkarnTask
{
internal readonly ISource source;
internal readonly uint token;
}
[AsyncMethodBuilder(typeof(CompilerServices.AsyncValkarnTaskMethodBuilder<>))]
[StructLayout(LayoutKind.Auto)]
public readonly struct ValkarnTask<T>
{
internal readonly ValkarnTask.ISource<T> source;
internal readonly T result;
internal readonly uint token;
}
構造体であることは、タスク値自体がヒープ上ではなくスタック上(または親オブジェクト内にインライン)に存在することを意味します。readonly修飾子により、コンパイラーが不変性について推論でき、誤ったコピーのバグを防げます。StructLayout.Autoによりランタイムがターゲットプラットフォーム向けにフィールドの順序を最適化できます。
主要な不変条件:source == null
設計は単一の不変条件を中心に構築されています:
sourceがnullの場合、タスクはエラーなしで同期的に完了しています。ヒープオブジェクトは関与しません。
ValkarnTask.CompletedTaskはdefault(ValkarnTask)です — そのsourceフィールドはnullなので、コストゼロです。ValkarnTask<T>はresultフィールドにインラインで結果を保持しており、ValkarnTask.FromResult(value)もゼロアロケーション呼び出しになります:
// ゼロアロケーション — sourceはnull、resultはインライン保存
ValkarnTask<int> task = ValkarnTask.FromResult(42);
// こちらもゼロアロケーション — sourceはnull
ValkarnTask done = ValkarnTask.CompletedTask;
ゼロアロケーション正常系パス
asyncメソッドが一度もサスペンドせず完了した場合(不完全な操作に対するawaitでyieldしない)、メソッド全体が呼び出しスレッド上で同期的に実行されます。ビルダーはこれを検出し、source == nullのタスクを返します。
awaiterは即座にこれをチェックします:
public bool IsCompleted
{
get
{
var s = task.source;
return s == null || s.GetStatus(task.token).IsCompleted();
}
}
OnCompletedが呼ばれる前にIsCompletedがtrueの場合、ステートマシンはコンティニュエーションを登録しません。GetResultは即座に呼ばれ、source == nullのValkarnTask<T>では、結果は構造体のインラインresultフィールドから読み取られます:
public T GetResult()
{
var s = task.source;
if (s == null)
return task.result; // インライン、ISource呼び出しなし
return s.GetResult(task.token);
}
オブジェクトは作成されず、インターフェースディスパッチも発生せず、コンティニュエーションデリゲートもアロケーションされません。await全体が直接の値読み取りとして解決されます。
ソースが必要な場合
非同期メソッドがサスペンドした場合(まだ完了していないものをawaitした場合)、ビルダーはプールされたAsyncValkarnTaskRunner<TStateMachine>オブジェクト(ジェネリックバリアントの場合はAsyncValkarnTaskRunner<TStateMachine, TResult>)をアロケーションします。このオブジェクトは二重の役割を持ちます:コンパイラーが生成したステートマシンを値として保持し、ValkarnTask.ISourceを実装するので、タスクのバッキングソースとして直接使用できます。呼び出し元に返されるタスクはこのランナーと世代uintトークンをラップします。
完了時に、呼び出し元がawaiterのGetResultを呼び出すと、ランナーはリセットされてプールに戻ります — アロケーションは多くのメソッド呼び出しにわたって償却されます。
ISourceインターフェース
ValkarnTask構造体と非同期バッキングオブジェクトの間のコントラクトがValkarnTask.ISourceです:
public interface ISource
{
ValkarnTask.Status GetStatus(uint token);
void GetResult(uint token);
void OnCompleted(Action<object> continuation, object state, uint token);
ValkarnTask.Status UnsafeGetStatus();
}
public interface ISource<out T> : ISource
{
new T GetResult(uint token);
}
ISourceを実装するオブジェクトはValkarnTaskをバックアップできます。ライブラリにはいくつかの実装が含まれています:
| 型 | 目的 |
|---|---|
AsyncValkarnTaskRunner<TStateMachine> | すべてのasync ValkarnTaskメソッドをバックアップ(内部) |
AsyncValkarnTaskRunner<TStateMachine, TResult> | すべてのasync ValkarnTask<T>メソッドをバックアップ(内部) |
ValkarnTask.PooledPromise | 自動プール返却付き手動完了ソース |
ValkarnTask.PooledPromise<T> | 上記のジェネリックバリアント |
ValkarnTask.Promise | プーリングなし手動完了ソース(長期的な操作向け) |
ValkarnTask.Promise<T> | 上記のジェネリックバリアント |
uint tokenパラメーターは世代ガードです。プールされたソースが再利用のためにリセットされると、世代カウンターがインクリメントされます。古いトークンを持つValkarnTask構造体は、リサイクルされた状態を暗黙的に読み取る代わりに、即座にInvalidOperationExceptionを受け取ります。
ValkarnTask vs ValkarnTask<T>
| 機能 | ValkarnTask | ValkarnTask<T> |
|---|---|---|
| 戻り値 | なし(void相当) | T |
| インライン結果保存 | resultフィールドなし | resultフィールド(型T) |
Awaiter GetResult | void | Tを返す |
| ビルダー型 | AsyncValkarnTaskMethodBuilder | AsyncValkarnTaskMethodBuilder<TResult> |
| 同期完了値 | ValkarnTask.CompletedTask | ValkarnTask.FromResult(value) |
| 非ジェネリックへの変換 | 該当なし | .AsNonGeneric() |
非同期メソッドに意味のある戻り値がない場合はValkarnTaskを使用し、結果を生成する場合はValkarnTask<T>を使用してください。WhenAllのようなコンビネーターで型付きと非型付きのタスクを混在させる必要がある場合は、AsNonGeneric()を使ってValkarnTask<T>を常にValkarnTaskにダウンキャストできます。
非同期メソッドビルダーの仕組み
C#コンパイラーは戻り値型の[AsyncMethodBuilder(...)]に指定された型を探します。ValkarnTaskの場合はAsyncValkarnTaskMethodBuilder、ValkarnTask<T>の場合はAsyncValkarnTaskMethodBuilder<TResult>です。
ビルダー自体は、ビルダーオブジェクト自体のヒープアロケーションを避けるために構造体です。2つのフィールドを持ちます(ジェネリックバリアントは3つ):
public struct AsyncValkarnTaskMethodBuilder
{
IStateMachineRunnerPromise runner; // 最初のサスペンドまでnull
Exception syncException; // 同期フォルトパスのみ設定
}
public struct AsyncValkarnTaskMethodBuilder<TResult>
{
IStateMachineRunnerPromise<TResult> runner;
Exception syncException;
TResult result; // 同期成功パスのみ設定
}
ビルダーのライフサイクル
コンパイラーはこれらのメソッドを順番に呼び出します:
1. Create() — デフォルトビルダーを返します(すべてのフィールドnull/デフォルト)。アロケーションなし。
2. Start(ref stateMachine) — stateMachine.MoveNext()を同期的に呼び出します。メソッドが不完全なawaitに到達せずに完了した場合、SetResult/SetExceptionが呼ばれ、runnerはnullのままです。
3. AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine) — メソッドが不完全なawaitに遭遇したときに呼ばれます。runnerがnull(最初のサスペンド)の場合、AsyncValkarnTaskRunnerをレンタルまたは作成し、ステートマシンをそこにコピーします。次にawaiter.UnsafeOnCompleted(runner.MoveNextAction)を呼び出してステートマシンコンティニュエーションを登録します。
4. SetResult() / SetException(exception) — ランナーのValkarnTaskCompletionCoreへの完了をシグナルし、登録済みのawaiterを起こします。
5. Taskプロパティ — 呼び出し元がValkarnTask値を取得するために確認します。同期成功パス(runner == null && syncException == null)では、default(またはジェネリックバリアントの場合new ValkarnTask<T>(result))を返します — ゼロアロケーション。非同期パスでは、ランナーをソースとしてラップします。
重要な最適化として、runnerは遅延アロケーションされます。メソッドが同期的に完了した場合(キャッシュヒット、ガード、早期リターンなど一般的なケース)、プールされたオブジェクトは一切レンタルされません。
ValkarnTaskStatusの状態
ステータスはValkarnTask内にネストされたbyteサイズの列挙型で表されます:
public enum Status : byte
{
Pending = 0, // まだ完了していない
Succeeded = 1, // 正常に完了した
Faulted = 2, // 未処理例外で完了した
Canceled = 3 // OperationCanceledExceptionで完了した
}
ステータスを直接確認できます:
ValkarnTask task = SomeOperation();
ValkarnTask.Status status = task.GetStatus();
switch (status)
{
case ValkarnTask.Status.Pending:
// まだ実行中 — GetResultを呼べない
break;
case ValkarnTask.Status.Succeeded:
// 正常に完了した
break;
case ValkarnTask.Status.Faulted:
// 例外で完了 — GetResultは再スローする
break;
case ValkarnTask.Status.Canceled:
// OperationCanceledExceptionで完了した
break;
}
同期完了高速パス(source == null)では、GetStatus()はインターフェース呼び出しなしでSucceededを返します:
public Status GetStatus()
{
if (source == null) return Status.Succeeded;
return source.GetStatus(token);
}
IsCompletedプロパティは同じパターンに従い、Pending以外の任意の状態でtrueを返します。
IL2CPPへの影響
IL2CPP はC#をC++ソースコードにコンパイルしてからネイティブコードにビルドします。ジェネリック値型(構造体を含む)は生成コードで完全に特殊化されており、これはこのライブラリにとって重要な結果をもたらします。
ステートマシンの特殊化。 コンパイラーは非同期メソッドごとに固有のステートマシン構造体を生成します。AsyncValkarnTaskRunner<TStateMachine>もそれゆえ非同期メソッドごとに固有であり、ValkarnTaskPool<AsyncValkarnTaskRunner<TStateMachine>>はメソッドごとに別のプールです。これは実際には有益です:プールは互換性のない型にわたって共有されることがなく、型の混同リスクを排除します。
ステートマシンのボクシングなし。 ステートマシンはランナーオブジェクト内に値として保存されます。IL2CPPはこれを正しく処理します。なぜならランナーは具体的なTStateMachineフィールドを持つsealed classだからです。
ストリッピング保護。 [AsyncMethodBuilder]属性によりビルダー型が生きた状態を保ちます。ただし、アグレッシブなストリッピングを行うIL2CPPでインターフェース参照を通じてValkarnTask.ISourceを使用する場合は、UnaPartidaMas.Valkarn.Tasksアセンブリを保持するlink.xmlエントリを追加してください:
<linker>
<assembly fullname="UnaPartidaMas.Valkarn.Tasks" preserve="all"/>
</linker>
ICriticalNotifyCompletion。 awaiter構造体はICriticalNotifyCompletionを実装しており、コンパイラーにOnCompletedの代わりにUnsafeOnCompletedを呼ぶよう指示します。「unsafe」バリアントは意図的にExecutionContextのキャプチャをスキップします。これはUnityでは正しい動作です — UnityのデフォルトConfiguration ではSynchronizationContextがなく、キャプチャすると利点のないオーバーヘッドが増えます。IL2CPPでは、標準Taskが常に支払うExecutionContext.Runパスのオーバーヘッドも回避できます。
実践的な例
アロケーションなしの早期リターン
// ホットパスで同期的に完了するasync ValkarnTask<int>
async ValkarnTask<int> GetCachedValue(string key)
{
if (_cache.TryGetValue(key, out var value))
return value; // コンパイラーがSetResult(value)を呼ぶ;sourceはnullのまま
var result = await FetchFromDatabaseAsync(key);
_cache[key] = result;
return result;
}
値がキャッシュにある場合、メソッドは一度もサスペンドしません。返されたValkarnTask<int>はsource == nullでインラインに結果を保持します。このパスではヒープアロケーションは発生しません。
awaitの前にIsCompletedを確認する
ValkarnTask<Texture2D> loadTask = LoadTextureAsync("sprites/hero.png");
if (loadTask.IsCompleted)
{
// 既に完了 — GetAwaiter().GetResult()はISource呼び出しなしでインライン結果を読む
Texture2D tex = loadTask.GetAwaiter().GetResult();
ApplyTexture(tex);
}
else
{
// 本当に非同期 — コンティニュエーションを登録
ApplyTextureAsync(loadTask).Forget();
}
未処理例外の監視
awaitされなかったフォルトタスク(fire-and-forgetパターン)は、ValkarnTask.UnobservedExceptionイベントを通じて例外を報告します。これは、プールされたソースのプール返却時に決定的に発生します。
ValkarnTask.UnobservedException += ex =>
{
Debug.LogError($"[ValkarnTask] 未処理: {ex}");
};
イベントはスレッドセーフです;ハンドラーはロックフリーのcompare-exchangeループを使用して任意のスレッドから追加・削除できます。