3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unityの非同期処理は怖くない

Last updated at Posted at 2022-12-31

TL;DR

Unityで使える非同期処理は主に2種類、Task系とCoroutine系
相互変換可能ダヨ

注意・免責

C#のTaskは非常に奥が深い概念です。自分も間違って認識していることがあると思います。指摘は大歓迎です。

Unityにおける非同期処理

この記事にたどり着いた方は、何かしらの理由で非同期処理を触らなくてはならなくなった方だと思います。「通信が終わるのを待ちたい」「ファイルの読み出しに時間がかかっていてプチフリーズがひどい」「Animatorでの演出が終わるのを待ちたい」などなど、様々なシチュエーションが考えられます。そこで気にしなければならないのが、「何を使って待つか」という点です。
Unityで「待つ」「並列処理する」際に使えるのが主にTaskとCoroutineです。本記事ではそれぞれについて少し詳しく触れた後、使い分けと変換について触れます。

Task

TaskはC#の機能で、System.Threading.Tasks名前空間にあります。名前空間の指す通り、System領域、つまりUnityの中でもネイティブに近いAPIを使います。
具体的なソースコードを見てみましょう。

using System.Threading.Tasks;
using UnityEngine;

public class Test : MonoBehaviour {
    private void Start()
    {
        // パターン1
        DelayedMessagePost();
        // パターン2
        //Task.Run(DelayedMessagePost);
        // パターン3
        //Task.Run(()=>DelayedMessagePost());
    }
    private async Task DelayedMessagePost()
    {
        await Task.Delay(1000);
        Debug.Log("1秒待ったよ");
    }
}

上記コードは、シーンの再生から1秒後にConsoleに"1秒待ったよ"と表示するMonobehaviourです。パターン1~3のどの書き方でも動きます。
非同期処理は原則、asyncをつけた1つの関数の中に書きます。1その関数をTask.Runに指定することで非同期処理が始まります。
ここで「あれ?DelayedMessagePost()関数を呼び出すときにawaitは必要ないの?」と思った方もいるかと思います。実は、非同期処理を行うだけであればawaitは不要なのです2(もちろん呼び出し元のStart関数もasync不要)。では、どういうときに使うかというと、「その処理を待ってから次の処理を行いたいとき」です。サンプルではawait new Task.Delay(1000);Task.Delay(1000)、すなわち「1000ミリ秒かかる処理」を待っています。

さて、このTask、C#の機能で専用の構文も用意されており、コードは直感的に書きやすいのですが、問題を起こすことがあります。それは、オブジェクトやコンポーネントなどUnityの機能を触ろうとするとよく発生します。
例えば、以下のコードを見てみましょう。

using System.Threading.Tasks;
using UnityEngine;

public class Test2 : MonoBehaviour {
    private void Start()
    {
        Debug.Log("現在の位置は" + transform.position + "だよ");
        // パターン2の書き方を採用
        Task.Run(DelayedMessagePost);
    }
    private async Task DelayedMessagePost()
    {
        await Task.Delay(1000);
        Debug.Log("1秒待ったよ");
        Debug.Log("1秒後の位置は" + transform.position + "だよ");
    }
}

この関数は先ほどのTestでログを書き出すときに自身の位置を報告するものです。これをオブジェクトにアタッチして実行すると以下のようなエラーが発生します3

UnityException: get_transform can only be called from the main thread.
Constructors and field initializers will be executed from the loading thread when loading a scene.
Don't use this function in the constructor or field initializers, instead move initialization code to the Awake or Start function.

このエラー文の意味を理解するにはスレッドについて触れる必要があります。全部取り上げると長くなるので、ここではエラー文を理解するのに最低限必要な分を、たとえ話で分かりやすく記述します。
スレッドを「コンピュータの中で計算する人」で例えます。この人は大量の計算問題を1人で解いていましたが、とても時間がかかるため、友人を1人呼んで一緒に解いてもらうことにしました。このことを、「子スレッドを作成する」といい、元々計算していた人を「親スレッド」、呼び出された友人を「子スレッド」といいます。彼らは同時に計算するため処理が倍速になりました。やったね。

…で終われるほどスレッドの話は単純ではないのです。
例えば、計算問題A(時間がかかる)の結果を使って計算問題B(一瞬でできる)を解く場合、Aが解き終わる前にBを解き始めてはいけません。また、計算問題A・Bの計算結果を変数Xにそれぞれ1回ずつ加算するというタスクがあった場合、このタスクを2人で実行すると変数Xの読み取りと書き取りのタイミングによっては片方しか加算されないことがあります。この状況を図で表すとこんな感じ:

時刻 X 親スレッド 子スレッド 解説
0 10 初期状態。(ここではXに元々10が代入されているとする)
1 10 x:=10 親が加算処理を行うためXの値を読み出して一時変数xに代入
2 10 (x=10) y:=10 子が加算処理を行うためXの値を読み出して一時変数yに代入
3 10 x:=10+5 (y=10) 親がAの計算結果(ここでは5とする)を加算
4 10 (x=15) y:=10+3 子がBの計算結果(ここでは3とする)を加算
5 10 (x=15) (y=13) 他の処理を待つ
6 15 (x=15) (y=13) 親が加算結果をXに書き戻す
7 13 (x=15) (y=13) 子が加算結果をXに書き戻す
8 13 計算結果は本来18になってほしいのに13になってしまった

このようなことを避けるため、Unityでは指定されたスレッド4以外でUnityの機能5にアクセスすることを禁止しています。変数にアクセスするスレッドが1個であればこのようなことが起きることはないので安心して計算・代入できます。

先ほどのエラーは、子スレッドでUnityの機能にアクセスしてしまった際に発生するエラーでした。この子スレッドですが、Task.Runで呼び出すパターン2・3の場合に生成されます。逆に言うとパターン1の場合はメインスレッドのまま実行できるため、このエラーは発生しなくなります。ただし、これを裏返すと、パターン1では「重い処理をするとメインスレッドが止まる→Unityがフリーズする」ことになるので、以下のようなスクリプトの場合にパターン1で実行するのは避けましょう。

using System.Threading.Tasks;
using UnityEngine;

public class Test3 : MonoBehaviour {
    private void Start()
    {
        // パターン1で書くと処理時間だけフリーズする
        //HeavyTask();
        // パターン2・3で書けばフリーズはしない
        Task.Run(HeavyTask);
    }
    private async Task HeavyTask()
    {
        // 実行すると1秒かかる超重い処理
        Task.Delay(1000).Wait();
    }
}

ちなみに、非同期関数内で別の非同期関数を呼ぶ場合、返り値を設定することができます。

using System.Threading.Tasks;
using UnityEngine;

public class Test3 : MonoBehaviour {
    private void Start()
    {
        Task.Run(Calc);
    }

    private async Task Calc()
    {
        // 重い関数の実行終了を待ち、その値を取得する
        float result = await HeavyCalcTask();
        Debug.Log(result);
    }
    // float型の値を返す、重い関数
    private async Task<float> HeavyCalcTask()
    {
        Task.Delay(1000).Wait();
        return 100f;
    }
}

Coroutine

CoroutineはUnityの機能です。C#の機能ではありません。そのため、どこでも書けるわけではありません。具体的には、UnityEngine.MonoBehaviourの機能です。つまりコンポーネント以外では動きません。(エディタ拡張でも使えません。)

Coroutineの挙動に入る前に、Coroutineとは切っても切り離せないIEnumeratorについて話しましょう。

using UnityEngine;
using System.Collection;

public class Test4 : MonoBehaviour{

    private void Start()
    {
        var coroutine = WaitFor3Frames();
        StartCoroutine(coroutine);
    }

    public IEnumerator WaitFor3Frames()
    {
        for(int frameCount=0;frameCount<3;frameCount++)
        {
            yield return new WaitForEndOfFrame();
        }
        Debug.Log("3フレーム待ったよ");
        yield break;
    }
}

このようなMonoBehaviourがあるとします。このとき、WaitFor3Frames();を実行して帰ってくるオブジェクトSystem.Collection.IEnumeratorとはいったい何でしょう?
実は、このオブジェクトは「"値をちょうだい"と言うと次々と異なる値を返してくれるオブジェクト」です。いわゆるイテレータみたいなものです。
このオブジェクトには主に次のような関数が備わっています。

IEnumerator ie = ...;
var hoge = ie.Current; // 現在の値
ie.MoveNext(); // 次の値に移動する
ie.Reset(); // 最初の値に戻る

UnityのCoroutineは、ユーザーが指定したタイミングで次々とie.MoveNext()を呼んでくれるもの、と考えて下さい。つまり、先ほどの関数WaitFor3Frames()の返り値をStartCoroutineに代入すると、

// WaitFor3Frames()を実行した段階でこの先の最初のyield returnまでは進む
// for文の初期化ステートメント
int frameCount=0;
// for文の条件確認
frameCount<3 //trueなのでfor文の中へ
// ここでまずWaitForEndOfFrameオブジェクトを返す
yield return new WaitForEndOfFrame();

// Unityのコルーチンシステムは↑のオブジェクトをもとに「このフレームの最後まで待つ」を実施
// 待機が終わるとコルーチンシステムがMoveNext()を呼び出す
// for文の反復ステートメント
frameCount++;
// for文の条件確認
frameCount<3 //trueなのでfor文の最初から再度実行
// またWaitForEndOfFrameオブジェクトを返す
yield return new WaitForEndOfFrame();

// Unityのコルーチンシステムが↑のオブジェクトをもとに「このフレームの最後まで待つ」を実施
// 待機が終わるとコルーチンシステムがまたMoveNext()を呼び出す
// for文の反復ステートメント
frameCount++;
// for文の条件確認
frameCount<3 //trueなのでfor文の最初から再度実行
// またWaitForEndOfFrameオブジェクトを返す
yield return new WaitForEndOfFrame();

// Unityのコルーチンシステムは↑のオブジェクトをもとに「このフレームの最後まで待つ」を実施
// 待機が終わるとコルーチンシステムがMoveNext()を呼び出す
// for文の反復ステートメント
frameCount++;
// for文の条件確認
frameCount<3 //falseなのでfor文を抜けて次の行から実行
// ログ書き出し
Debug.Log("3フレーム待ったよ");
// ここで「このIEnumeratorから出るオブジェクトは全部出たよ」というメッセージを送る
yield break;

という流れが発生します。
なお、この関数では最後にyield breakしていますが、関数の最後まで行った時点で自動でyield breakされるので本来は書く必要がありません。
また、今回は固定回数で終了するものでしたが、IEnumeratorは実行時に出力オブジェクト数が決まっていなくても大丈夫です。無限にオブジェクトを書き出すIEnumeratorもあります。

このIEnumeratoryieldの仕組み自体はC#のものです。IEnumeratorを返す関数を手動で実行し、最後までMoveNext()を連続で呼び続ければ、IEnumeratorを返す関数でも一瞬で実行できます。

さて、このCoroutineですが、UnityEngine.MonoBehaviourの機能であると書きました。このことから、以下のような特性があります。

  • 実行は全てメインスレッド(Unityの機能が扱えるスレッド)で行われる
  • 実行したGameObjectやMonoBehaviourがGameObject.SetActive(false)MonoBehaviour.enabled = falseなどで無効化されると実行が止まる
  • 無効化されたGameObjectやMonoBehaviour上では、外部から関数を呼び出す等でStartCoroutineすることはできない

特に重要なのが最初の項目でして、スレッドのことを考えずに実行できるため便利です。ただし、全部メインスレッドであるということは、重い処理を並列処理するのに向かない、ということでもあります。

使い分け

TaskとCoroutineについて、筆者オススメの使い分け方法を紹介します。それは、
待ちたい理由が「スクリプトの処理時間が長く、その間に何か別のことをしたい」ならTask、そうでなければCoroutine
です。
※ただし、プロジェクトの方針ややりたいことによってどちらを使うかは変わってきます。

相互変換

上記の方針で運用したときに問題が起きることがあります。例えば、

  • ゲームアプリでAIの長考時間にフリーズさせたくないので思考プロセスは別スレッドに退避したいが、その結果を使って画面の要素を変化させたい(思考用スレッドのままでは操作できない)
    • Task→Coroutineの変換がしたい
  • 画面遷移中に平行して通信したい
    • Coroutine→Taskの変換がしたい

ということがあるかと思います。
それぞれ方法を見ていきましょう。

Task→Coroutine

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

public class Test5 : MonoBehaviour{
    private void Start()
    {
        StartCoroutine(WaitForHeavyCalcFinish());
    }

    public IEnumerator WaitForHeavyCalcFinish()
    {
        // TaskをCoroutineで待ちたい
        Task calcTask = Task.Run(HeavyCalc);
        // そんなときはWaitUntilを活用しよう
        // Taskには「終わったかどうか」を示すIsCompletedが用意されている
        yield return new WaitUntil(() => calcTask.IsCompleted);
        Debug.Log("Calc finished");
    }
    public void HeavyCalc()
    {
        Task.Delay(1000).Wait();
    }
}

Coroutine→Task

こちらは少し面倒です。なぜなら、C#のIEnumeratorやCoroutineに終了を表すプロパティがないためです。
そこで、1つ拡張クラスを作成します。なければ作ればいい

public class ExtendedEnumerator : IEnumerator
{
    private IEnumerator instance;
    public bool IsEnd { get; private set; }
    public ExtendedEnumerator(IEnumerator enumerator)
    {
        this.instance = enumerator;
    }
    public object Current => instance.Current;

    public bool MoveNext()
    {
        bool hasNext = instance.MoveNext();
        IsEnd = !hasNext;
        return hasNext;
    }

    public void Reset()
    {
        instance.Reset();
        IsEnd = false;
    }
}

IEnumerator.MoveNext関数の返り値は「次の要素を取り出せたときにtrue、そうでなければfalse」という意味を持ちます。そこで、次の要素を取り出すたびに最後の返り値を変数IsEndに書き出すことで取り出し終わったかどうかを覚えておくことができます。
この拡張クラスを用いると、Coroutine→Taskの変換は以下のように書くことができます。

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

public class Test6 : MonoBehaviour{
    private Animator animator;
    private void Start()
    {
        _ = ChangeScene();
    }

    public async Task ChangeScene()
    {
        ExtendedEnumerator animCoroutine = new ExtendedEnumerator(WaitForAnimationEnd());
        StartCoroutine(animCoroutine);
        while (!animCoroutine.IsEnd)
        {
            await Task.Delay(10);
        }
        Debug.Log("Calc finished");
    }
    public IEnumerator WaitForAnimationEnd()
    {
        yield return new WaitUntil(() => animator.GetCurrentAnimatorStateInfo(0).IsName("animFinished"));
    }
}

参考文献

How to check if code is running on the unity thread?
https://answers.unity.com/questions/1300851/how-to-check-if-code-is-running-on-the-unity-threa.html

  1. 関数内から別の非同期関数を呼ぶこともできます。1関数の中身が長くなったら分割も検討しましょう。

  2. コンパイラやエディタが「非同期処理の関数なのにawait忘れとるで」という警告を出しますが意図的であれば問題ありません。警告を消すには_ = DelayedMessagePost();のように破棄変数を使えばOKです。

  3. このエラーはUnityのメインスレッドで発生するものではないため、そのままではUnityのConsoleに表示されません。表示するには該当箇所をtry...catchで捕捉した上でDebug.LogException(e)で書き出す必要があります。

  4. StartUpdateなど、Unityのコールバック関数は全てUnityのオブジェクトが操作できるメインスレッドで呼び出されます。本記事では、「メインスレッド」という表記はこのスレッドを指します。

  5. UnityEngine名前空間以下の全部の機能が該当するわけではありません。例えば、Debug.Logは子スレッドでも使用できます。

3
3
1

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
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?