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

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

概要

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


前提・対象環境


問題

バインド済みのコレクションを、バックグラウンドスレッドから直接変更すると例外が送出される。
以下は、Task.Run で開始した処理の中から ObservableCollection<T> に要素を追加する例である。
データ供給元には System.IO の File.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);
        }
    });
}

Items が ItemsControl.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 にある。
公式ドキュメントは、ItemsControl と CollectionView の両方が「ItemsControl を生成したスレッドへのアフィニティ(親和性)を持ち、異なるスレッドからの使用は禁止され、例外が送出される」と明記している。
この制約は事実上、バインド対象のコレクションにも及ぶ。

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

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

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

コレクション 対策 結果 Count / Items.Count / ビューへの通知の数
ObservableCollection だけ - 例外なし 1
ItemsControl にバインド - NotSupportedException 1 / 1 / 0
ItemsControl にバインド Dispatcher.Invoke 例外なし 1 / 1 / 1
ItemsControl にバインド EnableCollectionSynchronization 例外なし 1 / 1 / 1

.NET 10 / Windows 11 で、Task.Run の中から ObservableCollection<string>.Add を呼んだ結果。1 行目はどこにもバインドしていないコレクション、2 行目以降は ItemsControl.ItemsSource にバインドしたうえでウィンドウに表示したコレクションである。最終列は、Add のあとのコレクションの Count、ItemsControl の Items.Count、ビュー(Items)が受け取った CollectionChanged の通知の数である。

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

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

最終列は、Add のあとのコレクションの件数、ItemsControl の Items.Count、ビューが受け取った変更通知の数である。
1 件の Add では、2 つの件数はどの行も 1 だった。ただし Items.Count が一致しても、変更がビューに届いたことにはならない。同期のないビューは、件数を元のリストから読むことがあるためである。NotSupportedException になった行ではビューは通知を 1 つも受け取っておらず、Dispatcher.Invoke と EnableCollectionSynchronization では 1 つ受け取った。
EnableCollectionSynchronization の行は、アプリケーション側が登録したのと同じロックで Add を囲んだ構成で測っている。登録するだけで 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 は、1 件ごとに UI スレッドでの実行を待つ。ロックを共有すれば、バックグラウンドから直接変更でき、その往復が発生しない。ただし、UI スレッドがロックを持っている間は、バックグラウンド側が待つ。
5,000 件を 1 件ずつ Add すると、Dispatcher.Invoke ではループに 992 ms かかった。
EnableCollectionSynchronization ではループが 6 ms で終わり、ビューが全件分の通知を受け取るまでで 64 ms だった。
ただし、ループのあと UI スレッドへ戻った時点でビューが受け取っていた通知は 746 件で、残りは後から UI スレッドで反映された。この値は await のあとに読んだもので、ループを抜けた瞬間の値ではない。その間にも UI スレッドは通知を処理できる。反映そのものは UI スレッドの仕事として残る。

対策 バックグラウンドのループ(ms) await の後のビューへの通知の数 すべて通知されるまで(ms)
1 件ごとに Dispatcher.Invoke 992 5,000 994
EnableCollectionSynchronization + lock 6 746 64

.NET 10 / Windows 11 で、仮想化した ListBox にバインドしたコレクションへ、Task.Run の中から 5,000 件を 1 件ずつ Add した結果。3 列目は、ループのあと UI スレッドへ戻った時点でビューが受け取っていた CollectionChanged の通知の数、右端の列は、Task.Run を呼ぶ直前(タスクが始まるまでの待ちを含む)から、ビューが 5,000 件分の通知を受け取るまでの時間である。

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

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


方法別の比較

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

注意点


まとめ

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

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