概要
今回はUniTask
を使ってUnity上でマルチスレッド処理を実行する場合の書き方と例外処理での注意点を紹介します。
UniTaskでマルチスレッド処理を実行する方法
UniTask
でマルチスレッド処理を実行する方法(書き方)は大まかに2パターン存在します。
やり方A. UniTask.RunOnThreadPoolを使う
UniTask.RunOnThreadPool
は引数で渡したデリゲートをスレッドプール上で実行し、処理完了後に自動的にUnityメインスレッドに戻ってくれるメソッドです。
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
なお、引数のCofigureAwait
をfalse
に変更することで、処理完了後にUnityメインスレッドへコンテキストを戻すかどうかを切り替えることもできます。
(デフォルトはtrue(メインスレッドへ自動的に戻る)
)
CofigureAwait=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.RunOnThreadPool
はawait
時に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.SwitchToThreadPool
とUniTask.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
この様にSwitchToThreadPool
とSwitchToMainThread
で挟んだ区間がマルチスレッド処理で実行されます。
しかしこのサンプルの書き方では例外発生時に意図しないスレッドで処理が実行される可能性があります。
例外発生時の挙動
上記のサンプルコードを単に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.ReturnToMainThread
を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 する
// ブロックを抜けたタイミングでメインスレッドに戻る
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
/finally
にUniTask.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
の内部実装はこうなっています。
// 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.Yield
とUniTask.SwitchToMainThread
はこの場合においてはほぼ同じ挙動)
そのため「マルチスレッドで処理を実行した後にすぐにメインスレッドに戻す」という用途においてはどちらも大差ないので、書きやすい方を使うでよいと思います。
まとめ
スレッドを切り替える方法は主に2つ。
-
UniTask.RunOnThreadPoolを使う
- 例外発生時も自動的にメインスレッドに戻してくれる(
CofigureAwait=true
のとき) -
CofigureAwait
で後続の処理スレッドをどうするかも指定できる(デフォルトtrue = メインスレッドに戻す
)
- 例外発生時も自動的にメインスレッドに戻してくれる(
-
UniTask.SwitchToThreadPoolを使う
-
await
した以降の処理をスレッドプール上に切り替えることができる - ただし例外発生時や途中
return
時にメインスレッドへ戻し忘れないように注意が必要 -
UniTask.ReturnToMainThread
を併用することを推奨
-
SimpleなUniTask.SwitchToThreadPool
を使うか、EasyなUniTask.RunOnThreadPool
を使うか。処理の複雑さと読みやすさで選べは一旦はOKかと。