1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】コルーチン好き!GCキライ!だからシーケンス制御特化 async/await エンジン作った!

1
Last updated at Posted at 2026-07-01

~はじめに~ なぜ作った?

Unityでのゲーム開発において、時間の流れを伴う「シーケンス制御」をどう書くかは重要な課題です。

私は現在開発中の自作シューティングゲームにおいて、敵の挙動や弾幕のシーケンスを管理するために、当初は独自に IEnumerator の配列をループで回すシステムを作って動かしていました。でっかい switch 文を書いて状態管理とかしたくないので。

でも、Unityと長く触れ合ってるとどうしても耳に入るんです。
「Unityエンジニアは、親の仇のようにGC(ガベージコレクション)アロケーションを憎んでいる」
私の愛用する IEnumerator は、実行のたびにヒープにゴミをまき散らす存在でした。

「だったら、界隈のデファクトスタンダードである UniTask に乗り換えようか」

当然そう思い、導入を検討しました。UniTaskは本当に素晴らしいライブラリです。しかし、私のシューティングゲームのアーキテクチャとはどうにも噛み合わない。

私のプロジェクトでは、タスクを変数として受け渡したり、システムの都合に合わせて 手動で MoveNext() を叩いて進行を細かくコントロールする手法 を多用します。UniTaskは強力な非同期ライブラリですが、こういった「毎フレーム手動でゴリゴリ回す」ようなシーケンス制御を行うにはなかなかハードルが高い。

さらにパフォーマンス面を掘り下げると、UniTaskはオブジェクトプールによってアロケーションを抑えてはいるものの、元となるステートマシン自体は個別にアロケートして管理してるっぽい。

「手動で MoveNext() を叩ける自由度を残したまま、ステートマシンを最初から『構造体の配列』として扱うシステムを作ればアロケーションが憎い人も文句はないだろう」

そう考えた私は、コルーチンの実行モデル(フレームベースの進行)を維持したまま、async/await の皮を被った全く新しい構造体配列ベースのタスクエンジンを自作することにしました。

こうして誕生したのが、ゲームロジック・シーケンス制御特化型のゼロアロケーション非同期タスクエンジン 「Omochaya Story」 です。


Omochaya Story とは?

Story の目的は「非同期処理をすること」ではなく、「コルーチンをより軽量・高速・安全に実行すること」 です。結果として async/await の構文を採用していますが、内部的にはコルーチンと全く同じメンタルモデルで動きます。

UniTask との明確な棲み分け

Story は汎用的な非同期ライブラリではありません。UniTaskの代替ではなく、Coroutine(Unity)の代替に特化しています。

項目 Omochaya Story UniTask Coroutine(Unity)
Coroutine(Unity)の置換 ◎ 最適 -
手動の進行制御 (MoveNext等) ◎ 最適
マルチスレッド × ×
ネットワーク通信 / ファイルI/O △(要ポーリング) △(要ポーリング)
ゼロアロケーションへの特化 ×
安全な後始末 ×

HTTP通信やスレッドワーカーなどには向きませんが、「RPGイベント」「会話システム」「ターン制バトル」「カットシーン」など メインスレッドで完結するフレームベースのゲームロジック において最高のパフォーマンスを発揮します。


Omochaya Story が目指したもの

このライブラリの根底にあるテーマは、非常にシンプルです。
「コルーチンにできることは全部できる。コルーチンにできないことも async だからできる」

1. 【Storyでも】基本的なことは全部できる

Coroutine(Unity)でできることをまとめて書くとこんな感じだと思います。

void Start()
{
    // [case.1] ルートタスクを起動する
    this.StartCoroutine(RootTask());
}
IEnumerator RootTask()
{
    // [case.2] サブタスクを起動して終了を待つ
    yield return SubTask();

    // [case.3] 手動で回す
    foreach (var _ in SubTask2()) { yield return null; } // IEnameratable SubTask2() {} の場合

    // [case.4] 直接 MoveNext() を叩いて進める
    var tmpTask = SubTask();
    while (tmpTask.MoveNext()) { yield return null; }

    // [case.5] タスクを一旦変数に入れて...
    var subTask = SubTask();

    // 別の場所で「起動」する
    yield return new WaitForSeconds(1f);
    this.StartCoroutine(subTask);

    // 別の場所で「終了」する
    yield return new WaitForSeconds(1f);
    StopCoroutine(subTask);

    // [case.6] 自身が消えるとルートタスクは終了する
    Destroy(this.gameObject);
    Debug.Log("ここは実行されるけど");
    yield return null;
    Debug.Log("ここは実行されない");
}

これを Story で書くとこうなります。

using Omochaya;

void Start()
{
    // [case.1] ルートタスクを起動する
    RootTask().Start(this);
}
async Story.Task RootTask()
{
    // [case.2] サブタスクを実行して待つ
    await SubTask();

    // [case.3] 手動で回す
    foreach (var _ in SubTask()) { await Story.Yield; } // IEnumerator と IEnumerable のどっち?とか意識しなくてよい

    // [case.4] 直接 MoveNext() を叩いて進める
    while (SubTask().MoveNext()) { await Story.Yield; } // 変数を介さなくてもOK

    // [case.5] タスクを一旦変数に入れて...
    var subTask = SubTask(); // UniTask と違ってこれだけではタスクは動きません
    subTask.Keep(); // 【ここだけ注意】使ってないタスクはフレームを跨ぐと解放される(ようにしてる。リーク対策)ので、すぐに使わないときは Keep してください

    // 別の場所で「起動」する
    await Story.WaitTime(1f);
    subTask.Start(this);

    // 別の場所で「終了」する
    await Story.WaitTime(1f);
    subTask.Stop();

    // [case.6] 自身が消えるとルートタスクは終了する
    Destroy(this.gameObject);
    Debug.Log("ここは実行されるけど");
    await Story.Yield;
    Debug.Log("ここは実行されない");
}

2. 【Storyでも】Update / Late / Fixed のシームレスな移動ができる

Coroutine(Unity)のちょっと特殊だけど便利な機能でこういうものがあります。

IEnumerator PhysicsSequence()
{
    // 最初の yield return までは呼び出した所で実行
    UpdateLogic();

    // FixedUpdate 層(の直後)で実行
    yield return new WaitForFixedUpdate();
    RigidbodyLogic();

    // LateUpdate 層(の直後)で実行
    yield return new WaitForEndOfFrame();
    CameraLogic();

    // Update 層(の直後)に戻る
    yield return null;
    UpdateLogic();
}

Storyでも書けます。

using Omochaya;

async Story.Task PhysicsSequence()
{
    // 最初の await までは呼び出した所で実行
    UpdateLogic();

    // FixedUpdate 層で実行
    await Story.YieldFixed;
    RigidbodyLogic();

    // LateUpdate 層で実行
    await Story.YieldLate;
    CameraLogic();

    // Update 層に戻る
    await Story.Yield;
    UpdateLogic();
}

3. 【Storyだから】ゼロアロケーション

Omochaya Story 作成のきっかけです。

IEnumerator ではなく async なのでステートマシンは構造体です。
タスク生成時にこの構造体をシステムで管理している配列(プール)にぶち込みます。

どこに入れたかは世代管理付きIDで管理しているので 安心安全 です。使い終わったタスクのハンドルで間違ってアクセスすることはありません。

空きはフリーリストで管理しているので 確保/解放もチョッパヤ です。

配列なので初回や容量をオーバーして拡張するときにアロケーションが発生しますが、アプリ起動時にあらかじめ所定メソッド( Warmup )で最大サイズを宣言しておけば、実行中にはアロケーションは発生しません。

ただし...タスクをキャンセルしたときに安全に finally を処理するためにキャンセル例外を throw しています。インスタンスはシングルトンですが throw する以上内部でスタックトレース生成のアロケーションは発生してしまいますごめんなさい。
v1.1.1 にて CancelMode を実装し、例外を発生させないキャンセルを書けるようになりました。詳しくはこちらの記事をご覧ください。

また開発時はデバッグにログ出力や文字列生成を行っているため、そこでもアロケーションは発生します。こちらは製品版では無効化されます。

それ以外では 「完全ゼロアロケーション!」(のハズ)

4. 【Storyだから】タスクの戻り値を受け取れる

Storyは async/await ベースなので戻り値を受け取れます。

using Omochaya;

async Story.Task MainTask()
{
    var result = await SubTask();
    if (Story.HasValidResult())
    {
        // SubTask() が正常に終了した場合
        Debug.Log($"result = {result}"); // result = 1
    }
    else
    {
        // SubTask() がキャンセルされた場合は default が返ります
        Debug.Log($"result = {result}"); // result = 0
    }
}
async Story.Task<int> SubTask()
{
    await Story.WaitTime(10f);
    return 1;
}

5. 【Storyだから】finally が安全に処理される

Unityの標準コルーチン(IEnumerator)で例外処理や後始末を書こうとしたとき、多くのUnityエンジニアが苦い顔をします。

  1. C#の仕様上、catch の中では yield return が書けない。
  2. Unityの仕様上、外部から StopCoroutine されたり、オブジェクトが Destroy されたりすると、コルーチンは finally ブロックを実行せずに強制終了(完全沈黙)する。

Storyは async/await ベースなので try-catch の中で待機処理が書けます。
一度キャンセル(Stop)されたのにそれでも待機したタスク、に対してさらにキャンセル(Stop)することもできます。つまり正しくタスクを書いておけば、止めたのに終わらないタスクを終わるまで止めつつ finally ブロックは確実に処理することができます。

using Omochaya;

async Story.Task Sequence()
{
    try
    {
        while(true) { await Story.Yield; } // この最中にGameObjectが破壊されたとする
    }
    finally
    {
        try { await FinalEffect(); } // 最後の演出...長いのでキャンセルされたとする
        catch (Exception e) when (Story.IsCanceledException(e)) { } // こう書くとキャンセルされても握りつぶせる

        try { await FinalFinalEffect(); } // 最後の最後の演出...すぐに終わりたいのでキャンセルされたとする
        catch (Exception e) when (Story.IsCanceledException(e)) { } // 握りつぶせる

        try { await FinalFinalFinalEffect(); } // 最後の最後の最後の演出...しつこいのでキャンセルされたとする
        catch (Exception e) when (Story.IsCanceledException(e)) { } // る

        // 何度キャンセルされても必要な後始末は処理できる(正しい書き方をしていれば)
        Cleanup();
    }
}

自作するなら自分好みにしたいじゃないか

💡「CancellationToken」はあえて捨てた

モダンな非同期処理といえば CancellationToken ですが、Omochaya Storyはこれに一切対応していません。理由は単純で、煩わしい「トークンバケツリレー」から解放されるためです。あとアロケーション。

Storyのキャンセルは「タスクのハンドル(task.Stop())」と「紐づけたオーナー(Destroy(owner))」で制御されます。StopCoroutine と同じメンタルモデルで、引数を汚染することなく直感的なキャンセル管理が可能です。

ゲーム特化のコンビネータ(With / Until

複数の演出を同時に動かしたり、どちらか一方が終わるまで待つ処理を1行で書けます。
つまり WhenAllWhenAny ですが、Storyのコンビネータはゲーム制御に最適化されています。

  • With (並行実行): 実行中のいずれかのタスクがキャンセルされても、残りのタスクを道連れにしません。
  • Until (競争実行): いずれかのタスクが完了(勝負がついた)瞬間、敗者のタスクは自動的に安全なキャンセル処理へ移行します。

サブルーチンでなんで戻るねん

Coroutine(Unity)の便利な WaitForFixedUpdate()WaitForEndOfFrame() ですが、 yield return null で Update 層に戻ってしまうというクセがあります。そのため、サブルーチンのなかでも yield return null ではなく yield return new WaitForFixedUpdate() とか書かなければなりません。サブルーチンを別の実行タイミングから呼ぶことは...まあないので困らないのかも知れませんが、なんか変。

ということで、Storyには現在の実行タイミングを維持する Story.YieldSame があります。

using Omochaya;

async Story.Task RootTask()
{
    await Story.Yield;
    await SubTask(); // Update層で実行される
    SubTask().Start(); // Update層で実行される

    await Story.YieldFixed;
    await SubTask(); // FixedUpdate層で実行される
    SubTask().Start(); // FixedUpdate層で実行される

    await Story.YieldLate;
    await SubTask(); // LateUpdate層で実行される
    SubTask().Start(); // LateUpdate層で実行される
}
async Story.Task SubTask()
{
    Debug.Log("ここが実行されるのと同じタイミング(Update/FixedUpdate/LateUpdate)で");
    await Story.YieldSame; // 次の同じタイミングまで待つ
    Debug.Log("ここも実行される");
}

制約事項

async ならできるでしょ?と思われることがStoryではできませんごめんなさい。

  • メインスレッド限定: マルチスレッドには対応していません。
  • 複数からの同時 await 禁止: 1つのタスクを複数箇所から同時に await することはできません。
  • 終了したタスクの await 禁止: すでに完了したタスクを await して結果を取り出すことはできません。
  • 外部非同期タスクとの混在不可: Storyの非同期メソッド内で、標準の TaskUniTask などの外部非同期メソッドを await することはできません。逆も同様です。

見えないタスクを可視化する専用デバッグモニタ

コルーチンや非同期タスク最大の敵である「今、裏でどのタスクが生き残っているのか分からない」問題に対処するため、2つの専用EditorWindow(モニタリングツール)を標準搭載しています。

🔍 Story Task Monitor
現在実行中のタスクや、紐づいているオーナー(GameObject)、実行タイミングの状態などをリアルタイムにリスト表示します。
スクリーンショット 2026-07-12 23.10.00.png

📊 Story Pool Monitor
Story内で使用している各種プールの仕様状況を可視化します。最大でどれだけ使用されたかを確認できるので、タスク別の事前確保数を知りたいときにも役立ちます。
スクリーンショット 2026-07-12 23.10.15.png


生きたリファレンスとしての「テストコード」

Omochaya Story は、UPMの Samples から Framework Validation & Allocation Tests をプロジェクトにインポートできます。

これ(StoryTests.cs)は UTF で実行できるテストコード群です。
各機能の実用的な使い方を網羅していますので動作保証とともにリファレンスとしても活用できます。


導入方法

Omochaya Story は GitHub で公開しており、Unity Package Manager (UPM) からサクッとインストール可能です。

  1. Package Managerの左上 + ボタンから Add package from git URL... を選択
  2. 以下のURLを入力して Add
https://github.com/yananose/omochaya.git?path=/Omochaya/Story#story/1.1.1

「コルーチンのアロケーションが気になる」「UniTaskだとどうにもシーケンス制御が書きにくい」という方がいらっしゃいましたら、ぜひ触ってみてください!

バグ報告やフィードバック、GitHubへのStarもお待ちしております!


開発裏話・ディープダイブ(Zenn連載)

このライブラリをいかにして「完全ゼロアロケーション」にしたのか、内部のディープな言語仕様ハックや最適化については Zenn で連載しています。興味のある方はぜひ覗いてみてください!

第1回:【Unity】コルーチンを完全代替するゼロアロケ非同期タスクシステムを自作した話 〜AsyncMethodBuilderのハック〜

第2回:【Unity】コルーチン最大の弱点「消滅」を克服する、ゼロアロケタスクの安全なキャンセル機構と「死後の時間」

第3回:【Unity】IL2CPPをねじ伏せ、CPUを極める!ゼロアロケタスクの執念の最適化

第4回:【Unity】例外の壁を越えろ!ゼロアロケタスク「Omochaya Story」v1.1.1更新


注意

Omochaya Story は個人開発のため不具合対応や機能追加に時間がかかる場合があります。更新が停止することも全然ありえます。ご利用の際は自己責任でお願いします。


1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?