ReactivePropertySlim詳解

ReactiveProperty v4.1.0 をリリースしましたということで、Pull Requestしたコードをリリースして頂きました。ReactivePropertyはオリジナルは私が作ったのですが、数年前からokazukiさんがメインに開発/リリースしてもらっています。

今回はReactivePropertySlimという新クラスが追加されました!名前の通り、軽量なReactivePropertyで、これはもともと社内で(Unityの)ReactivePropertyを大量に使っていて、改善のやり玉に上がっていて、その中で施された/施した施策を移植してきたという代物になります。当初そんな乗り気じゃなかったんですが、同僚に書き換えてもらったのを見て、ようやくやる気が上がったという、最低ですね、はい。

無印との違いは

  • フィールド数を最小限にしてアロケーションを抑えた(無印はバリデーション系などのためにSubjectやLazyの保持がかなりある)
  • 内部で使ってるSubjectをやめて完全自前管理&Subscription(IDisposable)自体を連結リストのノード自身にすることで、複数Subscribeでのアロケーションをなくした
  • 変更通知の実行をスケジューラー経由で行わず直接する(無印はデフォルトでDispatcher経由になるけれど、パフォーマンス上の問題と、厄介な挙動を時折示していた)
  • バリデーション系のメソッドを除去
  • ReactivePropertySlimからObservable Sourceを受けとる機能/コンストラクタを削除(ReadOnlyReactivePropertySlimのみがその機能を持つ)

もともとReactivePropertyはViewModelでのViewへのバインディング用を主に考えて機能を足していったため、Modelで使う分には不適切な重さがあるな、と考えていました。なので今回、一掃して、2018年エディションとして再デザインしました。基本的な箇所の設計は2011年と6年前のものなので、今視点で見ると考慮が甘い部分も割とあったのですよね。

パフォーマンスを見てみましょう。

image

image

上がコンストラクタ+3つSubscribeした場合。下がValueへの代入を3回した場合。Subscribeの高速化と生成時も含めた省メモリは意図通りなのですが、Valueの代入のほうがインパクト大きいですね。こっちは想定外。これはScheduler経由をなくした効果だと思われるけれど、かなりの差がでてて、あらあら、という感じ……(ちなみにSchedulerはImmediate指定してるのでSchedulerの中では最速ではあるはず)。

生成/Subscribeの高速化は起動時間(Unityだとシーン初期化だとか、WPFでもWindow作ったりとか)に影響あるので、短いにこしたこたぁないですねん。いいことです。

ReactiveProperty/Subject分解

Slim、について考える前に、改めてReactivePropertyについて見てみましょう。

// 最小のReactivePropertyはSubjectのラッパーというイメージ
public class MinimumReactiveProperty<T> : IObservable<T>
{
    readonly Subject<T> subject = new Subject<T>();
    T latestValue;

    public T Value
    {
        get
        {
            return latestValue;
        }
        set
        {
            // 値の設定で通知
            latestValue = value;
            subject.OnNext(value);
        }
    }

    public IDisposable Subscribe(IObserver<T> observer)
    {
        return subject.Subscribe(observer);
    }
}

これ以上ないってぐらいシンプルで、まぁいいじゃんって話で、2011年は不満はなかったんですが、今視点で見るとちょっと引っかかるところがあったりします。

というわけで、Subjectを展開してみます。

public class MinimumReactiveProperty<T> : IObservable<T>
{
    // Subjectの内部のobserverのリスト
    IObserver<T>[] data;

    public T Value
    {
        set
        {
            // subject.OnNext(value);
            for (var i = 0; i < data.Length; i++)
            {
                data[i].OnNext(value);
            }
        }
    }

    public IDisposable Subscribe(IObserver<T> observer)
    {
        // observerの追加のたびに新しい配列に詰め直し(ImmutableArray)
        var newData = new IObserver<T>[data.Length + 1];
        Array.Copy(data, newData, data.Length);
        newData[data.Length] = value;
        data = newData; // (代入時、実際にはThreadsafeのための挙動も入ります)

        // 購読解除のためのIDisposableの生成
        return new Subscription(this, observer);
    }
}

Subjectは内部でIObserverをImmutableArrayとして保持しています。なのでSubscribeがある度に、新規配列を生成してコピーしてます。古いやつはゴミ行き!これは一見無駄に見えますが、別に悪い話ではなくて、一点目はスレッドセーフになること(しやすいこと、実際には代入前後にThreadSafeを確保する処理は必要)。二点目は、OnNextが最速になること。C#において列挙は、配列をその配列の長さでforで回すのが最速です。通常、この手のイベント処理は、購読が初回の一回で、その後に大量の配信があるという構成になるのが普通なので、OnNext側の性能を最大限にするというのは全然アリです。

また、こう見ると、Subjectではなく生のevent構文を使ったほうが安価に見えるかもしれませんが、実はC#のeventも似たような実装になっているためMulticastDelegate.CompibeImplぶっちゃけ同じです(この辺は特にイベント専用のマジックとかなく、割と実装されたまんまに実行されます)。

そして、最後に購読解除のためのSubscriptionを作って返す。これは必要コストですよねshoganai。

で、まぁ、OnNextの性能を最大限にするとはいえImmutableArrayは生成コストがちょっと高いよねー、と思ってました。また、Subscriptionを都度生成しなきゃいけないのも必要コストとはいえ勿体無くて、気になるものは気になる。うーむ。

それらを何とかするアイディアとして、必要コストとして絶対に存在するSubscriptionを線形リストのノードにすることで、最小限の生成に抑えました。

// 別添えでLinkedList本体は作らず、ReactivePropertySlim自体をLinkedListにする(節約)
internal interface IObserverLinkedList<T>
{
    void UnsubscribeNode(ObserverNode<T> node);
}

// LinkedListNode自体がSubscriptionになる(節約)
internal sealed class ObserverNode<T> : IObserver<T>, IDisposable
{
    readonly IObserver<T> observer;
    IObserverLinkedList<T> list;

    public ObserverNode<T> Previous { get; internal set; }
    public ObserverNode<T> Next { get; internal set; }

    public ObserverNode(IObserverLinkedList<T> list, IObserver<T> observer)
    {
        this.list = list;
        this.observer = observer;
    }

    public void OnNext(T value)
    {
        observer.OnNext(value);
    }

    public void OnError(Exception error)
    {
        observer.OnError(error);
    }

    public void OnCompleted()
    {
        observer.OnCompleted();
    }

    public void Dispose()
    {
        var sourceList = Interlocked.Exchange(ref list, null);
        if (sourceList != null)
        {
            sourceList.UnsubscribeNode(this);
            sourceList = null;
        }
    }
}

// というのを使って実装すると
public class ReactivePropertySlim<T> : IReactiveProperty<T>, IReadOnlyReactiveProperty<T>, IObserverLinkedList<T>
{
    // LinkedListでいうFirstとLastを保持(ReactivePropertySlim自体がLinkedList本体になる)
    ObserverNode<T> root;
    ObserverNode<T> last;

    public T Value
    {
        set
        {
            this.latestValue = value;
            
            // LinkedListを辿ってObserverを発火
            var node = root;
            while (node != null)
            {
                node.OnNext(value);
                node = node.Next;
            }

            this.PropertyChanged?.Invoke(this, SingletonPropertyChangedEventArgs.Value);
        }
    }

    public IDisposable Subscribe(IObserver<T> observer)
    {
        // 線形リストのノードを作って、自身でノードを管理する
        var next = new ObserverNode<T>(this, observer);
        if (root == null)
        {
            root = last = next;
        }
        else
        {
            last.Next = next;
            next.Previous = last;
            last = next;
        }

        return next; // ノード自体がSubscription
    }
    
    // SubscriptionのDisposeでLinkedListを張り替える
    void IObserverLinkedList<T>.UnsubscribeNode(ObserverNode<T> node)
    {
        if (node == root)
        {
            root = node.Next;
        }
        if (node == last)
        {
            last = node.Previous;
        }

        if (node.Previous != null)
        {
            node.Previous.Next = node.Next;
        }
        if (node.Next != null)
        {
            node.Next.Previous = node.Previous;
        }
    }
}

良い所は、生成において無駄が全くない。同居できるものは徹底的に同居させることで、もうこれ以上は削れないでしょう。多分。悪い所は、線形リストの列挙は、配列列挙よりも明らかに遅いので、通知のパフォーマンスの低下がある。まあこのへんは購読料にもよりけりなので、なんとも言い難いところですね。(それとReactiveProperty, ReactivePropertySlim比較だと、スケジューラー経由を削ったことによってそれどころじゃないパフォーマンス向上を果たした)。

悪い所は、スレッドセーフじゃないです。うーん、Subscriptionの解除側ぐらいはスレッドセーフにしたほうがいいかなあ。ここちょっと悩ましい所で、考えさせてください。

***Slim

***Slimという命名は、ManualResetEventManualResetEventSlimの関係性にならって付けています。ManualResetEventは、通常Slim版しか使わないです。

と、いうわけで、ReactivePropertySlimも、Model専用での推奨というかは、ViewModel側でも支障がなければ積極的に使ったほうが幸せになれると思っています。機能的には、バリデーションが必要なところだけ、無印を使うのがいいと考えています。

機能的に低下した所は他に、ToReactivePropertySlimがありません。これは、Sourceから流れてくるのとValueへのセットの二通りで値が変化する(Mergeされてる)のが気持ち悪いというか、使いみちあるのそれ?みたいに思ったからです。ない、とはいわないまでも、存在がおかしい。のでいっそ消しました。かわりにToReadOnlyReactivePropertySlimがあります。値の変化はSourceからのみ。このほうが自然でしょふ。

UniRx

Unityは元々ほとんどシングルスレッドなので、スレッドセーフじゃなくても概ね問題はないし、ゴミにたいして敏感な環境でもあるので、むしろReactiveProeprty(無印)をSlim版に変えたいと思ってます(今の無印版の命名をどうしようか問題はある、どうしよう)。が、破壊的変更になるので、どうしようか……。でも明らかにSlimのほうがいいし、デフォで使ってもらうべきなので、まぁ、多分、変えます。近いうち(ほんとか?)に。

あとToReactivePropertyでReadOnlyReactivePropertyを返すようにするかも。前述のように普通のToReactivePropertyがなくなるので、そっちのほうが自然にまとまった感じでいいんじゃないかなー、とか。

ところでちなみに.NET版のReactivePropertyよりもUniRxのReactivePropertyのほうが元々スリムなので、冒頭のベンチマークほどのOnNext(Valueへの代入)の性能差は出ませんので、そこは安心してください。

まとめ

パフォーマンス向上の原則はオブジェクトを作らない!オブジェクトを作らないためには、機能を一つのクラスに詰める!機能は分けない!まぁ、分けないことによって使い勝手が悪くなるのは最悪なので、パブリックAPIは適切な分割と集約をきっちり意識して、プライベートAPIは、性能を意識して、あえて統合する、というのもアリ(必ずしも性能が最重要案件ではないというか、最適化は後回しでいい場合が多いので、別に全てをそうしろとはいいませんよ)。ということで。

ReactivePropertySlimはSlimの名前の通り、小さくはあるんですが、大きく作るよりも、小さく作るほうが存外難しく、そして価値あるものです。実装自体は見た通り簡単なもので、別に複雑なアルゴリズムやコーディングが入っているわけでもないですが、アイディアが大事ということで。言われてみれば、そうですねー、っていう話ではあるのだけれど、そこに気づいて実装まで回せるかというのは全然難易度が違うんですよね。ともあれ、中々良い仕上がりになったと思うので、是非試してみてください(機能的には無印と一緒ですが!)。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive