LoginSignup
34
30

More than 1 year has passed since last update.

UniTaskでマルチスレッド処理と例外処理

Last updated at Posted at 2022-11-19

概要

今回はUniTaskを使ってUnity上でマルチスレッド処理を実行する場合の書き方と例外処理での注意点を紹介します。

UniTaskでマルチスレッド処理を実行する方法

UniTaskでマルチスレッド処理を実行する方法(書き方)は大まかに2パターン存在します。

やり方A. UniTask.RunOnThreadPoolを使う

UniTask.RunOnThreadPoolは引数で渡したデリゲートをスレッドプール上で実行し、処理完了後に自動的にUnityメインスレッドに戻ってくれるメソッドです。

UniTask.RunOnThreadPool(ConfigureWait:true)
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class MultiThreadSample : MonoBehaviour
{
    // (小ネタ: Start()メソッドはUniTaskVoidにしても動く)
    async UniTaskVoid Start()
    {
        Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

        // スレッドプール上で処理を実行する
        await UniTask.RunOnThreadPool(SampleMethod);

        Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
    }

    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");
        // ちょっと待つ
        Thread.Sleep(1000);
    }
}
実行前のスレッド=1
SampleMethodを実行中:スレッド=22
実行完了後のスレッド=1

なお、引数のCofigureAwaitfalseに変更することで、処理完了後にUnityメインスレッドへコンテキストを戻すかどうかを切り替えることもできます。
(デフォルトはtrue(メインスレッドへ自動的に戻る)

CofigureAwait=falseのときのサンプル
UniTask.RunOnThreadPool(ConfigureWait:false)
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

        // スレッドプール上で処理を実行する
        await UniTask.RunOnThreadPool(SampleMethod, configureAwait: false);

        Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
    }

    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(1000);
    }
}
実行前のスレッド=1
SampleMethodを実行中:スレッド=20
実行完了後のスレッド=20

例外発生時の挙動

UniTask.RunOnThreadPoolawait時にtry-catchで包むことにより、その内部で発生した例外をキャッチすることができます。

また発生した例外は、メインスレッドに処理を戻した上でcatch節の中に到達します。CofigureAwait=trueのときのみ)。
このため例外が起きてたとしても確実にメインスレッドに処理を戻してからエラーハンドリングを実行することができます。

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

public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        try
        {
            Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

            // スレッドプール上で処理を実行する
            await UniTask.RunOnThreadPool(SampleMethod);

            Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
        }
        catch (Exception e)
        {
            Debug.Log($"例外処理のスレッド={Thread.CurrentThread.ManagedThreadId}");
            Debug.LogException(e);
        }
    }

    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");

        // 例外!
        throw new Exception("失敗しちゃった");
    }
}
実行前のスレッド=1
SampleMethodを実行中:スレッド=20
例外処理のスレッド=1    // ←メインスレッドにちゃんと戻ってる
Exception: 失敗しちゃった

やり方B. UniTask.SwitchTo***使う

別の方法として、UniTask.SwitchToThreadPoolUniTask.SwitchToMainThreadを組み合わせるという書き方もあります。

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

// 【注意:意図的に必要最低限のみに絞ったコード。実際に使う場合は例外発生時のハンドリングを考慮する必要あり】
public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

        // これ以降の処理をスレッドプールへ切り替え
        await UniTask.SwitchToThreadPool();
        
        SampleMethod();

        // これ以降の処理をメインスレッドに戻す
        await UniTask.SwitchToMainThread();

        Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
    }

    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");
        Thread.Sleep(1000);
    }
}
実行前のスレッド=1
SampleMethodを実行中:スレッド=22
実行完了後のスレッド=1

この様にSwitchToThreadPoolSwitchToMainThreadで挟んだ区間がマルチスレッド処理で実行されます。
しかしこのサンプルの書き方では例外発生時に意図しないスレッドで処理が実行される可能性があります。

例外発生時の挙動

上記のサンプルコードを単にtry-catchで包んだだけでは例外発生時にコンテキストがメインスレッドに戻りません。

よくない例外処理
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        // 例外処理が不十分な例
        try
        {
            Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

            // これ以降の処理をスレッドプールへ切り替え
            await UniTask.SwitchToThreadPool();

            SampleMethod();

            // これ以降の処理をメインスレッドに戻す
            await UniTask.SwitchToMainThread();

            Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
        }
        catch (Exception e)
        {
            Debug.Log($"例外処理のスレッド={Thread.CurrentThread.ManagedThreadId}");
            Debug.LogException(e);
        }
    }

    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");

        // 例外!
        throw new Exception("失敗しちゃった");
    }
}
実行前のスレッド=1
SampleMethodを実行中:スレッド=22
例外処理のスレッド=22
Exception: 失敗しちゃった

単にtry-catchで包んだだけでは例外発生時にUniTask.SwitchToMainThread()をスルーしてしまうため、**例外が発生した以降の処理がすべてスレッドプールで実行され続けてしまいます。**これにはasyncメソッドを呼び出したその上流側も影響を受けます。
そのため例外のハンドリング漏れが意図してない箇所を別スレッドで実行してしまう状況を引き起こし、事故を起こす可能性が非常に高くなります。

上流までスレッドプールが継続してしまう事故
上流までスレッドプールが継続してしまう事故
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        // 上流側
        
        Debug.Log($"呼び出し元 実行前 スレッド={Thread.CurrentThread.ManagedThreadId}");
        await NankaMethod();
        
        // 例外発生時、NankaMethod以降の処理がすべてスレッドプールで実行されてしまうことになる
        Debug.Log($"呼び出し元 実行後 スレッド={Thread.CurrentThread.ManagedThreadId}");
    }

    private async UniTask NankaMethod()
    {
        try
        {
            Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

            // これ以降の処理をスレッドプールへ切り替え
            await UniTask.SwitchToThreadPool();

            SampleMethod();

            // これ以降の処理をメインスレッドに戻す
            await UniTask.SwitchToMainThread();

            Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
        }
        catch (Exception e)
        {
            Debug.Log($"例外処理のスレッド={Thread.CurrentThread.ManagedThreadId}");
            Debug.LogException(e);
        }
    }

    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");

        // 例外!
        throw new Exception("失敗しちゃった");
    }
}
呼び出し元 実行前 スレッド=1
実行前のスレッド=1
SampleMethodを実行中:スレッド=22
例外処理のスレッド=22
Exception: 失敗しちゃった
呼び出し元 実行後 スレッド=22

例外発生時にメインスレッドへ戻す対策

例外発生時にメインスレッドへ戻す方法は2つあります。

対策方法 I: UniTask.ReturnToMainThreadを使う

UniTask.ReturnToMainThreadawait usingで呼び出すことで、該当のスコープを抜けた際にコンテキストをメインスレッドに戻してくれます。

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

public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        try
        {
            Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

            // UniTask.ReturnToMainThread を await using する
            // ブロックを抜けたタイミングでメインスレッドに戻る
            await using (UniTask.ReturnToMainThread())
            {
                // 処理をスレッドプールへ切り替え
                await UniTask.SwitchToThreadPool();
                SampleMethod();
            }

            Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
        }
        catch (Exception e)
        {
            Debug.Log($"例外処理のスレッド={Thread.CurrentThread.ManagedThreadId}");
            Debug.LogException(e);
        }
    }


    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");

        // 例外!
        throw new Exception("失敗しちゃった");
    }
}
実行前のスレッド=1
SampleMethodを実行中:スレッド=22
例外処理のスレッド=1 // メインスレッドに戻っている
Exception: 失敗しちゃった
await usingの別の書き方
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        try
        {
            Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

            // UniTask.ReturnToMainThread を await using する
            // try節を抜けたタイミングでメインスレッドに戻る
            await using var r = UniTask.ReturnToMainThread();

            // これ以降の処理をスレッドプールへ切り替え
            await UniTask.SwitchToThreadPool();

            SampleMethod();

            Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
        }
        catch (Exception e)
        {
            Debug.Log($"例外処理のスレッド={Thread.CurrentThread.ManagedThreadId}");
            Debug.LogException(e);
        }
    }


    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");

        // 例外!
        throw new Exception("失敗しちゃった");
    }
}
実行前のスレッド=1
SampleMethodを実行中:スレッド=22
例外処理のスレッド=1 // メインスレッドに戻っている
Exception: 失敗しちゃった

対策方法 II: finallyにUniTask.SwitchToMainThreadを書く

シンプルに処理をtry-catch-finallyでつつみ、catch/finallyUniTask.SwitchToMainThreadを書くというやり方です。

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

public class MultiThreadSample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        try
        {
            Debug.Log($"実行前のスレッド={Thread.CurrentThread.ManagedThreadId}");

            // これ以降の処理をスレッドプールへ切り替え
            await UniTask.SwitchToThreadPool();

            SampleMethod();

            Debug.Log($"実行完了後のスレッド={Thread.CurrentThread.ManagedThreadId}");
        }
        catch (Exception e)
        {
            // 例外処理をメインスレッドで行いたいなら追加する
            await UniTask.SwitchToMainThread();
            
            Debug.Log($"例外処理のスレッド={Thread.CurrentThread.ManagedThreadId}");
            Debug.LogException(e);
        }
        finally
        {
            // 例外が発生しようがしまいが、最後に確実にメインスレッドに戻す
            await UniTask.SwitchToMainThread();
        }
    }


    private void SampleMethod()
    {
        Debug.Log($"{nameof(SampleMethod)}を実行中:スレッド={Thread.CurrentThread.ManagedThreadId}");

        // 例外!
        throw new Exception("失敗しちゃった");
    }
}

UniTask.RunOnThreadPool vs UniTask.SwitchTo***

マルチスレッドで処理を呼び出す方法を2つ紹介しましたが、それぞれ何が違うのか。
ぶっちゃけ両方とも最終的にやることは同じです。

というのも、UniTask.RunOnThreadPoolの内部実装はこうなっています。

RunOnThreadPool

// Copyright (c) 2019 Yoshifumi Kawai / Cysharp, Inc.
// https://github.com/Cysharp/UniTask/blob/master/LICENSE

// UniTaskのRunOnThreadPoolの実装より抜粋
public static async UniTask RunOnThreadPool(Action action, bool configureAwait = true, CancellationToken cancellationToken = default)
{
    cancellationToken.ThrowIfCancellationRequested();

    await UniTask.SwitchToThreadPool();

    cancellationToken.ThrowIfCancellationRequested();

    if (configureAwait)
    {
        try
        {
            action();
        }
        finally
        {
            await UniTask.Yield();
        }
    }
    else
    {
        action();
    }

    cancellationToken.ThrowIfCancellationRequested();
}

やってることとしては、UniTask.SwitchToThreadPoolでスレッドを切り替えて、try-finallyで最後にメインスレッドへ戻してます。
UniTask.YieldUniTask.SwitchToMainThreadはこの場合においてはほぼ同じ挙動)

そのため「マルチスレッドで処理を実行した後にすぐにメインスレッドに戻す」という用途においてはどちらも大差ないので、書きやすい方を使うでよいと思います。

まとめ

スレッドを切り替える方法は主に2つ。

  • UniTask.RunOnThreadPoolを使う
    • 例外発生時も自動的にメインスレッドに戻してくれる(CofigureAwait=trueのとき)
    • CofigureAwaitで後続の処理スレッドをどうするかも指定できる(デフォルトtrue = メインスレッドに戻す
  • UniTask.SwitchToThreadPoolを使う
    • awaitした以降の処理をスレッドプール上に切り替えることができる
    • ただし例外発生時や途中return時にメインスレッドへ戻し忘れないように注意が必要
    • UniTask.ReturnToMainThreadを併用することを推奨

SimpleなUniTask.SwitchToThreadPoolを使うか、EasyなUniTask.RunOnThreadPoolを使うか。処理の複雑さと読みやすさで選べは一旦はOKかと。

34
30
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
34
30