エフアンダーバー

個人開発の記録

UnityとIDisposableの罠

この記事には修正版があります。

この記事は修正前にこの記事を読んだ方が修正箇所を確認するためにあります。
初めてこの記事を読む方は修正版をお読みください。

www.f-sp.com


UnityとIDisposable絡みのバグで一日悩んだのでメモ。

IDisposableというよりもファイナライザ(C#的にはデストラクタ)の問題なのだけど、 ファイナライザの主な用途がIDisposableだと思うのでこのタイトルで。

【2016/09/02 追記】

【2016/09/02 修正】

はじめに

IDisposableはC#ではお馴染みのインターフェースで、 メモリなどのリソースを明示的に解放する必要がある場合(あるいは明示的に解放することに利点がある場合)に実装します。 usingステートメントなどC#の言語仕様にまで組み込まれる重要なインターフェースです。

しかし、UnityにおいてはUnity自体がだいたいのリソース管理をやってくれるうえ、 MonoBehaviourOnDisableOnDestroyといった機構を持っているためあまり出番はありません。 とはいえ、まったく使い道がないわけでもありません。 例えば、HideFlags.DontUnloadUnusedAssetを適用したオブジェクトの管理が考えられます (蛇足ですが、HideFlags.DontSaveHideFlags.HideAndDontSaveを適用した場合も同様です)。

エディタ拡張を書くときなど、 Unityに勝手にリソースを回収してほしくない場合には、 対象のリソース(UnityEngine.Objectを継承した型)のhideFlagsHideFlags.DontUnloadUnusedAssetを設定します。 このフラグを設定すると、リソースがUnityの判断で解放されない代わりに、 DestroyImmediateをリソースに対して呼び出して明示的に解放しなければならない義務が発生します。 これは、IDisposableを利用すべきシチュエーションに合致しています。

というわけで、このような想定のもとIDisposableを実装したクラスを書いていきます。

IDisposable 実装クラス

IDisposable実装のテンプレートに従って、実装クラスを書くと次のようになります(人によって多少の違いはありますが)。

using System;
using UnityEngine;

public class DisposableObject : IDisposable
{
    private ScriptableObject @object;

    private bool disposed;

    public DisposableObject()
    {
        @object = ScriptableObject.CreateInstance<ScriptableObject>();
        @object.hideFlags = HideFlags.DontUnloadUnusedAsset;
    }

    ~DisposableObject()
    {
        if (!disposed)
        {
            Dispose(false);

            disposed = true;
        }
    }

    public void Dispose()
    {
        if (!disposed)
        {
            Dispose(true);
            GC.SuppressFinalize(this);

            disposed = true;
        }
    }

    protected virtual void Dispose(bool disposing)
    {
        UnityEngine.Object.DestroyImmediate(@object);
    }
}

コンストラクタでHideFlags.DontUnloadUnusedAssetを設定したScriptableObjectを生成して、 protectedDisposeメソッドでDestroyImmediateにより解放。

IDisposableで定義されているDisposeメソッドをpublicで宣言し、 protectedDisposeメソッドに引数trueを与えて呼び出すよう実装。 明示的に呼び出されなかった場合にも必ずprotectedDisposeメソッドが呼び出されるように、 ファイナライザをprotectedDisposeメソッドに引数falseを与えて呼び出すように実装。

まあ、いつものやつです。

protectedDisposeメソッドの引数disposingは明示的に呼び出されたか否かの確認に使います。 絶対に解放しなければならないリソースは引数の値に関わらず解放し、 解放するとパフォーマンスがよくなるよ程度のものであれば引数がtrueのときだけ解放します。

このコードでは引数の値に関わらず常に解放するように記述しています。

IDisposable 実装クラスの使用

では、実際にこのクラスをスクリプトで使用してみます。

using UnityEngine;

public class GameScript : MonoBehaviour
{
    private DisposableObject disposable;

    private void Awake()
    {
        this.disposable = new DisposableObject();
    }

    private void OnDestory() // typo
    {
        this.disposable.Dispose();
    }
}

シンプルなAwakeでオブジェクトを初期化し、OnDestroyでオブジェクトを解放しようとするスクリプトです。 ただし、OnDestroyの定義でタイプミスをしており、 実際にはOnDestoryメソッドは呼び出されず、Disposeメソッドは呼ばれません。

このスクリプトを適当なゲームオブジェクトにアタッチして実行してみます。

すると、ゲームの終了時*1にUnityが次のようなエラーをはきます。

DestroyImmediate can only be called from the main thread.

何が起きたか

まあエラーの内容のままなのですが・・・

Unityは基本的にシングルスレッド設計です *2。 そのため、メインスレッド以外からUnityのAPIを呼び出そうとするとこのように拒否されます。

では、いったいどうして別スレッドから呼び出されたかというと、 protectedDisposeメソッドがファイナライザを通して実行されたためです。 ファイナライザはファイナライズ用のスレッド上で実行されます。 そのため、DestroyImmediateの呼び出しがファイナライズ用のスレッドから行われ、Unityに怒られるのです。

バグの連鎖

とはいえ、ここまでの話は特に大したことはありません。 IDisposableの実装の仕方を知っているくらい知識があるならば、 先ほどのエラーメッセージで原因の推測はできます。

問題なのはここから・・・バグは連鎖するのです。

エラーメッセージの消失

IDisposable実装クラスの解放部分のコードを次のように変更します。

protected virtual void Dispose(bool disposing)
{
    if (@object != null)
    {
        UnityEngine.Object.DestroyImmediate(@object);

        @object = null;
    }
}

すると、不思議な事にエラーメッセージが表示されなくなります。

デバッガをアタッチして詳細を追ってみると、どうも最初のnullチェックの部分で死んでいるようです。 UnityEngine.Objectに対する比較はオーバーロードが定義されているので、その内部で何かが起きているのだと推測されます。

エラーメッセージが表示されるということは、発生した問題がある意味では適切に処理されているということです。 つまり裏を返せば、表示されないということは・・・

【修正】
表示されない理由はUnityがゲーム終了時にScriptableObjectが回収してしまうためのようです。 結果として条件式が偽となるためDestroyImmediateは呼び出されません。 しかし、条件式内でUnityのAPIである演算子のオーバーロードが呼び出されていることには注意すべきです。

デバッガをアタッチしたせいで、余計なプロパティやメソッドを呼び出してしまい、その結果のエラーをみて勘違いした模様。
【ここまで】

閉じないエディタ

さて、はじめに自前にリソース管理をするべき状況の例としてエディタ拡張を挙げました。 というわけで、実装クラスの使用箇所をEditorWindow内に変えてみましょう。

using UnityEditor;

public class EditorScript : EditorWindow
{
    private FatalDisposableObject disposable;

    [MenuItem("IDisposable/Open")]
    public static void Open()
    {
        EditorScript @this = GetWindow<EditorScript>();

        @this.disposable = new FatalDisposableObject();
    }
}

これでIDisposable実装クラスはうまくいけば(?!)Unityエディタの終了時まで生き残る(GCに回収されない)はずです。

ついでに、管理するリソースをもっと深刻なものに変えてみましょう。

using System;
using UnityEngine;

public class FatalDisposableObject : IDisposable
{
    private RenderTexture @object;

    private bool disposed;

    public FatalDisposableObject()
    {
        @object = new RenderTexture(1024, 1024, 0, RenderTextureFormat.ARGB32);
        @object.hideFlags = HideFlags.DontUnloadUnusedAsset;
        @object.Create();
    }

    ~FatalDisposableObject()
    {
        if (!disposed)
        {
            Dispose(false);

            disposed = true;
        }
    }

    public void Dispose()
    {
        if (!disposed)
        {
            Dispose(true);
            GC.SuppressFinalize(this);

            disposed = true;
        }
    }

    protected virtual void Dispose(bool disposing)
    {
        if (@object != null)
        {
            @object.Release();
            UnityEngine.Object.DestroyImmediate(@object);

            @object = null;
        }
    }
}

リソースをScriptableObjectからRenderTextureに変えました。 これで解放されなかったときのリスクは重大です。 Unityはなんとしても解放したいはず。

あとはつくったエディタウィンドウを開いてから、Unityエディタを閉じようとするだけで、 Unityが無限ループに嵌って動かなくなります。

【追記】
どうもふつうに閉じられるときと閉じられないときとあるようです。 差がどこにあるのかは不明ですが、何度もウィンドウを開閉してから閉じようとすると固まる可能性が高いようです。

【修正】
特に開いた回数などは関係なかった模様。 原因から察するにGCがオブジェクトを処理する順番やスレッドの順番の問題かと。 修正版の記事には動作が停止した理由について、もう少し詳しく書きました。
【ここまで】

対処法

対処法としては簡単で、ファイナライザからUnityのAPIを呼び出さないようにするだけです。

具体的には、

  • disposing引数によって場合分けする
  • そもそもファイナライザを定義しない

という二通りがあります。

基本的にはUnity関連のリソース管理にファイナライザはいらないと思うのですが、 IDisposableのテンプレート実装に慣れていると書かないのが気持ち悪いので自分はdisposingで場合分けしています。

ちなみに要注意なケース(というか今回自分がやらかしたこと)として、 IDisposableなオブジェクトをラップしたIDisposableなクラスを定義する場合があります。 下記はアウトです(理由は言うまでもないと思います)。

protected virtual void Dispose(bool disposing)
{
    member.Dispose();
}

補足:リソースの行方 【追記】

上述の対処法では明示的な解放をしなかった場合に、 結局リソースに対してDestroyImmediateを呼び出していません。 そのとき解放されなかったリソースはどうなってしまうのか、という話。

正直な話、私はあまり下層レイヤーに明るくなく、Unityの内部を知っているわけでもないのでよくわかりません。 しかし、HideFlags.DontUnloadUnusedAssetの設定により、Unityがリソース管理を全面的にユーザコードに委ねているとは思えません。 おそらく、内部的にはどのようなリソースを使用中であるかという情報は管理していて、 絶対にすべて解放しなければならないタイミング(つまりはプログラムの終了時)ではすべて解放されるものと思われます。 あるいはOSレベルでそうなっているのかもしれません。

というわけで、私個人の見解としては「問題ないんじゃない?」という感じです。
もし詳しい方がいたら教えてください。

【修正】
Unity標準のプロファイラで確認したところ、やはりUnityが内部的に使用中か否か管理しているようでした。
【ここまで】

補足:HideFlagsについて 【追記】

はじめにエディタ拡張でリソースに対してHideFlags.DontUnloadUnusedAssetを設定すると勝手に回収されなくなると書きました。 しかし、いくらか試してみたところ、どうもHideFlags.DontSaveInEditorを設定していないとゲーム終了時に回収されてしまうようです。

HideFlags.DontSaveInEditorスクリプトリファレンスによると、

The object will not be saved to the scene in the editor.

らしいので、これが回収のフラグに使用されているのはおかしな感じがしますが、 ゲーム終了時にシーンのデシリアライズをしているのでそのあたりが何か関係しているのかもしれません(詳細不明)。

【修正】
HideFlags.DontSaveInEditorの値によってリソースが回収される理由についてなんとなく推測して修正版に書きました。
【ここまで】

まあ、実際にエディタ拡張でHideFlagsを使用する場合には、 HideFlags.DontSaveHideFlags.HideAndDontSaveを使うことになるので、 これが問題になることはあまりないとは思います。

おわりに

今回、記事では順を追って説明しましたが、実際には突然エディタがフリーズする現象が発生したので(しかも何故か再現性が微妙だった)、 バグの特定に非常に苦労しました。

また、解放忘れなんて滅多に起きないと思うかもしれませんが、 どうも調べている限りEditorWindowでは普通に発生してしまう事態のようなのです。 その話はまたそのうち(次回?)。



執筆時のUnityのバージョン:5.4.0f3
追記時のUnityのバージョン:同上

*1:正確にはGCにより対象オブジェクトが回収されたとき

*2:内部的にはマルチスレッドだったりしますが