C#
WPF

弱イベントはマルチスレッドで使ってはいけない

初めに

WPFでアプリケーションを作成していると、弱いイベントパターンを利用する機会があると思います。弱いイベントパターン自体の詳細な解説は行いませんが、利用していてハマったので覚書を残しておきます。
弱いイベント パターン
https://msdn.microsoft.com/ja-jp/library/aa970850.aspx

Sample作成

新規でWPFプロジェクトを作成し、Window上にボタンを一つ配置。ボタンのイベントハンドラを作成したところから始めます。

Listener

IWeakEventListenerインターフェースを実装しているクラスを作成し、リスナーとします。イベントが発生するとReceiveWeakEventメソッドが呼び出されます。

class Listener : IWeakEventListener
{
    public string Name { get; set; }

    public bool ReceiveWeakEvent(Type managerType, object sender, EventArgs e)
    {
        Console.WriteLine($"{Name} invoke");
        return true;
    }
}

通常パターン

ここでは、INotifyCollectionChangedイベントを弱イベントパターンで利用してみます。CollectionChangedEventManagerクラスのAddListenerメソッドでイベントを購読し、RemoveListenerメソッドで購読を解除。通常のイベントとほぼ変わらない使い方ができているように見えます。

private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
    var collection = new ObservableCollection<int>();

    var listener1 = new Listener { Name = "aaa" };
    var listener2 = new Listener { Name = "bbb" };

    CollectionChangedEventManager.AddListener(collection, listener1);
    CollectionChangedEventManager.AddListener(collection, listener2);

    Console.WriteLine("--Add 1");
    collection.Add(1);

    CollectionChangedEventManager.RemoveListener(collection, listener2);

    Console.WriteLine("--Add 2");
    collection.Add(2);
}

Add1でリスナが2つとも登録されていることが確認できます。
また、Add2でリスナを正しく削除できてることがわかります。

出力結果
--Add 1
aaa invoke
bbb invoke
--Add 2
aaa invoke

スレッドをまたぐ使い方

で、このRemoveListenerをAddListenerを行ったのと異なるスレッドから実行してみます。

private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
    var collection = new ObservableCollection<int>();

    var listener1 = new Listener { Name = "aaa" };
    var listener2 = new Listener { Name = "bbb" };
    var listener3 = new Listener { Name = "ccc" };

    Task.Run(() => CollectionChangedEventManager.AddListener(collection, listener1)).Wait();
    CollectionChangedEventManager.AddListener(collection, listener2);
    Task.Run(() => CollectionChangedEventManager.AddListener(collection, listener3)).Wait();

    Console.WriteLine("--Add 1");
    collection.Add(1);

    // 非UIスレッドでAddListenerしたものをUIスレッドでRemoveListener → 削除されない
    CollectionChangedEventManager.RemoveListener(collection, listener1);
    // UIスレッドでAddListenerしたものを非UIスレッドでRemoveListener → 削除されない
    Task.Run(() => CollectionChangedEventManager.RemoveListener(collection, listener2)).Wait();
    // 非UIスレッドでAddListenerしたものを非UIスレッドでRemoveListener → 削除されるかもしれないしされないかもしれない
    Task.Run(() => CollectionChangedEventManager.RemoveListener(collection, listener3)).Wait();

    Console.WriteLine("--Add 2");
    collection.Add(2);
}

ソースコード内にもコメントで書きましたが、listener3が削除される場合とされない場合があります。Taskクラスは裏側でスレッドプールを利用しているので、たまたま同じスレッドで実行された場合は削除されるし、そうではない場合は削除されないのだと推測できます。

出力結果1
--Add 1
aaa invoke
bbb invoke
ccc invoke
--Add 2
aaa invoke
bbb invoke
ccc invoke
出力結果2
--Add 1
aaa invoke
bbb invoke
ccc invoke
--Add 2
aaa invoke
bbb invoke

起きていたこと

実際にハマった不具合では以下のようになっていました。
これは大分簡易にしたものなので直接AddListenerを行っていますが、元はParallel.ForEachの内部で生成されているクラスの一部が内部的に弱いイベントを利用しているクラスに依存しているという状態でした。

private void ButtonBase_OnClick(object sender, RoutedEventArgs e)
{
    var collection = new ObservableCollection<int>();

    var listener1 = new Listener { Name = "aaa" };
    var listener2 = new Listener { Name = "bbb" };
    var listener3 = new Listener { Name = "ccc" };

    var listeners = new[] { listener1, listener2, listener3 };

    Parallel.ForEach(listeners, listener => CollectionChangedEventManager.AddListener(collection, listener));

    Console.WriteLine("--Add 1");
    collection.Add(1);

    Parallel.ForEach(listeners, listener => CollectionChangedEventManager.RemoveListener(collection, listener));

    Console.WriteLine("--Add 2");
    collection.Add(2);
}

この場合、どのイベントリスナが削除されるか?というのは、神のみぞ知るという。そして、確実にイベントを削除する方法はありません。

まとめ

WeakEventManager.AddListenerおよびRemoveListenerは必ず同期Contextから呼び出しましょう。