水無川旅館 2024/07/18 05:40

【Unity】UniTaskで重い処理を並行で走らせたいならRunOnThreadPoolを使おう!

 前回の記事で、ひとつ書こうか悩んだことがあります。それは……、「重い処理を並行で走らせたい場合」のことです。

まぁまずは次のコードを見てくれ。こいつをどう思う?

 実行順序のクイズです。以下のコードは、どういう順序で実行されるでしょーか!?
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

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

public class TestScript : MonoBehaviour {
    async void Start() {
        Debug.Log("Start");
        await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
        await UniTask.WhenAll(TestA(), TestB());
        await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
        Debug.Log("End");
    }

    async UniTask TestA() {
        for (var i = 0; i < 65536; ++i) {
            Debug.Log($"Test A/{i}");
        }
    }

    async UniTask TestB() {
        for (var i = 0; i < 65536; ++i) {
            Debug.Log($"Test B/{i}");
        }
    }
}

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

 …………答えは……………………

↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓

 Start → Test A/0 ~ Test A/65535 → Test B/0 ~ Test B/65535 → End です。

 TestA() と TestB() が同時に実行されると思ったあなた。不正解です。

キエエエエ! できてへんやんけー基本的なことがーーーー!!!

 前回の記事を書いたあと、このことについて書こうか悩んでいました。

https://twitter.com/bydriv/status/1813622351290081570

https://twitter.com/bydriv/status/1813624113950240968

 ↑IOに限らず「重い処理」だとこうなります。

 このことはちょっと専門的で、説明するのも難しいし、まぁ普通にコード書く分には困らないだろう……。と思っていたりもしました。

 が、ちょっと前回の記事を書いた後気になって眠れなくなってしまったため、筆を執った次第です。

とりあえず解決策 その① ループ処理なら UniTask.Yield を使う

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

public class TestScript : MonoBehaviour {
    async void Start() {
        Debug.Log("Start");
        await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
        await UniTask.WhenAll(TestA(), TestB());
        await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
        Debug.Log("End");
    }

    async UniTask TestA() {
        for (var i = 0; i < 65536; ++i) {
            Debug.Log($"Test A/{i}");
            await UniTask.Yield(PlayerLoopTiming.Update);
        }
    }

    async UniTask TestB() {
        for (var i = 0; i < 65536; ++i) {
            Debug.Log($"Test B/{i}");
            await UniTask.Yield(PlayerLoopTiming.Update);
        }
    }
}

とりあえず解決策 その② IOなら非同期IOを使う

 たとえばファイル入出力で、同期IOを使ってしまうと上記のような挙動になります。たとえば File.ReadAllBytes とかですね。

 多くのライブラリで非同期IO(ほにゃにゃらAsync)という関数が提供されているはずですので、それを使いましょう。 File.ReadAllBytesAsync とかがそれです。

とりあえず解決策 その③ どうしてもだめなら UniTask.RunOnThreadPool を使う

 タイトルでは「使おう!」と言っている割に「どうしてもだめなら」というのはズコーーーーという感じですが、まぁ基本的には最終手段だと思ってください。

 この関数は文字通りスレッドを立てます。

 ただ、スレッドなので立てすぎると変な挙動になる可能性があります。なので、最終手段、ということです。

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

public class TestScript : MonoBehaviour {
    async void Start() {
        Debug.Log("Start");
        await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
        await UniTask.WhenAll(UniTask.RunOnThreadPool(TestA), UniTask.RunOnThreadPool(TestB));
        await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
        Debug.Log("End");
    }

    async UniTask TestA() {
        for (var i = 0; i < 65536; ++i) {
            Debug.Log($"Test A/{i}");
        }
    }

    async UniTask TestB() {
        for (var i = 0; i < 65536; ++i) {
            Debug.Log($"Test B/{i}");
        }
    }
}

非同期って結局なんなのぜ? スレッドとは違うの?

 非同期というのは、基本的には「メインスレッド上で疑似的に並行処理をする仕組み」であり、スレッド自体はひとつなのです。

 この仕組みを実現するものを 非同期ランタイム と呼びます。

 前回の記事で、

「画像の1/N枚目を読み込む→プログレスバー更新処理→画像の2/N枚目を読み込む→プログレスバー更新処理→……→画像のN/N枚目を読み込む→完了処理」というように交互に進めるなど、工夫して実装する

 という実装パターンを紹介しましたが、これを高度に抽象化した機構だと思うとよいでしょう。

 await が呼ばれてから、次に await が呼ばれるまで をひとつの実行の単位として 同期的に 実行し、それが終わったら、次の await の処理に移る……。という感じに、次々とメインスレッドで処理を切り替えるようになっているわけです。

 そのため await が一切呼ばれない、極度に重い処理をする関数 は、完全にひとつの実行の単位として実行されてしまい、 メインスレッドが止まってしまう のです。

 そのため、ライブラリ側で ほにゃにゃらAsync という、非同期バージョンの関数が提供されているわけです。

それでもどうしても必要な場合もあるため、これは RunOnThreadPool で、スレッドで実行する

 基本的には重い処理は await をはさんでするのが基本。それがわかったところで、それでもなお、どうしても await なしで重い処理をする必要もあると思います。

 そういう場合は RunOnThreadPool を使う! ここまで覚えて帰ってくださいね。

 今日はここまでです。よかったらいいねしてね♥ おつかれさまでした!

この記事が良かったらチップを贈って支援しましょう!

チップを贈るにはユーザー登録が必要です。チップについてはこちら

記事のタグから探す

月別アーカイブ

限定特典から探す

記事を検索