WPF で ObservableCollection をバックグラウンドスレッドから更新するとクロススレッド例外が発生する問題の解決方法

バインド中の ObservableCollection を別スレッドから変更すると NotSupportedException が発生する。原因のスレッドアフィニティと、EnableCollectionSynchronization・Dispatcher による解決策を整理する。

概要

WPF で ItemsControl にバインドした ObservableCollection<T> を、UI スレッド以外のスレッドから変更すると、既定では NotSupportedException が発生する(後述の BindingOperations.EnableCollectionSynchronization を登録するとこの制約は解除される)。
メッセージは「この種類の CollectionView では、Dispatcher スレッドと異なるスレッドからの SourceCollection への変更はサポートされていません」といった内容になる(文言は .NET のバージョンやロケールで多少異なる)。
本記事では、この例外がコレクション自体ではなく CollectionView のスレッドアフィニティに起因することを説明し、BindingOperations.EnableCollectionSynchronizationDispatcher を使った解決方法、および両者の使い分けの基準を整理する。


前提・対象環境


問題

バインド済みのコレクションを、バックグラウンドスレッドから直接変更すると例外が送出される。
以下は、Task.Run で開始した処理の中から ObservableCollection<T> に要素を追加する例である。
データ供給元には System.IOFile.ReadLines を用いており、ファイルの各行を遅延列挙する。
本記事のスニペットは ViewModel クラスのメンバーであり、名前空間 System.Collections.ObjectModel / System.IO / System.Threading.Tasks / System.Windows / System.Windows.Data を前提とする。

public ObservableCollection<string> Items { get; } = new();

private async Task LoadAsync(string path)
{
    await Task.Run(() =>
    {
        foreach (var line in File.ReadLines(path))
        {
            // UI スレッド以外からの Add で NotSupportedException が発生する
            Items.Add(line);
        }
    });
}

ItemsItemsControl.ItemsSource にバインドされている場合、Add の呼び出しは CollectionChanged 通知を通じて CollectionView に伝わる。
この通知が UI スレッド以外から届くため、CollectionView が例外を送出する。

スレッド間のデータの流れを 3 段で比較した図。1 段目はバックグラウンドスレッドからの CollectionChanged 通知が CollectionView に直接届いて例外になる。2 段目は EnableCollectionSynchronization を登録した場合で、変更はバックグラウンドスレッドのまま行われ、通知はキューされて UI スレッドの shadow copy に非同期で反映される。3 段目は Dispatcher でコレクション操作自体を UI スレッドへ移す。
例外が起きるのはコレクションを触った瞬間ではなく、変更通知が CollectionView に届いた時点である。2 つの解決策は仕組みが異なる。EnableCollectionSynchronization はコレクションへのアクセスを同じ同期機構に参加させたうえで、CollectionView が持つ shadow copy へ通知を非同期に反映する(変更操作はバックグラウンドスレッドのままでよい)。Dispatcher はコレクション操作そのものを UI スレッドへマーシャリングする。

原因・背景

ObservableCollection<T> 自体はスレッドセーフではないが、それとは別の話として、この NotSupportedException の直接原因は WPF がコレクションを表示する際に経由する CollectionView にある。
公式ドキュメントは、ItemsControlCollectionView の両方が「ItemsControl を生成したスレッドへのアフィニティ(親和性)を持ち、異なるスレッドからの使用は禁止され、例外が送出される」と明記している。
この制約は事実上、バインド対象のコレクションにも及ぶ。

WPF のオブジェクトの多くは DispatcherObject から派生し、生成元スレッド(通常は UI スレッド)へのスレッドアフィニティを持つ。
CollectionViewDispatcherObject から派生し、既定ではバインド対象コレクションが別スレッドから変更されることを許可しない。
そのため、CollectionChanged 通知が UI スレッド以外から届くと、CollectionView はクロススレッドの変更を許可しないものとして NotSupportedException を送出する。
問題の本質は「コレクションを別スレッドで触ったこと」ではなく、「別スレッドからの変更通知を UI スレッド専有の CollectionView が受け取れないこと」にある。


解決方法

アプローチは 2 つある。

前者は「変更を UI スレッドへ寄せる」方法、後者は「別スレッドでの変更を WPF に安全に取り込ませる」方法である。


実装例

Dispatcher で UI スレッドへマーシャリングする

コレクションの変更を Dispatcher.Invoke(または InvokeAsync)で UI スレッドへ移す。
Application.Current.Dispatcher を用いれば、ViewModel からも UI スレッドの Dispatcher を取得できる。
これは単一 UI スレッド構成を前提とする。複数の UI スレッドを持つアプリでは、Application.Current.Dispatcher はメインスレッドを指し、バインド先の CollectionView を所有するとは限らないため、バインド先の ItemsControl(またはその CollectionView)に紐づく Dispatcher を取得して使う。

private async Task LoadAsync(string path)
{
    var dispatcher = Application.Current.Dispatcher;
    await Task.Run(() =>
    {
        foreach (var line in File.ReadLines(path))
        {
            // Add を UI スレッド上で実行するため例外は起きない
            dispatcher.Invoke(() => Items.Add(line));
        }
    });
}

変更操作が UI スレッドで実行されるため、CollectionView のアフィニティ違反は発生しない。
ただし要素ごとに Invoke すると UI スレッドへの往復が多発するため、まとめて追加できる場合は 1 回の Invoke 内で複数要素を処理するのが適切である。

EnableCollectionSynchronization でロックを共有する

ロックオブジェクトを用意し、UI スレッド上で EnableCollectionSynchronization を呼んで WPF に登録する。
以降はアプリ側の変更も、その同じロックで保護する。

private readonly object _lock = new();
public ObservableCollection<string> Items { get; } = new();

public ViewModel()
{
    // 呼び出しは UI スレッドで、かつコレクションを別スレッドで使う前に行う
    BindingOperations.EnableCollectionSynchronization(Items, _lock);
}

private async Task LoadAsync(string path)
{
    await Task.Run(() =>
    {
        foreach (var line in File.ReadLines(path))
        {
            lock (_lock)
            {
                Items.Add(line);
            }
        }
    });
}

EnableCollectionSynchronization を呼ぶと、CollectionView は登録されたロックを使ってコレクションへアクセスし、UI スレッド用の「シャドウコピー」を保持する。
変更通知は到着順にキューされ、UI スレッドが処理可能なときに反映される。
これによりバックグラウンドスレッドから直接 Add できる。
公式ドキュメントの要件どおり、呼び出しは UI スレッドで、かつコレクションを別スレッドで使う前(またはコントロールへ結び付ける前)に行う必要がある。


注意点


代替案・比較

方法 メリット デメリット 適するケース
Dispatcher.Invoke / InvokeAsync 追加設定が不要で単純。既存コードに適用しやすい 要素ごとの往復で UI スレッドを圧迫しやすい 更新頻度・件数が少ない。散発的な追加・削除
EnableCollectionSynchronization(単純ロック) バックグラウンドから直接変更でき、UI を占有しにくい ロックの一貫運用が必要。設計がやや複雑 大量・高頻度の更新を別スレッドで行う
EnableCollectionSynchronization(コールバック版) セマフォ等、ロック以外の同期機構を使える 実装が最も複雑 独自の同期機構が既にある構成
UI スレッドでまとめて反映 スレッド問題を回避できる バックグラウンドの利点が薄れる 収集後に一括反映できる処理

まとめ

バインド中の ObservableCollection<T> を別スレッドから変更したときの例外は、コレクションではなく CollectionView のスレッドアフィニティに起因する。
解決策の選択基準は次のとおりである。

いずれの場合も、UI 要素そのものは UI スレッド専有のままである点を踏まえ、緩めるのはコレクションアクセスに限る、という前提で設計するのが要点である。