💪

非同期処理ブートキャンプ【2025年版】

に公開

はじめに

  • 扱っている内容
    • 非同期処理のしくみ
    • IEnumerator や Unity コルーチンとの比較
    • asyncawait がコンパイル結果に与える影響
    • 並列処理と並行処理(=非同期処理)
    • デッドロック、その原因と対策
    • 起動済みタスクという状態
    • 再利用可能な Unity コルーチン
    • ValueTask、キャンセルが何故難しいか、ConfigureAwait やその他のアレコレ
  • うっすら言及
    • await するには async が必要だが await される側に async は不要
  • 扱っていない話題

C# 全般に共通する内容ですが暗黙的に Unity を前提としてる部分が多いです。

非同期メソッドは存在しない

まず、C# には「非同期メソッド」というモノは存在しません。async await はあくまで糖衣構文であり、コンパイル結果には存在しません。

非同期処理と呼ばれるものが何なのかというと、本質は Unity のコルーチンと全く同じです。似ているとかじゃなくて同じです。

では、Unity コルーチンが非同期処理の原型なのかと言うとそれは違います。Unity のコルーチンは本質的には IEnumerator と全く同じです。ならば IEnumerator が非同期処理の原型なのかというと、コレもまた違います。

原型は古くから存在するテクニックであり、C# の IEnumerator はそのテクニックを簡単に実装できるようにしたもので、実体は「状態を保存できる関数オブジェクト的なモノ」です。

class StateMachine
{
    int step = 0;  // 状態

    public void Execute()
    {
        switch (step)
        {
            case 0:
                Console.Log("こんにちは!");
                break;
            case 1:
                Console.Log("お元気ですか?");
                break;
            default:
                Console.Log("もう連絡してこないで…");
                break;
        }

        step++;  // 状態を一つ進める
    }
}

この関数オブジェクトは実行するたびに状態を進めることが出来ます。

var s = new StateMachine();
s.Execute();  // こんにちは!
s.Execute();  // お元気ですか?
s.Execute();  // もう連絡してこないで…
s.Execute();  // もう連絡してこないで…
s.Execute();  // もう連絡してこないで…

// 記憶無くなれーー!!
s = new StateMachine();
s.Execute();  // こんにちは!
s.Execute();  // お元気ですか?

非同期メソッドはこれと同じ構造で、前回どこまで処理を進めたかを記憶し続きから再開できるようにした関数オブジェクトです。

そして関数オブジェクトの「状態の進め方」を工夫することで非同期処理を実現しています。

MonoBehaviour で非同期処理を再現

非同期処理も Unity コルーチンも、下記の MonoBehaviour と同じ処理を行っています。単に「書き味」が違うだけです。

class FakedAsync : MonoBehaviour
{
    StateMachine m_stateMachine;

    void Start()
    {
        // ステートマシンを作る
        // ※ Async() メソッドや StartCoroutine() の呼び出しに相当
        m_stateMachine = new StateMachine();
    }

    void Update()
    {
        // 一度に状態を進めるのではなく毎フレーム1ステップずつ進める
        // この状態の進め方によって GUI が固まらなくなる
        m_stateMachine.Execute();
    }
}

IEnumerator は糖衣構文

C# では IEnumeratoryield return の組み合わせによって、上記のようなステートマシンを簡単に実装できるようになっています。

using System.Collections;

public class C
{
    IEnumerator StateMachine()
    {
        yield return "こんにちは!";
        yield return "お元気ですか?";
        yield return "もう連絡してこないで…";
    }
}

このコードのコンパイル結果(抜粋)は以下の様になります。

確認環境:https://sharplab.io/

private bool MoveNext()
{
    switch (<>1__state)
    {
        default:
            return false;
        case 0:
            <>1__state = -1;
            <>2__current = "こんにちは!";
            <>1__state = 1;
            return true;
        case 1:
            <>1__state = -1;
            <>2__current = "お元気ですか?";
            <>1__state = 2;
            return true;
        case 2:
            <>1__state = -1;
            <>2__current = "もう連絡してこないで…";
            <>1__state = 3;
            return true;
        case 3:
            <>1__state = -1;
            return false;
    }
}

async も糖衣構文

非同期処理で使われる async は「ソースジェネレーター向けの特殊な修飾子」のようなもので、付与すると概ね以下のようなコンパイル結果になります。

async Task<string> Async()
{
    // イメージ
    await GetResultAsync("こんにちは!");
    await GetResultAsync("お元気ですか?");
    await GetResultAsync("もう連絡してこないで…");
}

👇 (あくまでイメージです)

struct StateMachine
{
    int state = 0;

    // async メソッドにローカル変数が含まれていると、それらはフィールドに変換される。
    int localVar;
    string localVar2;

    // メソッドから async 修飾子は消える
    string __Async()
    {
        switch (state)
        {
            // 本来はネストされた await GetResultAsync も再帰的に分割・再構築されるがココでは割愛
            case 0:
                SynchronizationContext.Current.Post(this);  // 👈 ポイント!!
                return GetResultAsync("こんにちは!");
            case 1:
                SynchronizationContext.Current.Post(this);  // 👈
                return GetResultAsync("お元気ですか?");
            case 2:
                SynchronizationContext.Current.Post(this);  // 👈
                return GetResultAsync("もう連絡してこないで…");
        }

        state++;
    }
}

状態を記憶し前回の続きから再開できるという構造は IEnumerator のコンパイル結果と同じです。

Span<T> 等のスタック限定型が「yield 越え」「await 越え」出来なかったり、構造体の値が何故か思った状態ではない原因は、境界を超える際にローカル変数をフィールドにコピーして保存するという処理が行われる為です。

ステートマシン化の条件は async 修飾子が付いていることで await の有無は関係ありません。await はあくまでメソッドの分割位置を示す(制御を呼び出し元に戻す)ための修飾子です。(awaityield return

そして、ステートマシン化されると若干ですが呼び出しの効率が悪くなります。なので await 無しのメソッドはアナライザーで警告が出ます。

逆に非同期処理の枠組みでメソッドを実行したい場合は、TaskValueTask を同期的に直接返せる場合でも async を付けてやる必要があります。アナライザーのメッセージには「同期的に実行されます。意味ないですよ」的な事が書いてありますが、実際には await が無くてもメソッドの再構築が行われ実行方式が変わるので、意図的に async 化を行うケースもあります。

asyncIEnumerator コルーチンの違い

async と IEnumerator の構造は同じです。

違うのは yield return ではなく await で分割される点と、何もしないでも自分で自分を再実行する機構がコンパイル結果に組み込まれていることです。(上記サンプルのポイント参照のこと)

IEnumerator と Unity のコルーチン

これらは寸分違わず同じ構造をしています。

IEnumerator は使用者が能動的に状態を進める必要があります。

// IEnumerator の代表的な使い方
foreach (var val in enumerable)
{
    //...
}

// 👇 AOT コンパイル結果

// 関数オブジェクトを最後まで同期的に消費する(スレッドがブロックされてしまう!)
using var e = enumerable.GetEnumerator();
while (e.MoveNext())
{
    var val = e.Current;

    //...
}

状態を進めたときに返すオブジェクトを変えることでコレクション的な振る舞いをさせることが出来ます。(=LINQ)

サンプルのコメントにある通り、while で回すとスレッドがブロックされてしまい GUI がフリーズします。

Unity コルーチン

Unity のコルーチンも実態は IEnumerator なので同じように能動的に消費する必要があります。

ただし、コルーチンの場合は StartCoroutine が上手いことやって同期的にスレッドをブロックしないようになっています。

Unity の内部処理をエスパーすると概ね以下の通り。

IEnumerator coroutine = MyCoroutine();
StartCoroutine(coroutine);

// 👇

// while でループしない!
if (!coroutine.MoveNext())  // 👈 次の yield return までの処理が実行される
    return;

var queue = coroutine.Current;    // 👈 元のメソッドの yield return *** 部分

// yield return の戻り値の型によってどのキューに投入するか決定する
switch (queue)
{
    case WaitForEndOfFrame:
        // フレーム終了時に実行する処理を貯めておくキューに追加
        UnityInternal.RegisterEndOfFrameJob(coroutine);
        break;

    case WaitForFixedUpdate:
        // FixedUpdate に実行する処理を貯めておくキューに追加
        break;

    // yield return null でも yield return 0 でも同じ結果になるのは default ケースだから
    default:
        // Update に実行する処理を貯めておくキューに追加
        break;
}

こういった具合に yield return で戻ってきた型によって、次はどのキューに登録するか切り替えているのだと思います。

キューに登録したらメソッドが終了するので、次の「キューを消費するコード」に到達するまでに存在する Unity ライフサイクル(Update LateUpdate 等)が回ります。で、キューに登録されたコルーチンを消費するとまた一段階進んで再登録が行われる。このループ構造により GUI のフリーズを回避しています。

WaitForEndOfFrame 等は毎回 new しないでキャッシュしたインスタンスを返しても問題なく動作するし、構造体に IEnumerator を実装すればノンアロケを実現したり高速・高効率なトゥイーンライブラリを実装したりできるよ!

非同期処理の場合は?

非同期処理と同じ構造の IEnumerator と Unity コルーチンは見てきた通りです。構造が同じなので非同期処理もやることは変わりません。「どうやってメインスレッドを止めずに消費するか」が違うだけです。

async/await は C# の言語機能としてガッツリ組み込まれているので、Unity の StartCoroutine のように「適切に状態を進める始点となるメソッド」にインスタンスを渡す必要がなくなっています。

実際には渡す必要がありますが勝手にやってくれます。これが後述の厄介な問題を生み出します。

async によるメソッドの作り変え結果

元のメソッドは跡形もなく消滅しステートマシンを作って起動(Unity コルーチンの StartCoroutine 相当)するところまで行う同期メソッドに作り替えられる。

async 修飾子でメソッドの作り変えを指示し、どこでメソッドを分割し後続を取り込むかを指示するのが await 修飾子。(どちらもコンパイル結果から完全に消滅する)

確認環境: https://sharplab.io/

[AsyncStateMachine(typeof(<M>d__0))]
[DebuggerStepThrough]
public Task M()
{
    <M>d__0 stateMachine = new <M>d__0();
    stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
    stateMachine.<>4__this = this;
    stateMachine.<>1__state = -1;
    stateMachine.<>t__builder.Start(ref stateMachine);
    return stateMachine.<>t__builder.Task;
}

前述の通り、非同期処理の原理は Unity のコルーチンと同じです。コルーチンは戻り値の型によって投入先を切り替えていましたが、非同期処理は

SynchronizationContext.Current.Post(this);  // イメージ

ステートマシンに加えられた上記の処理によって、常に UnitySynchronizationContext のキューに自身を投入します。

そして UnitySynchronizationContext はメインループで実行されています。

メインループ

GUI アプリケーションには必ずメインループと呼ばれるものが存在し、アプリ起動直後から終了までずーーーっと回っています。Unity の場合、イメージとしては以下の通り。

// アプリのエントリーポイント
void Main(string[] args)
{
    while (true)
    {
        UnityMainLoop();  // 👈👈👈
    }
}

//   👇 👇 👇 👇 👇
void UnityMainLoop()
{
    //...

    ExecuteAllUpdate();      // 全ての MonoBehaviour の Update() を実行
    ExecuteAllLateUpdate();  // 全ての MonoBehaviour の LateUpdate() を実行

    ExecuteEndOfFrame();  // コルーチンの実行

    //...

    // 👇 同期コンテキスト
    UnitySynchronizationContext.Exec();  // 非同期ループを回すメソッドも「メインスレッドのメインループ」で実行されている
}

非同期処理の処理の流れ

Unity のコルーチンと同じように、非同期処理はスレッドをブロックしない形でステートマシンの状態を進めます。

C# のソースコードで流れを追う!
void UnityMainLoop()
{
    // どこかで非同期メソッドを呼ぶと。。。
    await Async();
    // 👇 メソッド内部
    {
        var s = new StateMachine();
        s.__Async();
            // 初回実行時「こんにちは!」まで実行し、自分自身を同期コンテキストに登録する。
            // (最初の await 到達までは同期的実行が保証される)

        // await 以後に処理が続く場合は、メソッド再構築によって Async() に取り込まれ消滅する。
        // ※ 結果として終了を待っているような感じになる。
    }

    // await に到達する度にメソッドが終了するので、メインループがより多く回る(画面が固まらなくなる)
    ExecuteAllUpdate();      // 全ての MonoBehaviour の Update() を実行
    ExecuteAllLateUpdate();  // 全ての MonoBehaviour の LateUpdate() を実行
    ExecuteEndOfFrame();     // コルーチン

    //...

    // 登録された「元非同期メソッド」を1ステップだけ進める
    UnitySynchronizationContext.Exec();  // 👈👈👈 ココにステートマシンが投入される!!
    // 👇
    {
        s.__Async();
            // 2回目は「お元気ですか?」を返し、自分自身を同期コンテキストに再登録する。
            // (次のメインループで3回目が実行される)
            // 最後の非同期ループで取り込んだ「await 以後」を実行することで終了待ち(錯覚)を達成する。
    }
}

箇条書きでメインループの流れを追う!

-- 非同期メソッドを起動する回のメインループ --

  • Update()
    • await Async();
      • ステートマシンを作って最初の await 直前まで実行してキューに登録する
      • var stateMachine = new StateMachine();
      • stateMachine.__Async();(1回目)
  • LateUpdate()
  • EndOfFrame()
  • UnityShnchronizationContext.Exec()
    • stateMachine.__Async(); (2回目)
      • 再度自分が実行されるように登録し直す

-- 次のメインループ --

  • Update()
  • LateUpdate()
  • EndOfFrame()
  • UnityShnchronizationContext.Exec()
    • stateMachine.__Async(); (3回目)
      • 同上

-- 次のメインループ --

  • Update()
  • LateUpdate()
  • EndOfFrame()
  • UnityShnchronizationContext.Exec()
    • stateMachine.__Async(); (4回目)
      • 同上
      • ※ タスクが終了するタイミングで「取り込んだ await 以後」が実行される。結果として非同期処理の完了を待っているような動作になる。(完了待ちを実現するためにエグいメソッドの作り変えが起きている)

await に到達するたびにメソッドが終了するので、Update LateUpdate 等の Unity ライフサイクルの実行頻度が上がりましたね!!

コレが非同期処理にすると GUI がフリーズしなくなる理由です。

また、処理の流れを見てわかる通り await 間の処理間隔が長いと1回の呼び出しに時間がかかるようになり、結果 Update/LateUpdate の実行頻度が下がるので GUI がカクつきます。

async/await だから GUI が固まらなくなるという訳ではなく、同期処理を細切れにして他の処理が実行される機会を増やすことで GUI が更新されるチャンスを増やしているという事です。

GUI のカクつきを直したい場合は await の頻度を挙げれば良いわけですが、呼び出し元に制御を戻すのはノーコストではないので、GUI が滑らかになることと引き換えに処理時間は長くなります。

デッドロックの原因

【注】デッドロックに関する話は GUI アプリケーションに限ります。コンソールアプリでは再現しません。

GUI アプリケーションと ASP.NET アプリケーションには、一度に実行するコードを 1 つのチャンクに限定する SynchronizationContext があります。

(中略)

コンソール アプリケーションはこのデッドロックを引き起こしません。コンソール アプリケーションでは、一度に 1 つのチャンクに制限する SynchronizationContext ではなく、スレッド プールを備えた SynchronizationContext を使用するため、await が完了するとき、スレッド プールのスレッドで async メソッドの残り処理のスケジュールが設定されます。このメソッドは完了でき、返されたタスクを完了するため、デッドロックは発生しません。

https://learn.microsoft.com/ja-jp/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming

メインループの存在する GUI アプリでデッドロックが起きやすいのは、これまで見てきた構造が原因です。

「最初の await」で ConfigureAwait(false) 等して別スレッドにジョブを投入するようにしないと、メインスレッドのループが進まないため「メインスレッドに自分自身を再登録した」状態までしか進まず、肝心の非同期ループが回りません。結果デッドロックが起きます。

👇 デッドロックを引き起こすメインループの流れ

  • Update()
    • var task = await Async();
      • ステートマシンを作って最初の await 直前まで実行してキューに登録する
      • var stateMachine = new StateMachine();
      • stateMachine.__Async();(1回目)
  • LateUpdate()
    • while (!task.IsCompleted)
      • 非同期処理の2回目以降は UnityShnchronizationContext で実行される
      • が! while するとメインループが進まないので IsCompleted が真にならない
      • デッドロック!!
  • EndOfFrame()
  • UnityShnchronizationContext.Exec()
    • stateMachine.__Async(); (2回目) 👈 ここまでメインループが進まない!

メインスレッドが「待ち」状態だと結果を受け取れないのではなく、そもそも非同期処理のループが回っていない訳です。(2回目まで到達できない)

軽めの予防策

HttpClient は最初の await 以後が別スレッドに移行するためデッドロックが起きにくいです。(というか起こせない?)

それに倣って、絶対にデッドロックして欲しくない非同期メソッドの冒頭に以下を加えておくと(1ループ無駄になりますが)予防になります。

await Task.Delay(1).ConfigureAwait(false);

これにより最初の呼び出し時にキューの登録先がスレッドプールになるので、メインスレッドを止めてもタスクが完了します。

ちなみに以下はダメです。「最初の await の中の最初の await の中の。。。」と末端まで再帰して、そこに ConfigureAwait(false) 相当の処理が含まれていないと予防になりません。

await Task.CompletedTask.ConfigureAwait(false);

Task.Delay(0) もダメです。待機時間が 0 の場合は最適化パスに流れてしまう為、継続タスクが別スレッドに投入されません。(最初の await の次の await まで同期的に実行されてしまう)

Task.Delay は「Task 型を返す同期メソッド」で非 async メソッドです。タスク型を返すので await 出来ますが async によるメソッドの作り変えとステートマシン生成が行われないタイプのメソッドです。

軽めの予防策2

上記の予防策は「呼ばれる側」のものです。「呼ぶ側」の対策は、

Task.Run(async () => await FooAsync()).Wait();

Task.Run で包む、です。

起動済みタスク問題

基本 Func<Task> Func<Task<T>> を引数で受けて Task.Run に流せばデッドロックは起きません。(※ Unity 6 では起こり得ます)

ただ、既に起動しているタスクを受け取る場合は注意が必要です。

Task 型は単なるハンドルです。そのハンドルを別スレッドで待っても何の意味もありません。肝心のタスク本体は既にメインスレッドにキューされている可能性があります。(そしてそれを知る術はありません)

// 👇 メインスレッドで起動!
var t = writeThreadId();

UnityEngine.Debug.Log("Right after task creation");

_ = Task.Run(async () =>
{
    UnityEngine.Debug.Log("Task Thread ID: " + Environment.CurrentManagedThreadId);
    UnityEngine.Debug.Log("Task Status: " + t.Status);

    await t;  // 👈 別スレッドで待つ!!
});
wirteThreadId の中身
static async Task writeThreadId()
{
    UnityEngine.Debug.Log(nameof(writeThreadId));
    await nestedThreadId();
    UnityEngine.Debug.Log($"{nameof(writeThreadId)}: " + Environment.CurrentManagedThreadId);
}

static async Task nestedThreadId()
{
    UnityEngine.Debug.Log(nameof(nestedThreadId));
    await deepNestThreadId();
    UnityEngine.Debug.Log($"{nameof(nestedThreadId)}: " + Environment.CurrentManagedThreadId);
}

static async Task deepNestThreadId()
{
    UnityEngine.Debug.Log(nameof(deepNestThreadId));
    await Task.Yield();
    UnityEngine.Debug.Log($"{nameof(deepNestThreadId)}: " + Environment.CurrentManagedThreadId);
}

実行すると。。。

// 非同期メソッドの呼び出し階層全ての「最初の await 直前」までが同期的に実行される
writeThreadId
nestedThreadId
deepNestThreadId

Right after task creation  // 「最初の await の直前まで」に重い処理が含まれていてもこの順番で出力される。
                           // すべて同期的に実行されるので順序が変わることは無い。

// 別スレッド起動! タスクを待つ所存!!
Task Thread ID: 2631
Task Status: WaitingForActivation  // 👈 既にタスクの一部が実行されちゃってますが?!?!!!?!

// メインスレッドで起動すると「メインスレッドの同期コンテキストに投入」する部分まで処理が進む。
// なのでメインスレッドで非同期ループが回ることになり結果メインスレッドの ID が表示される。
deepNestThreadId: 1
nestedThreadId: 1
writeThreadId: 1

// どのスレッドで待つかは関係ないので上記サンプルの Task.Run を .Wait() するとデッドロックする。

Unity のコルーチンと違い、ステートマシンのインスタンスを作るコードと起動するコードが不可分なので、Task 型を引数で受けた時点で Task.Run や Task.Delay を使ったデッドロック予防策は無意味、打つ手がありません。

「起動していないタスク」を作る方法はありますが一般的ではありません。Func<Task> を使う方が分かりやすいのでおススメです。

Unity 6 の Awaitable.MainThreadAsync 問題

Unity 6 で「メインスレッドに戻る」という超便利 API が追加されました。

この API の登場によって、Unity 向けライブラリでは非同期メソッドを用意する必要がなくなりました。

// 利用者側が完全にコントロールできるようになった!!
async Task OnButtonClickAsync(CancellationToken ct = default)
{
    using var _ = LoadingSpinner.Instance.Start("処理中です… 頑張っています……");

    // メインスレッドはメインスレッドでしか実行できない処理の為に空けておく
    await Task.Run(async () =>
    {
        // 別スレッドで処理して。。。
        var result = HeavyJob(data);

        // メインスレッドで適用する!!
        await Awaitable.MainThreadAsync();

        // ct がキャンセルされたかどうかはメインスレッドで行った方がレースコンディション的に楽。
        // -> 適用先のオブジェクトやウインドウの破棄に合わせて ct をキャンセル
        // -> メインスレッドの非同期はすべて同期的に実行されるので NRE が起きない
        //   ※ キャンセル確認と適用の合間に絶対に別の処理が挿入されない

        // 処理が終わった段階で ct がキャンセルされてたら破棄!
        // (適切なキャンセルの実装は非常に難しい(後述)のでこのやり方がベスト)
        if (ct.IsCancellationRequested)
            return;

        ApplyResult(result);
    });
}

とにかく簡単になった、何よりライブラリ側の事情ではなく使用者側の都合で自由に実行スレッドを制御出来るようになって最高な訳ですが、一方で安全に同期的に待つという点では「メインスレッドに戻ってきちゃうんですか!?」という感じです。

// 絶対にデッドロックしないハズなのに何故か動かない??????????
Task.Run(async () =>
{
    await Task.Delay(1).CofigureAwait(false);
    await OnButtonClickAsync();  // 👈 Awaitable.MainThreadAsync() を含んでいるとデッドロック
})
.Wait();

Task.Run と Task.Delay(1) を使った予防策が突破されてしまうわけですね。

どうやったらデッドロックを完璧に防げるのか

見てきた通り、「呼ばれる側」は Wait や Result されるかもしれないということで Delay(1).ConfigureAwait(false) を冒頭で呼び出すことでデッドロック対策が可能です。

「呼ぶ側」は Task.Run で包むことでデッドロックを予防できます。

が! 結局どんな対策をしても可能性を潰し切れません。

💡 メインスレッドを止めたまま非同期ループを回す

デッドロックの原因は非同期ループを回す部分までメインループが進まないことです。

裏を返せば、メインスレッドをブロックしていても非同期処理のループを回せばデッドロックを回避できるという事です。

Unity の場合はリフレクションを使って UnitySynchronizationContextExec を実行し、非同期ループを強制的に進めることが出来ます。

※ 同期コンテキスト/非同期ディスパッチャーのソースコードは以下より確認できます。

https://github.com/Unity-Technologies/UnityCsReference/blob/4b463aa72c78ec7490b7f03176bd012399881768/Runtime/Export/Scripting/UnitySynchronizationContext.cs#L74-L90

これにより、「メインスレッドで実行している非同期処理」をデッドロック無しで「メインスレッドで同期的に待つ」ことが出来るようになります。

サンプルコード

while (!t.IsCompleted)
{
    UnitySyncCtx_ExecMethod.Invoke(...);  // タスクが完了するまで非同期ループを回す!!
}

👇 メインループ(イメージ)

void UnityMainLoop()
{
    ExecuteAllUpdate();      // 👈 ココで while してメインスレッドを止めたとしても。。。
    ExecuteAllLateUpdate();
    ExecuteEndOfFrame();

    //...

    UnitySynchronizationContext.Exec();  // 👈 while 内で何度も実行して非同期ループを強制的に進める!!
}

冗談みたいな話ですがちゃんとタスクが完了しますw やったね!

フレーム/時間に基づいた処理

メインスレッドが止まっていると Unity の Time.time は進まず Time.realtimeSinceStartup は進む状態です。(イメージ通り)

なので非同期ループだけ回すと UnityEngine の Time に依存している非同期処理の一部が微妙な結果になる可能性があります。

Unity 時間やフレームに依存した処理を非同期で書くなよ。。。って話ではあります。

Unity のメインスレッドが止まっている間もスレッドプールに投入したタスクは進むので、この挙動自体は非同期処理において特に注意を払うべきモノではないでしょう。

つまり、非同期ループだけ回しても問題は起きません。やっても大丈夫です。問題が起きたらそれは非同期処理の方に原因があるということです。「特定の回し方」に依存する方が悪いです。

ValueTask の使いどころ

速く終わる処理という曖昧な表現で説明されることが多いですが、具体的には「最初の await 前に結果が出る場合」が使いどころで、処理の複雑さや所要時間は無関係です。

IAsyncDisposableValueTask DisposeAsync() のように非同期で処理しようがないケースがある API も使いどころです。

// await が無いなら async メソッドにしなくて良い(無駄なメソッドの作り変えとステートマシン生成を防げる)
public ValueTask DisposeAsync()
{
    Dispose();
    return default;
}
public async ValueTask DisposeAsync()
{
    if (_isDisposed)
    {
        return;  // 最初の await 前に結果が出る
    }

    await DisposeAsync(disposing: true);
    _isDisposed = true;
}

とりあえず全部 ValueTask で良いじゃん感もありますが、構造体は型の継承関係が無くてすげー扱いづらいんで公開 API は Task の方がおススメです。

(ValueTask を使っても最初の await 前に結果が出ない場合は結局 Task 型のインスタンスが作られます)

await しないとどうなる?

await は「待つ」ではなくメソッドを分割するための糖衣構文で「呼び出し元に制御を戻す = yield」処理です。コンパイル結果に与える影響は「メソッドの分割」「await 以後を全て取り込む」です。

なので、await 無しで実行するという事は「制御を戻さず await 以後を取り込まない」ということです。

// async が付いてると意味が無くてもメソッドの作り変えが行われる
// (呼び出し方に無駄が増えるだけで結果は変わらない)
async Task RunInBackground()
{
    Console.WriteLine("Before");
    FooAsync();
    Console.WriteLine("After");
}

// 👇
// Before
// FooAsync(1回目)
// After
// FooAsync(2回目)
// FooAsync(3回目)
// FooAsync(4回目)

結果は非同期処理をバックグラウンドで実行したような感じになります。

冒頭に書きましたが C# に非同期メソッドは存在しません。(スレッドプールを使わない限り)全てのメソッドが同期的に実行されるので実行順は固定で変わることはありません。

ConfigureAwait

ConfigureAwait(false) しておけばデッドロックは起きないと言われています。

このメソッドは

  • 呼び出したインスタンスその物には影響を与えない
  • await と組み合わせないと何の意味もない

というモノです。

var task = FooAsync();

task.ConfigureAwait(false);  // 👈 意味ナシ
Console.WriteLine(Environment.CurrentManagedThreadId);  // メインスレッドの ID が表示される

var other = BarAsync();
await other.ConfigureAwait(false);  // 👈 意味アリ
                                    //   が! other の実行スレッドが変わるわけではないので注意!
                                    //   ※ 起動済みなので呼び出し階層は全てメインスレッドで実行される

// 👇 await 以後はメソッド再構築によって other に取り込まれる。
//   その「取り込まれたコード」の実行スレッドが ConfigureAwait によって変わる
Console.WriteLine(Environment.CurrentManagedThreadId);

なんか知らないけど変な挙動をするな? という事態になるので基本的には付けないのがおススメです。

WebGL と HttpClient

古い Unity WebGL 環境では HttpClient が使えない訳ですが、正しくは「WebGL がマルチスレッドに対応していない」です。

マルチスレッドを有効化すれば WebGL でも HttpClient が使えるハズです。(未確認)

👇 HttpClient.GetAsync がコッソリスレッドプールを使う様子

https://github.com/dotnet/runtime/blob/5535e31a712343a63f5d7d796cd874e563e5ac14/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs#L384-L385

👇

https://github.com/dotnet/runtime/blob/5535e31a712343a63f5d7d796cd874e563e5ac14/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs#L512-L557

【Unity】再利用可能なコルーチン

非同期処理の方が優れていると勘違いされがちですが、コルーチンの方がパフォーマンスが良いです。(マイクロ秒レベル)

// 一度確保したら何度でも使える!
m_job = new ReusableCoroutine(onNext: () => ..., timing: new WaitForEndOfFrame());

m_job.Start();

m_job.Stop();
m_job.Reset();

https://github.com/sator-imaging/Unity-Fundamentals/blob/a5dbe679fee110c0262db1fd51437ce2e7efbf01/Runtime/FancyStuff/ReusableCoroutine.cs

理屈的にもおかしくない結果です。非同期処理は継続タスクを登録するときにキューにロックを掛けないといけませんが、メインスレッド限定の Unity コルーチンはそこら辺ナシでおkですし、StartCoroutine は早々にネイティブメソッドに流れます。中で何やってるかは知りませんが速そうです。

この辺り深堀したら面白そうではあります。が、今からやるなら DOTS というか、Burst コンパイラーとジョブシステムを使った方が良いでしょう。別次元で速いですから。

WaitForEndOfFrame ならコルーチンは全然現役

非同期処理アレコレ

同期 → 非同期処理は Task.Run を噛ますだけなので簡単ですが、逆は難しいです。ちゃんと待つには「非同期メソッドがどう実装されているか」で行うべき処理が変わります。

(なので「どう待ったらデッドロックしないか」を知っているライブラリ側が同期メソッドを用意すべき)

そもそも論、非同期処理は碌なもんじゃありません。最上位レイヤーで使う分にはめちゃくちゃ便利ですが、低レベルライブラリには非同期メソッドなんて実装する必要はありません。Task.Run すれば良いので。

非同期処理は Unity コルーチンよりも優れていると勘違いしている/ループが回ると思っていないケースも散見されます。え? await でずっとループ回すつもり!? みたいな。(1行で書くと負荷が無さそうに見える)

非同期処理は Rx やイベントのようにプッシュ型じゃなくてプル型なので、すぐに終了しない await は毎フレーム「処理が終わっているか」の問い合わせを行う必要があるし、終わっていなかった場合はキューにロックをかけて再登録するという無駄な処理も併せて行う必要があります。

加えて async 修飾子はメソッドの作り変えとステートマシン(各メソッド専用の一点物)の生成を行います。少なければ少ないほど良いです。

GUI アプリと非同期処理の相性

Visual Studio でも結局非同期処理を同期的に待つ API を使っています。

https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.visualstudio.threading.joinabletaskfactory.run

Web アプリが非同期まみれなのは Web アプリだからです。計算集約的ではない通信処理が中心でエントリーポイントから全て async に出来るならアリでしょうが、GUI アプリで非同期処理を濫用するのは微妙です。

ステートレスな HTTP 通信は非同期との相性が抜群です。それぞれのリクエストは他のリクエストの状況を考える必要がありませんから、コンソールアプリの非同期処理(=スレッドプールを使った並列化)は恩恵しかありません。

キャンセルできるだけ問題

別の問題もあります。例えば File.WriteAllBytesAsync は書き込みの途中でキャンセルするとファイルが破損します。ジョブを途中で止める、場合によっては元の状態に復元するってのは非常に面倒です。

モチロン良くできた非同期処理 API を使うだけなら簡単ですが、キャンセル可能な非同期メソッドの起点、「誰かが作った Async メソッドを内部で呼んでるから Async になってるだけ」ではないメソッドを実装するのはそれなりに難しいです。

そして何より ct を渡していてもキャンセルされることはないという前提でコードを書いていることが大半だと思います。(非同期を使うのは GUI の更新を止めたくないってだけの理由)

例えば File.WriteAllBytesAsync でファイルを書き出す場合、上書き中にキャンセルすると普通にファイルが破損するので、

  1. Path.GetTempFileName() で取得したファイルに非同期で書き込み
    • 非同期処理がキャンセルされたら一時ファイルを消して終了
  2. (上書きの場合)保存パスにあるファイルに .bak{ハッシュ値} 等の拡張子を付けて退避
  3. 非同期処理で書き込んだ一時ファイルを保存パスへ移動
  4. 保存パスへの移動が成功したら .bak ファイルを削除
    • 失敗したら .bak を復元

って感じでしょうか。実際はまあ、キャンセルされないしタイムアウトにもならんやろの精神で ct に default を渡してるだけでしょう。キャンセルして問題にならないのはバックエンド側がちゃんとケアしてくれる通信タスク位です。

ネットワーク処理はバックエンド側が頑張って実装しているハズという前提の元、フロントエンド側は適当に CT キャンセルすりゃ良いかw をやっても適切にロールバック等が行われます。

それ以外は「ホントに CT キャンセルするなんて止めてくださいね?」って実装が殆どです。

とにかくキャンセルして問題ない API を作るのは難しいです。そして大半の非同期 API はキャンセルできるだけです。使う場合はまずはキャンセル(≠状態の復旧の保証)したらどうなるかの確認から必要です。

非同期に出来ない問題

MemoryStream.WriteAsync なんかがそうですが、とりあえず実際の処理は同期処理として走り切らせてその前後で CT のキャンセルを確認するぐらいで 99% のケースは十分です。(ストリームなのでコレで十分説もあります)

// 元になる同期メソッド
object Foo() { }

// async など不要!!
Task<object> FooAsync(CancellationToken ct = default)
{
    if (ct.IsCancellationRequested)
        return Task<object>.FromCanceled(ct);

    var result = Foo();  // 普通に同期メソッド回す

    if (ct.IsCancellationRequested)
        return Task<object>.FromCanceled(ct);  // ちょこちょこ CT キャンセルの確認をするよりも
                                               // 走り切らせた方が速いしロジックも単純になる

    return Task.FromResult(result);
}

あらゆる処理がキャンセル可能なロジックで実装できるわけではないので、インターフェイスや抽象クラスで求められるから実装してあるだけという非同期処理は、この様なナンチャッテ系非同期として実装してあることも少なくありません。

WebGL でマルチスレッドが使えるようになったり Awaitable.MainThreadAsync() で自由にメインスレッドに戻ってこれるようになった今、感染止めるのが面倒すぎるので非同期メソッドとか要りません。同期メソッドだけでおkです。必要に応じて Task.Run すれば良いだけです。

(サーマルスロットリング対策は必要ですが!)

別に速くならない問題

非同期処理をすると GUI がフリーズしなくなりますが、それだけです。別に処理が速くなるわけじゃなくて、むしろ(気にしなくて良いレベルで)遅くなります。

タスク型は参照型なのでアロケも必要で、呼び出し階層が深くなれば必要なインスタンスの数も増えます。が! そもそも非同期処理しなければおkです。アロケ回避のために構造体にする必要とかないです。

どうしても非同期感染が防げない場合は「Task/ValueTask 型を返す非 async メソッド」というやり方もあります。

トップレベルステートメントが使えるような小さな Web アプリとは違いますから、async 修飾子が付いたメソッドは少なければ少ないほど効率が良いです。もし非同期の効率が良いのなら、Unity エンジンのエントリーポイントに async を付けて根っこから全てのメソッドを作り変えれば良い話ですが、やっているようには見えません。根っこからメソッド作り変えるなんて! って感じでしょう。(実際やったらどうなるかは見てみたいですが)

Unity 6 では MainThreadAsync で何時でもメインスレッドに戻ってこれるので、非同期 API なんてもう用意する意味ないです。ボタンとか UI のコールバックだけ async にして突っ込んどけば大丈夫です。

ネットワーク処理の非同期感染も可及的速やかに対処するべきでしょう。感染を止められるけど止めてないのと、止め方が分からないのでもう全部非同期にするしか! は全然違います。

※ メインスレッドで回る非同期処理は全部同期的に実行されるので、非同期で統一したらバグが起きづらくなるとかもないです。(感染の止め方が間違っている場合は別)

むしろ早めに脱非同期して、結果をある地点で確定できるようにした方がバグが減るまであります。場合によっては結果が確定しているかもしれないし、確定していないかもしれない、なんて地獄です。

大抵のアプリはそういう地獄にならないように「読み込み中」ダイアログで結果が確定するまでユーザーに他の操作をさせない訳です。非同期処理は碌なもんじゃないって事です。

非同期処理のメリット

「GUI がフリーズしない」これだけです。それ以外の意味も使いどころもありません。それ外のメリットがあると誤解しているのなら、原因は以下のようなケースでしょうか。

var tasks = Task.WhenAll(
    Task.Run(() => HeavyJobAlpha()),
    Task.Run(() => HeavyJobBravo()),
    Task.Run(() => HeavyJobCharlie())
);

await tasks;
//tasks.Wait();

上記の例では複数のスレッドに処理を投げて(並列処理)、その完了を非同期で待っている(並行処理)ので GUI がフリーズしません。並列処理と非同期、2つの処理が行われています。

このサンプルコードの最後の処理を、

//await tasks;
tasks.Wait();

とした場合、別スレッドに複数の処理を投げてその完了をデッドロックなしで待つことが出来ます。が! GUI はフリーズします。

並列処理と非同期処理(並行処理)のうち、片方を無効化したという事です。GUI はフリーズしていますが並列処理による CPU 効率の向上という恩恵は受けられています。

同じ Task 型が絡んでいますが、目的は同じではありません。非同期処理によって得られる結果は「GUI がフリーズしない」だけです。そしてその実現のために(ほんのちょっと)非効率な処理を加えます。

(並列処理は CPU 効率が上がりますがスレッド数が多いと逆効果です)

非同期処理は GUI を止めたくない時にしょうがなく使うものなので、少なければ少ないほど良いです。「優れたモノ」という錯覚は捨てたほうが良いでしょう。全部メインスレッドで実行されるので CPU 効率が良いとかもないです。

もしかしたら内部でコッソリ別スレッドを起動して並列処理を行い CPU 効率を良くしているというメソッドもあるかもですが、それは非同期処理の恩恵ではないです。並列処理の恩恵です。

制限が多い実行環境ではスレッド数が増えないというのも一定のメリットとなる場合があるので、この辺りごっちゃになりがちです。非同期と言いつつよくよく聞くと並列処理の話をしているとかよくあるの事なので、しっかりと違いを認識する必要があります。

その他のメリット

「GUI がフリーズしない」ことだけ! と言い切っていますがスレッド数が増えすぎないという恩恵もあります。

が!「アセットをダウンロードしながら可能ならイベントを進められるようにしよう」みたいな、デバッグ負荷の上がる仕様で実装することはまずないので、その恩恵を感じることは無いと思います。

※ 古い環境に合わせると WebGL ビルドでマルチスレッドが使えないという状況もあるようで、そういうプラットフォームでは大きな恩恵を受けられる?

おわりに

「デッドロックを完璧に防ぐ方法」の使いどころは、Application.quitting 等のコールバックで非同期処理の完了(クラウドへのデータの保存とか)を完璧に待ちたい時なんかです。

自分では弄れないサービス提供側の用意したライブラリの API の実装がどうであれ完璧に完了が待てますし、回っている非同期ループの数も分かるので全てが完了するまで待つことも可能です。

Unity メインスレッド縛りのあるメソッドが非同期処理に含まれていてもデッドロックなしでメインスレッドで待つことも出来るようになります。

以上です。お疲れ様でした。

Discussion