観測可能なコレクションの設計考察

追記: 現在は推奨していません。

WPF 用のコレクションを設計しようとしてぐだぐだ考えた話をします。うまくいかない設計に基づく実装をけっこう書いてしまった ので、その供養でもあります。

背景: WPF のリストボックス

WPF とは、Windows で動くネイティブ GUI アプリケーションを作成するためのフレームワークの1つです。

WPF ではリストボックスのように複数の項目を含むコントロールを表示する際、バインディングという機能を用いて、次のように実装します。まず ObservableCollection のインスタンスを用意します。これはある機能を持つ、動的なリストです。要素は普通の文字列やオブジェクトでOK。このコレクションを (Binding オブジェクトでラップして) リストボックスの ItemsSource プロパティに設定すると、リストボックスに a, b, c という3つの項目が表示されます。興味深いのはここからで、ObservableCollection に要素を追加・削除すると、リストボックスの対応する項目も連動して追加・削除されるのです。

WPF のリストボックスなどにバインドできるコレクションはなんでもいいですが、項目の連動機能を利用するには一定の条件をクリアする必要があります。その条件を満たすコレクションを、ここでは 観測可能なコレクション と呼ぶことにします。例に挙げた ObservableCollection は観測可能なコレクションの代表例です。

要素の連動をどうやっているか簡単に説明すると、まず観測可能なコレクションに要素が追加・削除されるたび、それが実装する INotifyCollectionChanged インターフェイスの CollectionChanged イベントが発生します。WPF はこれを購読していて、イベントの内容 (要素 x が i 番目に追加された、など) に合わせてリストボックスを操作します。

マルチスレッド問題

WPF は 他の GUI ライブラリーと同じように UI スレッドパターンを利用していて、すなわち UI スレッドと呼ばれる単一のスレッドからしか UI 要素を操作できません。

さて、観測可能なコレクションに変更操作が加わるたび、イベントが発生して、WPF がリストボックスの項目を追加・削除するわけですが、イベントの発生とリストボックスの操作は同一のスレッドで行われるようです (※デフォルトの場合)。先述の通り WPF がリストボックスを触れるのは UI スレッドだけなので、観測可能なコレクションの CollectionChanged イベントも UI スレッドで起こさなければいけません。

すべての処理を UI スレッドで行えば話は簡単なのですが、そうは問屋が卸しません。時間のかかる処理を UI スレッド上で実行すると、その間 UI 要素が応答しなくなってしまいます。そのため、時間のかかる処理は非 UI スレッド上で実行するのが普通です。

そういうわけで、WPF アプリケーションではたいてい2つ以上のスレッドが走ります。問題は、複数のスレッドから単一のオブジェクトを操作すると恐怖の競合状態が発生することです。例えば、観測可能なコレクションがスレッド安全でない場合、非 UI スレッドでそれを変更するのと同時に UI スレッドからアクセスがあったとしたら、そこで競合状態になります。

ここに3つの選択肢があります。

  • (A) 観測可能なコレクションの操作はすべて UI スレッドで行う。
  • (B) 観測可能なコレクションをスレッド安全にする。
  • (C) スレッド安全ではないが競合は起きない方法をとる。(後述)

(A) を選べば万事解決ですが、人間はめんどうくさがるものです。コレクションを操作するのにいちいち UI スレッドへのディスパッチを行うのはめんどうです。(C) は思いつきもしなかったので、筆者は (B) を選ぼうとしました。

ここからが本題です。

(B)案: スレッド安全な観測可能コレクション

観測可能なコレクションをスレッド安全にする方法は、筆者が思いつくかぎりでは4つほどあります。

方法1: スレッドによる所有

観測可能なコレクションの代表例である ObservableCollection は、スレッド安全なコレクションです (※)。どのくらい安全かというと、非UIスレッドから操作すると例外が送出される のです。実にあんしんです。

これすなわち、変更操作をするたびに UI スレッドへのディスパッチが必要ということです。さきほどの (A) 案と一緒ですね。

※ここで、コレクションがスレッド安全であるとは、それに対して少なくとも1つの変更操作を含む複数の操作が同時に実行されたとしても競合状態を引き起こさないこと、と定義しておきます。ObservableCollection は1つのスレッドからしか触れませんから、競合状態を引き起こすことはありません。ゆえにスレッド安全となります。

方法2: 排他ロック

スレッド安全性といえば排他ロックでしょう。排他ロックそのものの説明は割愛します。

初め、筆者はこの方針で観測可能なコレクションを実装したのですが、スレッド安全性の保証で行き詰まりました。というのも、外部に晒したコレクションをロックで守るのがそもそも不可能だったのです。

例えば、 Enumerable.ToArray 拡張メソッドの実装はだいたいこんな感じになっているはずです:

public X[] ToArray<X>(IEnumerable<X> xs)
{
    var list = xs as IList<X>;
    if (list != null)
    {
        var array = new X[list.Count];
        list.CopyTo(array, 0);
        return array;
    }
    // 以下略
}

こういう「リストの Count を参照した後、その値を利用して別の操作をする」みたいな処理では、Count から後続の処理までの間に要素数が変わらないようにする必要がありますが、ToArray がロックをとってくれていないのでそれは不可能です。

書いたコードはお焚きあげに出しました。 R.I.P.

方法3. コレクション系インターフェイスを実装しない

先ほどの ToArray 問題の原因は、IList<_> などのコレクション系インターフェイスが並行プログラミングをサポートしていないことにあります。そこで、それらのインターフェイスを捨てて、IEnumerable<_> だけを実装するようにすれば安全です。

GetEnumerator の非効率性

この選択肢の利点と欠点を考えましょう。1つ目の欠点は、LINQ to IEnumerable が使えなくなることです。というのも、先述の Enumerable.ToArray は、渡されたシーケンス (IEnumerable) の実体の型がコレクションかどうかで条件分岐しています。コレクションでなければ Count などを参照できないので、GetEnumerator だけを使って処理を行います。一般的にシーケンスに対する GetEnumerator だけを使う実装は、著しく非効率な場合があります。例えば Enumerable.Count (シーケンスの長さを取得する) のように、コレクションであれば一瞬で実行できるはずの操作にも要素の数だけ時間がかかることになります。

しかしこの欠点にはいちおうの対策があります。というのも、拡張メソッドはオーバーライドできませんが、シャドーイングはできるからです。そのため、Enumerable.Count のように効率の悪くなるメソッドに対して観測可能なコレクション用に特化したメソッドを用意しておくことで、ある程度の問題は解決します。

// 読み取り専用の観測可能なコレクションを表すインターフェイスを定義しておく。
public interface IReadOnlyObservableList<T>
    : IEnumerable<T>
{
    Task<int> CountAsync();

    // ...
}

// 観測可能なコレクションに特化した拡張メソッドを定義しておく。
public static ObservableListExtension
{
    public int Count<X>(this IReadOnlyObservableList<X> @this)
    {
        return @this.CountAsync().Result;
    }
}

// 効率のよい方のメソッドが使われる。
new MyObservableList<int>().Count();

対象は Count(), Any(), ElementAt(int), Last() です。このくらいなら実装量的にも大丈夫でしょう。

もちろん WPF は特化版のメソッドを使ってはくれませんが、WPF がコレクションの要素数を取得したりインデックスでアクセスしたりする場面が思いつかないので (あったら教えてください)、ひとまず問題なしとします。

スレッド安全性の確保

標準のコレクション系インターフェイスを実装しないだけでは、まだスレッド安全になりません。UI スレッドから GetEnumerator が起動されることになるので、GetEnumerator と他の操作が同時に起動されても大丈夫なようにする (あるいは同時には起動されないようにする) 必要があります。

これは比較的簡単にできます。例えば排他ロックを用いて、GetEnumerator を「ロックの中でリスト全体のコピーを作り、そのコピーの列挙子を返す」ようにする、などです。これは次のような実装になります。

    // コレクションの要素を入れておくリスト
    readonly List<T> list;

    // ロックオブジェクト
    readonly object gate;

    public X Invoke<X>(Func<X> f)
    {
        lock (gate)
        {
            return f();
        }
    }

    public IEnumerator<T> GetEnumerator()
    {
        return Invoke(() => list.ToList()).GetEnumerator();
    }

考えられる方法としては:

  • 排他ロック & コピー
  • 単一スレッドへのディスパッチ & コピー
  • 不変オブジェクト

などがあります。詳細は割愛。

コレクション操作のインターフェイス

標準のコレクション系インターフェイスを実装しないということは、コレクションに対する挿入や除去の操作を独自に提供する必要があります。

単純に IList<_> と同じインターフェイスを提供するのが自然です。

スレッド安全性の保証に単一スレッドへのディスパッチを用いるのであれば、返り値を Task として返すような非同期操作を持たせるのもありでしょう。

まとめ

  • 利点: スレッド安全性を獲得できる。
  • 欠点: 外法。

方法4. 単一スレッドへのディスパッチ

スレッド安全なコレクションを考える話に戻ります。

次の方法は、コレクションへの変更を書き込む処理と、コレクションからデータを読み取る処理をすべて単一のスレッドで行う、というものです。すなわち、ある単一のスレッドではコレクションを普通に操作する代わり、それとは別のスレッドから操作しようとしたら、その単一のスレッドにコレクションへの操作を「依頼」する、ということです。ちなみに他のスレッドに処理を依頼することをディスパッチするというみたいです。

この方法ではディスパッチ処理をコレクションの内部 (Add メソッドなど) で行うので、使う側はめんどくさくありません。

スレッドの切り替えには、標準にある System.Threading.SynchronizationContext を使えばよいでしょう。WPF にある Dispatcher を使うという手もありますが、これは高機能すぎますし、sealed class なのでテストダブルが作れないという問題と、ライブラリーに対する依存関係が深くなる問題があるので、ひとまず置いておきます。

public class ObservableList<T>
{
    readonly SynchronizationContext context =
        SynchronizationContext.Current;

    readonly List<T> list;

    public void Add(T value)
    {
        context.Send(state =>
        {
            // ここは UI スレッドで行われる。
            list.Add(value);
        }, null);
    }
}

この方法では、先述の ToArray 問題が解決できません。

(C)案: UI/非UIスレッド間のスレッド安全性保証

コレクション自体をスレッド安全にするのではなく、ユーザーと WPF からの同時アクセスだけを防ぐ方針もありえます。その2つがどう違うのかというと、後者の場合は例えばユーザーが2つの非UIスレッドを作って、それらから単一のコレクションを同時に操作したときに、競合状態が起きうるということです。ただし、ユーザーがコレクションを操作しているのと同時に WPF (UI スレッド) がコレクションにアクセスしても、競合状態は起こりません。

そもそもの目的を思い出すと、たしかにユーザーサイドでの同時アクセスまで面倒をみる必要はないように思えます。

EnableCollectionSynchronization

実際にどうやるかというと、WPF が提供している BindingOperations.EnableCollectionSynchronization メソッドを使います。このメソッドをあらかじめコレクションに適用しておくことで、観測可能なコレクションを非UIスレッドから更新しても安全になるそうです。

所有スレッドの変更

これは筆者の経験に基づく憶測なのですが、ビューモデルの一生は2ステップに分かれます。1つはビューモデルのコンストラクターの内部で、データベースなどから取ってきた値にもとづいてコレクションなどに初期値を入れていく過程です。2つ目は、ビューモデルを UI 要素のプロパティにバインドして、ユーザーの操作のフィードバックを受ける過程です。

ビューモデルの生成をUIスレッドでやるか非UIスレッドでやるかという考察は、それだけで記事が1つ書けるぐらいの内容だと思いますが、ここでは非UIスレッドでやると仮定します。その場合、観測可能なコレクションが生成されるのは非UIスレッドです。1つ目の過程 (初期値を入れていく) は、まだ UI 要素にバインドされていないので、非UIスレッドで行っても問題ありません。しかし2つ目の過程 (ユーザーの操作のフィードバックを受ける) は、もちろん UI スレッドで行う必要があります。

観測可能なコレクションを所有するスレッドを動的に変更するのはどうか、というアイディアです。すなわち、第1過程では生成スレッド (非UIスレッド) に所有され、第2過程に移った段階で UI スレッドに所有されるようにする、ということです。

スレッドの所有権を明示的にプログラムの中で表現しようとすると、委譲処理をどうやって起動するかが問題になります。委譲を行うタイミングは UI 要素にバインドされた瞬間ですが、これをフックする方法が分かりません。この方法は もうめんどくさいのでかんがえたくない さらなる検討の余地がありそうです。

スレッドの所有権をプログラムで表現しない、すなわち「オブジェクトが特に制御しなくても操作元のスレッドが常に1つに限定されるように努力する」というのも1つの方法です。先述の2過程仮説が正しければ、次のような制約でコーディングすれば競合は回避できます: 観測可能コレクションは、それを所有するオブジェクトのコンストラクター、または UI スレッドでのみ操作できる。

これはけっこう現実的で、例えば ReactiveProperty が提供する観測可能なコレクションである ReactiveCollection は、そういう感じの使い方を想定しているようにみえます (※個人の感想です)。

結論

  • ReactiveProperty をインストールしているなら、ReactiveCollection を使おう。
  • .NET 4.5 以上なら、EnableCollectionSynchronization を使おう。

本稿では、ぐだぐだな考察と考慮漏れだらけの迷走を経て、ReactiveCollection がなぜ安全に運用できているのかに対する納得できる仮説を得られた。

参考文献

関連記事