時代に翻弄されるエンジニアのブログ

ゲームプログラマをやっています。仕事やゲームや趣味に関してつらつら書きたいと思います。

UniTaskCompletionSourceの例外処理で陥りがちな落とし穴

こんにちは、takuです。

UniTaskを使った非同期処理、便利ですよね。

自分もUnityのプロジェクトで日常的に使っているんですが、先日ハマった問題があります。

UniTaskCompletionSourceで例外をセットしたのに、なぜかtry-catchで捕まえられない。

デバッガーを見ると、例外が汎用的な場所に飛んで行ってしまって、本来意図した場所でキャッチできていませんでした。

なぜ例外がキャッチできなくなるのか

結論から言うと、UniTaskCompletionSourceで例外をセットした際に、その結果を誰もawaitしていないと、例外はUnityのグローバルな例外ハンドラに飛んでいってしまうんです。

つまり、例外をセットしただけでは不十分で、必ずその結果を誰かがawaitして受け取る必要があります。

こういうコードを書いたことはないでしょうか。

using Cysharp.Threading.Tasks;
using UnityEngine;

public class BadExample : MonoBehaviour
{
    private UniTaskCompletionSource<int> _completionSource;

    async void Start()
    {
        _completionSource = new UniTaskCompletionSource<int>();
        
        // 例外を発生させるメソッドを呼ぶが、awaitしない
        ProcessAsync();
        
        // 例外をセット
        _completionSource.TrySetException(new System.Exception("何かエラーが起きた"));
        
        Debug.Log("このログは表示される");
    }
    
    async UniTask ProcessAsync()
    {
        try
        {
            // ここでCompletionSourceの結果を待つ
            var result = await _completionSource.Task;
            Debug.Log($"結果: {result}");
        }
        catch (System.Exception ex)
        {
            // 本来ここで捕まえたい
            Debug.LogError($"例外をキャッチ: {ex.Message}");
        }
    }
}

このコードの問題点は、ProcessAsync()を呼び出しているのに、その結果をawaitしていないことです。

一見すると、ProcessAsyncの中でtry-catchを書いているので、例外は捕まえられそうに見えます。

でも実際には、ProcessAsyncが開始された後、呼び出し元のStart()は先に進んでしまいます。

そして_completionSource.TrySetExceptionが実行されたとき、もしProcessAsyncの実行が何らかの理由で中断されていたり、await前に処理が止まっていたりすると、例外は誰にも受け取られません。

結果として、例外はグローバルな場所に飛んで行ってしまい、意図したtry-catchブロックでは捕まえられないんです。

正しい実装パターン

では、どう書けばいいのか。

答えはシンプルで、UniTaskを返すメソッドは必ずawaitするか、明示的に結果を待つ仕組みを作ることです。

using Cysharp.Threading.Tasks;
using UnityEngine;

public class GoodExample : MonoBehaviour
{
    private UniTaskCompletionSource<int> _completionSource;

    async UniTaskVoid Start()
    {
        _completionSource = new UniTaskCompletionSource<int>();
        
        // awaitして結果を待つ
        await ProcessAsync();
        
        Debug.Log("処理完了");
    }
    
    async UniTask ProcessAsync()
    {
        try
        {
            // 別のタイミングで例外をセット
            TriggerExceptionLater();
            
            // ここでCompletionSourceの結果を待つ
            var result = await _completionSource.Task;
            Debug.Log($"結果: {result}");
        }
        catch (System.Exception ex)
        {
            // ちゃんとここで捕まえられる
            Debug.LogError($"例外をキャッチ: {ex.Message}");
        }
    }
    
    void TriggerExceptionLater()
    {
        _completionSource.TrySetException(new System.Exception("何かエラーが起きた"));
    }
}

このコードでは、ProcessAsync()を呼び出す側でawaitしているため、例外が発生してもちゃんとtry-catchで捕まえられます。

もう一つのポイントは、StartメソッドをUniTaskVoidにしていることです。

async voidではなくasync UniTaskVoidにすることで、UniTaskのコンテキストで実行され、例外処理がより安定します。

まとめ:awaitしてこその例外処理

UniTaskCompletionSourceは強力なツールですが、例外処理には注意が必要です。

例外をセットしても、その結果を誰もawaitしていなければ、意図した場所でキャッチできません。

自分がこの問題にハマったときは、「なんで例外が消えるんだ?」と悩みました。

結局、awaitを忘れていたことが原因でした。

UniTaskを使うときは、常に「この非同期処理の結果を誰が待つのか」「例外が起きたときにどこで処理するのか」を意識すると、こういう問題は防げるんじゃないかなと思います。

特に大規模なプロジェクトでは、非同期処理が複雑に絡み合うので、一つひとつの処理でちゃんとawaitして例外を受け取る設計が大事だなと感じています。

みなさんも、UniTaskを使うときは例外処理の流れを意識してみてください。

それでは。