UniRxの使い方を間違い、フリーズを発生させてしまった


はじめに

初歩的なミスをしてしまったので、再び発生させないようにするためにも備忘録を残します。

制作しているアプリで一定時間メインスレッドが止まり表示が更新されないフリーズが特定条件で発生していました。

原因を調査するためにプロファイラーを見ていたところ、GarbageCollectionで大きな山が😱

GCが発生した箇所

プロファイラーの詳細を見ると、ObservableDestroyTrigger.OnDestroyで発生していました。

コードを調べてみると、IObservableのSubscribe時に呼び出すAddTo()が原因でした。

問題となったコード

実際のコードは載せられないので、近い挙動をするコードになります。

using System;
using UniRx;
using UnityEngine;

public class Sample : MonoBehaviour {
    private Subject<Unit> _apiSubject = new Subject<Unit>();
    private Subject<Unit> _subject = new Subject<Unit>();

    void Start() {
        // ApiReceive時の想定
        _apiSubject.StartWith(Unit.Default).Subscribe(_ => {
            UpdateSubject();
        }).AddTo(this);

        // Apiの複数回投げたときの想定
        for (int i = 0; i < 26000; i++) {
            _apiSubject.OnNext(Unit.Default);
        }
    }

    void UpdateSubject() {
        _subject.Subscribe(_ => {
            Debug.Log("DoSomething");
        }).AddTo(this);
    }

    void Update() {
        if (Input.GetKeyDown(KeyCode.D)) {
            Destroy(gameObject);
        }
    }
}

実際のコードが、Apiであるデータのレスポンスが返ってきたときに対象の処理を再購読するというものでしたので、上記のようなコードになっています。

(実際のコードではこれほど大量にNextを発行していませんが、実際起った挙動を再現するためにこのようにしています)

ちなみにこちらのコードでGameObjectがDestroyされたときに、2.52GBのGC Allocが働いています。

なぜAddToが必要か

購読とオブジェクトを紐付けないと、オブジェクトが破棄されても購読が残り処理が走ってしまいます。

今回のコードは同じクラス内で生成したSubjectに対して購読しているので、AddTo()がなくても問題ありませんが、
クラスを跨いだときに問題が出るのでAddToを呼び出す癖をつけておいた方が良いです。

実際の挙動

フリーズが分かり易いように、アニメーションしているキューブを表示しています。

Dキーを押した時に、GameObjectがDestroyされるときにフリーズが発生しています。

何が問題だったか

こちらの処理に問題があります。

    void UpdateSubject() {
        _subject.Subscribe(_ => {
            Debug.Log("DoSomething");
        }).AddTo(this);
    }

再び購読する際に、前回購読していたものを破棄していないのでどんどん購読が積まれて行ってしまいます。

正しいコード

    private IDisposable _disposable = null;
    void UpdateSubject() {
        _disposable?.Dispose();
        _disposable = _subject.Subscribe(_ => {
            Debug.Log("DoSomething");
        }).AddTo(this);
    }

再購読時、上記のように前回のものを破棄してから購読させます。

?はnull条件演算子で、オブジェクトがnullの場合は関数が呼ばれません。
以下と同じ処理になります。

        if (_disposable != null) {
            _disposable.Dispose();
        }

最後に

普段、再購読するときは前回購読したものを破棄すると気をつけてはいましたが、今回はミスで正しくないコードになっていました。

ちょっとしたミスが分かりづらい不具合を生んでしまいます。

今回のミスのおかげで、GCが大量に走ってしまうパターンを発見することができました。

今回紹介した内容は初歩的なミスですが、これからはAddToに同じオブジェクトを大量に指定しないように気をつけなければいけませんね。