Unity非同期処理を理解しようとしてみた
Unityにおける非同期処理を理解するために、Simpleなコードから一歩づつ結果を見ていきます。
環境
- Windows10
- Unity 2019.2.11f1
- Visual Studio 2017
概要
- Unityで重い処理を実行してみる
- 重い処理を非同期実行してみる
- 重い処理の結果を待ってみる
- スレッドを追ってみる
- 後処理を正しい位置に修正
- (参考)Unity用のTaskパッケージ「UniTask」
シーン構成
シーン上にはUpdateMethodで上下に移動するだけのCubeと重い処理が実行されるボタン、そのただ2つだけがあります。
やりたいこと
- 重い処理をバックグラウンドで動かしつつ、Cubeの往復を止めない。
- 重い処理が終わったことを検知して、Cubeを消す。
Unityで重い処理を実行してみる
ここからボタンの処理を書いていきます。
最もシンプルなコードから始めます。
ボタンを押すとHeavyMethodとFinalizeMethodが順番に実行されます。
HeavyMethodは1秒Sleepするだけの単純な処理ですが、このコードを実行するとどうなるでしょうか?
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class AsyncTest : MonoBehaviour
{
public void ButtonClick()
{
Debug.Log("Start Click Method");
HeavyMethod();
FinalizeMethod();
Debug.Log("End Click Method");
}
void HeavyMethod()
{
Debug.Log("Start Heavy Method");
Thread.Sleep(1000);
Debug.Log("End Heavy Method");
}
void FinalizeMethod()
{
Debug.Log("Finalize Method");
}
}
結果はこちらです。
そしてHeavyMethodの実行中は、Cubeの動きは止まります。
重い処理を非同期で実行してみる
やっぱり重い処理の実行中は、シーンの処理(この場合はCubeの動き)を止めて欲しくないわけです。
ということで、重い処理を非同期実行にしてみます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class AsyncTest : MonoBehaviour
{
public void ButtonClick()
{
Debug.Log("Start Click Method");
Task.Run(HeavyMethod);
FinalizeMethod();
Debug.Log("End Click Method");
}
void HeavyMethod()
{
Debug.Log("Start Heavy Method");
Thread.Sleep(1000);
Debug.Log("End Heavy Method");
}
void FinalizeMethod()
{
Debug.Log("Finalize Method");
}
}
HeavyMethod();
をTask.Run(HeavyMethod);
に書き換えただけです。
これで結果がこのようになりました。
先ほどとは順番が変わっていますね。ButtonClickの中には
HeavyMethod
FinilizeMethod
となっているのに、順番が入れ替わって、
FinilizeMethod
HeavyMethod
の順番になっているわけです。
さらに言えば、Heavy Methodが終わる前に、Click Methodを抜けているのがわかります。
この実装であれば、画面のCubeの動きも止まりません。
重い処理の結果を待ってみる
さてここでまた新たな需要です。
「画面の動きは止めたくないけど、Finilize MethodはHeavy Methodが終わってから実行したい。」
わがままですね。
この需要を満たすように、処理を書き換えます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class AsyncTest : MonoBehaviour
{
public void ButtonClick()
{
Debug.Log("Start Click Method");
Task.Run(HeavyMethod);
// FinalizeMethod();
Debug.Log("End Click Method");
}
void HeavyMethod()
{
Debug.Log("Start Heavy Method");
Thread.Sleep(1000);
Debug.Log("End Heavy Method");
FinalizeMethod();
}
void FinalizeMethod()
{
Debug.Log("Finalize Method");
}
}
FinalizeMethod
の場所を移動しただけです。
実行結果はこちら。
この方法もまた、Cubeの移動は止まりません。
でもちょっと待ってください。FinalizeMethod
でもし、GameObjectの操作などをしたらどうでしょう?
試してみましょう。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class AsyncTest : MonoBehaviour
{
public void ButtonClick()
{
Debug.Log("Start Click Method : " + Thread.CurrentThread.ManagedThreadId);
Task.Run(HeavyMethod);
// FinalizeMethod();
Debug.Log("End Click Method : " + Thread.CurrentThread.ManagedThreadId);
}
void HeavyMethod()
{
Debug.Log("Start Heavy Method : " + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(1000);
Debug.Log("End Heavy Method : " + Thread.CurrentThread.ManagedThreadId);
FinalizeMethod();
}
void FinalizeMethod()
{
Debug.Log("Start Finalize Method : " + Thread.CurrentThread.ManagedThreadId);
Destroy(GameObject.Find("Cube"));
Debug.Log("Start Finalize Method : " + Thread.CurrentThread.ManagedThreadId);
}
}
FinalizeMethod
にCubeを破壊する処理と、おもむろにThreadのIdを出力する処理を追加しました。
結果はこちら。
あれ?
FinalizeMethod
が途中で終了していますね。
Cubeも破壊されていないし、例外も宇宙の彼方に消えてしまいました。
Task.Run()
で実行したHeavyMethod
から以降の処理のThreadのIdが切り替わってますね。非同期にした処理が、マルチスレッドで動いているのがわかります。
Unityのシーン上のオブジェクトは、メインスレッド(番号1のThread)からしか、触れることができないので、このようになってしまいました。
スレッドを追ってみる
- Cubeが消えない
- 例外が消える
という問題が発生していますが、一旦「Cubeが消えない」にフォーカスして、考えましょう。
DestroyCube
をメインスレッドから実行できればいいわけです。
というわけで修正していきます。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class AsyncTest : MonoBehaviour
{
private SynchronizationContext context;
public void ButtonClick()
{
context = SynchronizationContext.Current;
Debug.Log("Start Click Method : " + Thread.CurrentThread.ManagedThreadId);
Task.Run(HeavyMethod);
// FinalizeMethod();
Debug.Log("End Click Method : " + Thread.CurrentThread.ManagedThreadId);
}
void HeavyMethod()
{
Debug.Log("Start Heavy Method : " + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(1000);
Debug.Log("End Heavy Method : " + Thread.CurrentThread.ManagedThreadId);
FinalizeMethod();
}
void FinalizeMethod()
{
Debug.Log("Start Finalize Method : " + Thread.CurrentThread.ManagedThreadId);
context.Post(state => DestroyCube(), null);
// Destroy(GameObject.Find("Cube"));
Debug.Log("End Finalize Method : " + Thread.CurrentThread.ManagedThreadId);
}
void DestroyCube()
{
Debug.Log("DestroyCube : " + Thread.CurrentThread.ManagedThreadId);
Destroy(GameObject.Find("Cube"));
}
}
いきなりcontext = SynchronizationContext.Current
というものが出てきました。
これは、異なるThread間で状態をやりとりするためのオブジェクトです。
このcontext
を経由して、FinalizeMethod内でcontext.Post()
とすることで、メインスレッドに処理を戻すことができます。
また、本筋とは関係ありませんが、Cubeの破壊とLogの出力をまとめたMethodDestroyCube
を作成しました。
結果はこちら。
ちゃんとDestoryCube
がメインスレッドで実行され、シーン内のCubeが破壊されました。
ちなみにThreadIdが先程と異なっているのは、Unityが空いてるThreadを適当にPoolからPickUpして使うからです。自分で指定しているわけではありませんよ。
後処理を正しい位置に修正
さて、一応「やりたいこと」は実現できたように思いますね。
ただ、「重い処理の結果を待ってみる」でやったような、FinalizeMethod()
をHeavyMethod
内に書くのはよくない気がします。
HeavyMethod
の本来の責務ではないし、構造がネストして複雑になります。
というわけで、FinalizeMethod()
を本来あるべきButtonClick
内に戻します。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Threading;
using System.Threading.Tasks;
public class AsyncTest : MonoBehaviour
{
// private SynchronizationContext context;
async void ButtonClick()
{
// context = SynchronizationContext.Current;
Debug.Log("Start Click Method : " + Thread.CurrentThread.ManagedThreadId);
await Task.Run(HeavyMethod);
FinalizeMethod();
Debug.Log("End Click Method : " + Thread.CurrentThread.ManagedThreadId);
}
void HeavyMethod()
{
Debug.Log("Start Heavy Method : " + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(1000);
Debug.Log("End Heavy Method : " + Thread.CurrentThread.ManagedThreadId);
// FinalizeMethod();
}
void FinalizeMethod()
{
Debug.Log("Start Finalize Method : " + Thread.CurrentThread.ManagedThreadId);
// context.Post(state => DestroyCube(), null);
DestroyCube();
Debug.Log("End Finalize Method : " + Thread.CurrentThread.ManagedThreadId);
}
void DestroyCube()
{
Debug.Log("DestroyCube : " + Thread.CurrentThread.ManagedThreadId);
Destroy(GameObject.Find("Cube"));
}
}
ただ戻すだけでは、検証したようにTask.Run()
の結果をまたずにFinalizeMethod()
が動いてしまいます。
今回はTask.Run()
の前にawait
というものがありますね。これは、Task.Run()
が完了するまで次の処理を待てよ、という意味です。
これを付けると、VisualStudio2017様に「呼び出し元にasync
をつけろよ」と言われます。言われた通りにasync
をつけました。
いいですね。正しい順番で動いてます。先ほどと違うのは、End Click Methodの位置でしょうか。
Task.Run()
で実行した処理の中から、メインスレッドで処理をしたいという需要意外では、SynchronizationContext
を使う必要はないってことなんですね。
これならDestroyCube
で発生した例外もcatchできます。
Unity用のTask(UniTask)
今までは使ってきたTaskという仕組みは、C#に備わっている仕組みです。Unityの仕組みではありません。
これはusing System.Threading.Tasks;
をimportしていることからもわかりますね。
実は、UnityにはTaskをもっとUnityに特化させたUniTaskというものがあるようです。
これを使うと、処理が速くなるだけではなく後々いろいろメリットがありそうです。
合わせて、今回は自作メソッドごとTask.Run()
で呼び出しましたが、今回言及しなかった、別スレッドでの処理の例外をメインスレッドで受けるためにも「UniTaskCompletionSource」などを使って直接awaitできるように修正したら、よりいいのではないでしょうか。
使い方
UniTaskをGitHubから拾ってきて、UnityPackageをimportしたら、using UniRx.Async;
を書く。
それでは。