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

構造体タスク

ValkarnTaskValkarnTask<T>はValkarn Tasksのコアとなる非同期戻り値型です。常にヒープ上にアロケーションされる参照型であるSystem.Threading.Tasks.Taskとは異なり、両方のValkarnタスク型はreadonly struct値です。このページでは、それが実際に何を意味するか、ゼロアロケーション正常系パスがどのように機能するか、そしてコンパイラーがasync/await機構とどのように統合するかを説明します。


なぜreadonly structなのか?

Task<T>のようなクラスベースのタスクは、同期的に完了するメソッドであっても、非同期メソッドが呼び出されるたびにヒープ上にアロケーションされなければなりません。60 fpsで動作するUnityゲームループでは、フレームごとに数百の小さな非同期操作が積み重なり、測定可能なGC負荷につながります。

ValkarnTaskValkarnTask<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

設計は単一の不変条件を中心に構築されています:

sourcenullの場合、タスクはエラーなしで同期的に完了しています。ヒープオブジェクトは関与しません。

ValkarnTask.CompletedTaskdefault(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 == nullValkarnTask<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>

機能ValkarnTaskValkarnTask<T>
戻り値なし(void相当)T
インライン結果保存resultフィールドなしresultフィールド(型T
Awaiter GetResultvoidTを返す
ビルダー型AsyncValkarnTaskMethodBuilderAsyncValkarnTaskMethodBuilder<TResult>
同期完了値ValkarnTask.CompletedTaskValkarnTask.FromResult(value)
非ジェネリックへの変換該当なし.AsNonGeneric()

非同期メソッドに意味のある戻り値がない場合はValkarnTaskを使用し、結果を生成する場合はValkarnTask<T>を使用してください。WhenAllのようなコンビネーターで型付きと非型付きのタスクを混在させる必要がある場合は、AsNonGeneric()を使ってValkarnTask<T>を常にValkarnTaskにダウンキャストできます。


非同期メソッドビルダーの仕組み

C#コンパイラーは戻り値型の[AsyncMethodBuilder(...)]に指定された型を探します。ValkarnTaskの場合はAsyncValkarnTaskMethodBuilderValkarnTask<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ループを使用して任意のスレッドから追加・削除できます。