async/awaitとは?

async/awaitは、C#で非同期処理を書くための構文です。

時間のかかる処理を実行している間にメインの処理をブロックせず、完了を待ってから続きを実行できます。

本来、非同期処理はコールバックやスレッド管理が必要で複雑になりがちですが、

async/awaitを使うと、見た目は同期処理のように直列なコードで非同期の流れを記述できます。

Unityでは従来、フレームを跨ぐ処理にCoroutineを使うのが定番でしたが、

C#本来の機能であるasync/awaitも利用でき、Unity 2023.1以降ではAwaitableというUnity独自の型も追加されています。

AwaitableはTaskより軽量で、Unityのフレーム単位の待機にも対応した型ですが、本記事ではまず標準のasync/awaitとTaskの基本に絞って解説します。

コルーチンとの違い

async/awaitとCoroutineは、どちらも「時間のかかる処理を分割して扱う」点では似ていますが、いくつか大きな違いがあります。

戻り値が返せる

CoroutineはIEnumeratorを返すメソッドとして書くため、処理の結果を呼び出し元へ返せません。

対して、asyncメソッドはTask<T>を返すことで、待機しながら結果を受け取れます。

using System.Threading.Tasks;
using UnityEngine;

public class ReturnValueSample : MonoBehaviour
{
    private async void Start()
    {
        // 非同期メソッドの戻り値をawaitで受け取る
        int result = await CalculateAsync();
        Debug.Log($"結果: {result}");
    }

    private async Task<int> CalculateAsync()
    {
        // 1秒待機してから値を返す
        await Task.Delay(1000);
        return 42;
    }
}

例外を呼び出し元で受け取れる

Coroutine内で例外が発生しても、try-catchで外側から捕捉できません。

async/awaitでは、asyncメソッド内で発生した例外を、awaitした呼び出し元のtry-catchで捕まえられます。

// using / クラス定義は省略
private async void Start()
{
    try
    {
        await FailableAsync();
    }
    catch (System.Exception e)
    {
        // 非同期メソッド内の例外をここで受け取れる
        Debug.LogError(e.Message);
    }
}

private async Task FailableAsync()
{
    await Task.Delay(500);
    throw new System.Exception("失敗しました");
}

MonoBehaviourに依存しない

CoroutineはStartCoroutineが必要なため、MonoBehaviourを継承していないクラスからは直接呼べません。

async/awaitはC#の言語機能のため、純粋なクラスやstaticメソッドからも自由に利用できます。

停止方法

CoroutineはStopCoroutineで停止しますが、停止のためには戻り値のCoroutine参照を保持しておく必要があります。

async/awaitではCancellationTokenを渡すことで、外側からキャンセル可能です(後述)。

基本的な使い方

async修飾子とawait演算子

非同期メソッドを定義するには、メソッドにasync修飾子を付与します。

asyncメソッド内ではawait演算子を使い、TaskやTask<T>の完了を待機できます。

using System.Threading.Tasks;
using UnityEngine;

public class AsyncAwaitSample : MonoBehaviour
{
    // Startはvoidが要求されるためasync voidとしている
    private async void Start()
    {
        Debug.Log("開始");

        // 2秒間待機する
        await Task.Delay(2000);

        Debug.Log("2秒経過");
    }
}

Task.Delayは指定したミリ秒だけ待機するTaskを返します。

awaitを付けて待機している間、メインスレッドはブロックされず、Unityの描画や他の処理は通常通り進みます。

なお、サンプルではStartをasync voidにしていますが、これはUnityのライフサイクルメソッドがvoid戻り値を要求するための例外的な使い方です。一般のメソッドではasync Taskを推奨します(詳細は後述)。

Task<T>で値を返す

値を返す非同期メソッドは戻り値をTask<T>にします。

// 非同期にint値を返すメソッド
private async Task<int> LoadScoreAsync()
{
    await Task.Delay(1000);
    return 100;
}

呼び出し側では、awaitを付けることでintとして値を受け取れます。

int score = await LoadScoreAsync();

複数の非同期処理をまとめて待つ

Task.WhenAllを使うと、複数の非同期処理を並行して実行し、すべての完了を待てます。

// 3つの処理を並行して実行
await Task.WhenAll(
    Task.Delay(1000),
    Task.Delay(2000),
    Task.Delay(1500)
);

// 最も長い2秒経過した時点でここに到達する
Debug.Log("すべて完了");

順番にawaitで待った場合は合計4.5秒かかるのに対し、Task.WhenAllでは最も時間のかかる処理(この場合は2秒)が完了した時点で先に進みます。

UnityでのSynchronizationContext

async/awaitを扱う際に押さえておきたいのがSynchronizationContextです。

Unityでは、awaitで待機した後の続きの処理が自動的にメインスレッドへ戻ってくるようになっています。

これは、UnityがメインスレッドのSynchronizationContextをエンジン側で設定しているためです。

SynchronizationContextは、awaitの継続(続きのコード)をメインスレッドのキューへ積み直す仕組みです。

そのため、awaitの後でtransformやGameObjectなどのUnity APIを安全に触れます。

private async void Start()
{
    Debug.Log($"開始時のスレッド: {System.Threading.Thread.CurrentThread.ManagedThreadId}");

    // 別スレッドで実行される処理
    await Task.Run(() =>
    {
        Debug.Log($"Task.Run内のスレッド: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
    });

    // awaitの後はメインスレッドに戻ってくるためUnity APIを安全に呼べる
    Debug.Log($"await後のスレッド: {System.Threading.Thread.CurrentThread.ManagedThreadId}");
    transform.position = Vector3.zero;
}

Task.Runの中はワーカースレッドで実行されますが、awaitの後はメインスレッドに戻っていることが確認できます。

もしメインスレッドに戻したくない場合は、ConfigureAwait(false)を使う方法がありますが、Unityでは基本的に使う場面は多くありません。

async voidの危険性

asyncメソッドの戻り値にはvoidを指定することもできますが、これは原則として避けるべきです。

なぜ危険なのか

  • async void内で発生した例外はSynchronizationContextに投げられ、try-catchで捕まえられません
  • 戻り値がTaskではないため、awaitで完了を待ち合わせできません
  • 完了タイミングが取れないので、テストコードが書きにくくなります
// 推奨されないパターン
private async void DoSomethingAsync()
{
    await Task.Delay(1000);
    throw new System.Exception("この例外は外で捕捉できない");
}

// 推奨されるパターン
private async Task DoSomethingTaskAsync()
{
    await Task.Delay(1000);
    throw new System.Exception("この例外はawaitした側で捕捉できる");
}

例外的にasync voidを使う場面

唯一の例外が、Unityのライフサイクルメソッド(Start、Updateなど)やイベントハンドラです。

これらはvoidの戻り値が要求されるため、その中で非同期処理を呼び出すならasync voidにせざるを得ません。

その場合でも、メソッド内でtry-catchを入れて例外を必ず捕捉するように書くのが安全です。

private async void Start()
{
    try
    {
        await InitializeAsync();
    }
    catch (System.Exception e)
    {
        Debug.LogError(e);
    }
}

CancellationTokenでキャンセル処理

async/awaitではCancellationTokenを使って外部から処理を中断できます。

これにより、GameObjectが破棄された後も非同期処理が動き続け、破棄済みオブジェクトに触ってエラーになる、といった事故を防げます。

destroyCancellationToken

Unity 2023.1以降では、MonoBehaviourにdestroyCancellationTokenプロパティが追加され、GameObjectやMonoBehaviourの破棄に合わせて自動でキャンセルされるCancellationTokenが利用できるようになりました。

Task.Delayの第2引数などに渡しておくと、待機中にオブジェクトが破棄された際にOperationCanceledExceptionが飛び、安全に処理を中断できます。

自前のCancellationTokenSourceを作る

任意のタイミングでキャンセルしたい場合は、CancellationTokenSourceを自分で生成します。

using System;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class ManualCancelSample : MonoBehaviour
{
    private CancellationTokenSource _cts;

    private void Start()
    {
        _cts = new CancellationTokenSource();
        RunAsync(_cts.Token);
    }

    private async void RunAsync(CancellationToken token)
    {
        try
        {
            await Task.Delay(5000, token);
            Debug.Log("5秒経過");
        }
        catch (OperationCanceledException)
        {
            Debug.Log("中断されました");
        }
        catch (Exception e)
        {
            // async voidの例外は必ず受け止める
            Debug.LogError(e);
        }
    }

    private void OnDestroy()
    {
        // キャンセル + 解放
        _cts?.Cancel();
        _cts?.Dispose();
    }
}

標準Taskの問題点とUniTaskへの動機

async/awaitは使い勝手のいい構文ですが、標準のTaskをUnityで使うと少し気になる点が出てきます。

Taskはクラスでヒープ確保される

Taskはクラスとして実装されており、生成のたびにヒープ領域に割り当てられます。

また、asyncメソッドがawaitで中断・再開するために、コンパイラがステートマシンを生成するのですが、これも参照型として扱われる場合があり、GCアロケーションの原因となります。

単発の処理であれば気にならないものの、毎フレーム呼ばれるような処理や、大量に発行される非同期処理ではGC Allocの積み重なりがフレームレート低下に繋がります。

Unityのフレームに完全には合っていない

Task.DelayはOSのタイマースレッドで動作するため、Time.timeScaleの影響を受けません。

WaitForSecondsのような「ゲーム内時間に応じた待機」は素直には書けません。

UniTaskという選択肢

これらの課題を解決するために、Cysharpが公開しているUniTaskというOSSライブラリがあります。

Unityでasync/awaitを本格的に使うなら、UniTaskの導入も合わせて検討してみてください。

次回の記事では、UniTaskの導入と使い方を取り上げます。

まとめ

async/awaitは、C#の非同期処理を直列なコードで書くための構文です。

Coroutineと比べると、戻り値や例外処理を扱いやすく、MonoBehaviourに依存せず利用できます。

UnityではSynchronizationContextのおかげで、await後も自動的にメインスレッドへ戻ってくるため、安全にUnity APIを呼び出せます。

async voidは例外捕捉や待機ができないため、原則避けてasync Taskを使うようにしましょう。

CancellationToken、特にdestroyCancellationTokenを活用すれば、GameObjectの破棄に合わせて処理を安全に中断できます。

標準のTaskにはGCアロケーションなどの課題があるので、本格的にUnityで使うならUniTaskの導入も検討してみてください。

普段Coroutineで書いている処理をasync/awaitに置き換えてみると、戻り値や例外の扱いやすさを実感できると思います。