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 が受け取れないこと」にある。

実測: バインドの有無で結果が変わる

この違いは、バインドしていない ObservableCollection<T> を同じ手順で操作してみると確認できる。
バインドの有無と対処の有無を変えて、バックグラウンドスレッドから Add を呼んだ結果が次の表である。

バックグラウンドスレッドから Add を呼んだ結果の表。バインドしていない ObservableCollection では例外が発生しない。ItemsControl にバインドすると NotSupportedException になる。Dispatcher.Invoke と EnableCollectionSynchronization ではいずれも例外が発生しない。通知時にロックを保持していたのは EnableCollectionSynchronization の行だけである。
.NET 10 / Windows 11 で、Task.Run の中から ObservableCollection<string>.Add を呼んだ結果。1 行目はどこにもバインドしていないコレクション、2 行目以降は ItemsControl.ItemsSource にバインドしたうえでウィンドウに表示したコレクションである。最終列は、CollectionChanged が通知された時点で Monitor.IsEntered がロックの保持を報告した回数と、通知の総数である。

バインドしていないコレクションは、バックグラウンドスレッドから変更しても例外にならない。
ObservableCollection<T> 自体がスレッドアフィニティを持つのであれば、この行も例外になるはずである。
例外が出るのは ItemsControl.ItemsSource にバインドして CollectionView が介在したときだけであり、原因が CollectionView の側にあることがここで確認できる。

なお、バインドしていないコレクションで例外が出ないことは、スレッドセーフであることを意味しない
ObservableCollection<T> は複数スレッドからの同時アクセスに対する保護を持たないため、競合する更新を行えば別の形で壊れる。
ここで確認できるのは、あくまで「NotSupportedException の発生源はバインド先である」という点だけである。

最終列は、CollectionChanged が通知された時点でロックが保持されていたかを示す。
EnableCollectionSynchronization を使った行だけが 1/1 であり、変更と通知が同じロックの中で起きている。
他の行は 0/1 で、通知はロックの外で行われている。

この 1/1 は、アプリケーション側が lockAdd を囲んだ構成での結果である。
EnableCollectionSynchronization に渡したロックが、その Add から生じる通知まで保持されたままであることを示している。
登録するだけで通知がロック内に入るわけではない。Add を同じロックで囲むのはアプリケーション側の責務である。


2 つの基本方式

アプローチは 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 スレッドで、かつコレクションを別スレッドで使う前(またはコントロールへ結び付ける前)に行う必要がある。


選択の分岐点

基本となる方式は上の 2 つで、派生と回避策を含めると 4 通りある。どれを使うかは更新の量と頻度で決まる。

更新が散発的で件数が少ないなら Dispatcher
追加設定が要らず、既存コードの変更箇所も小さい。要素ごとに UI スレッドへ往復するコストは、件数が少なければ問題にならない。

別スレッドで大量・高頻度に更新するなら EnableCollectionSynchronization
要素単位の同期 Invoke を大量に回すと UI スレッドが飽和して応答性が落ちる。ロックを共有すれば、バックグラウンドから直接変更してもその往復が発生しない。

セマフォなど独自の同期機構が既にあるなら、コールバック版のオーバーロード。
ロック以外の同期機構を WPF 側の待ちに使わせられる。実装は最も複雑になる。

収集後に一括で反映できる処理なら、UI スレッドでまとめて反映する。
そもそもスレッドをまたいだ変更を発生させない選択である。バックグラウンドで処理を進める利点は薄れるが、同期の設計を持ち込まずに済む。


方法別の比較

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

注意点


まとめ

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

分岐点は更新の量と頻度にある。散発的なら Dispatcher で UI スレッドへ寄せ、大量・高頻度なら EnableCollectionSynchronization でロックを共有する。
いずれの場合も、UI 要素そのものは UI スレッド専有のままである。緩めるのはコレクションアクセスに限る、という前提で設計する。