エフアンダーバー

個人開発の記録

UnityとIDisposableの罠 【修正版】

この記事について
先日投稿した記事について再検証したところ、間違いや勘違いが多数見つかったため修正版を投稿します。 また、その際にいくらか細部について理解できた部分があるため、それらについて追記しています。

内容は大部分が元記事と重複しています。
元記事を読んでいない方が読みやすいよう、修正点についてはこの記事には記述しません。
修正箇所の確認は元記事をご参照ください。

www.f-sp.com


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

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

はじめに

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;
    }
}

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

これはゲーム終了時にUnityが自動的にScriptableObjectを回収することにより、条件式が偽と判定されたためです (なぜ回収されてしまうかは後述)。 すでに回収されてしまったのだから改めて解放する必要もないし、UnityのAPIも呼び出していない、 ならばこれは正しい記述ではないかと思うかもしれません。 本当にそうでしょうか?

UnityはUnityEngine.Object同士の比較に対してオーバーロードを定義しています。 よって、最初の条件式はUnityのAPIである静的なメソッドの呼び出しに置換されます。 つまり、やはりこの記述もメインスレッド以外からUnityのAPIを呼び出しています *3

閉じないエディタ

さて、はじめに自前にリソース管理をするべき状況の例としてエディタ拡張を挙げました。 というわけで、実装クラスの使用箇所を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に変えました。 RenderTextureはUnityの外部にも依存する繊細なリソースです。 Unityも扱いには慎重なはず。

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

これはファイナライザ実行のタイミングでUnityの管理するMonoオブジェクトがすでに破棄されているために起こります。 実際、停止時のログを見てみると、

GetManagerFromContext: pointer to object of manager 'MonoManager' is NULL (table index 5)

(Filename: C:/buildslave/unity/build/Runtime/BaseClasses/ManagerContext.cpp Line: 92)

となっており、すでにNULLとなっているMonoManagerへのアクセスが問題と推測されます。 無限ループに嵌る原因までは不明ですが、 演算子のオーバーロードによりリソースの存在を確認しようとしたときに、 その存在を管理していたオブジェクトが破棄されていたために何か問題が発生したのでしょう。

対処法

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

具体的には、

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

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

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

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

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

補足:リソースの行方

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

Unity標準のプロファイラでメモリの項目を確認してみると、 HideFlags.DontUnloadUnusedAssetを設定したリソースは"Scene Memory"から"Not Saved"に移されるものの、 存在自体はUnityも認識したままのようです。 ですので、絶対にすべて解放しなければならないタイミング(つまりはプログラムの終了時)ではすべて解放されるものと思われます。

補足:HideFlagsについて

「エラーメッセージの消失」の項でゲーム終了時にScriptableObjectが回収されると書きました。 HideFlags.DontUnloadUnusedAssetを設定したにも関わらず何故でしょうか?

これはUnityがゲームの開始時と終了時にUnloadUnusedAssetsとは少し異なる方法でリソースの回収をするためのようです。 Unityはゲームの終了時にアセットやエディタで用いているもの以外のすべてのオブジェクトを破棄してきれいにした上で、 ゲーム開始前にシリアライズしておいたシーンをデシリアライズしようとします。 このとき、エディタで用いていないということを確認するためにHideFlags.DontSaveInEditorの値を参照するようなのです。

HideFlags.DontUnloadUnusedAssetの意味はUnloadUnusedAssetsで回収されないというだけで、 エディタ内のオブジェクトかゲーム内のオブジェクトかということは規定していないためこの用途では使えません。

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

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

らしいので、こちらも本来の用途とは少し意味合いがずれていると思うのですが、 エディタ用かゲーム用かを判断するのにはこちらの方が適しているということなのでしょう。

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

おわりに

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

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



執筆時のUnityのバージョン:5.4.0f3

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

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

*3:ただし、Unity側が演算子のオーバーロードも含めて外部スレッドから呼び出してはいけないとしているかは謎

*4:固まる場合とすんなり閉じる場合とありますが、固まる確率はそれほど低くないので何度かやれば再現できるはず