PropertyChangedEventManagerはスレッドプールでは失敗する

## 初めに

ObservableCollectionの要素のプロパティ値の変化を捉える必要が出てきたとき、その方法の一つとして要素のPropertyChangedイベントを捉える方法があります。このためには、イベントハンドラーを直接登録する強参照の方法の他に、[WeakEventManager](https://learn.microsoft.com/en-us/dotnet/api/system.windows.weakeventmanager)を使う弱参照の方法があります。

後者を考える場合、PropertyChanged用には[PropertyChangedEventManager](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.propertychangedeventmanager)が用意されているので、これを使うとして、ObservableCollectionの要素の追加/削除を`BindingOperations.EnableCollectionSynchronization`を使ってバックグラウンドのスレッドプールで行う場合に、`PropertyChangedEventManager`が正常に機能するか、ふと気になったので確認してみました。

同様の問題について@dyonedaさんが`CollectionChangedEventManager`で検証された例があり、結論はAddListenerを実行したスレッドと同じスレッドでなければRemoveListenerは失敗するというものです。

 - [弱イベントはマルチスレッドで使ってはいけない](https://qiita.com/dyoneda/items/4d188b98a8ee066df162)

`PropertyChangedEventManager`も同じWeakEventManagerの派生なので、結論は半ば見えているようなものですが、もやもやは解消しておくに如くはなしです。

## サンプル

`PropertyChangedEventManager`を使うためにIWeakEventListenerを実装したクラスが必要ですが、これは以下のような`PropertyChangedEventListener`としました。PropertyChangedイベントが発生するとReceiveWeakEventが呼ばれ、コンストラクターで指定したActionが実行されます。

```csharp:PropertyChangedEventListener.cs
public class PropertyChangedEventListener<T> : IWeakEventListener
{
    private readonly Action<T, string?> _action;

    public PropertyChangedEventListener(Action<T, string?> action) => _action = action;

    public bool ReceiveWeakEvent(Type managerType, object sender, EventArgs e)
    {
        if (managerType != typeof(PropertyChangedEventManager))
            return false;

        _action.Invoke((T)sender, ((PropertyChangedEventArgs)e).PropertyName);
        return true;
    }
}
```

ObservableCollectionの要素の追加/削除の際には`BindingOperations.EnableCollectionSynchronization`のためのロックが必要なので、AddとRemoveメソッドを加え、これらの中で`PropertyChangedEventManager`のAddListenerとRemoveListenerも行うようにしました。

```csharp:MainWindowViewModel.cs
public partial class MainWindowViewModel : ObservableObject
{
    public ObservableCollection<ItemViewModel> Items { get; } = new();
    private readonly object _lock = new();

    public MainWindowViewModel()
    {
        BindingOperations.EnableCollectionSynchronization(Items, _lock);
    }

    private readonly PropertyChangedEventListener<ItemViewModel> _listener = new(OnReceived);

    private static void OnReceived(ItemViewModel item, string? propertyName)
    {
        switch (propertyName)
        {
            case nameof(ItemViewModel.Name):
                Trace.WriteLine($"Received: {item.Name}");
                break;
        }
    }

    private void Add(ItemViewModel item)
    {
        lock (_lock)
        {
            Items.Add(item);
            PropertyChangedEventManager.AddListener(item, _listener, nameof(ItemViewModel.Name));
        }
    }

    private void Remove(ItemViewModel item)
    {
        lock (_lock)
        {
            Items.Remove(item);
            PropertyChangedEventManager.RemoveListener(item, _listener, nameof(ItemViewModel.Name));
        }
    }
}
```

```csharp:ItemViewModel.cs
public partial class ItemViewModel : ObservableObject
{
	public string? Name
	{
		get => _name;
		set
		{
			if (_name != value)
			{
				Trace.WriteLine($"Changed: {value}");
				SetProperty(ref _name, value);
			}
		}
	}
	private string? _name;
}
```

ObservableCollectionの要素のプロパティ値が変化すると`PropertyChangedEventListener`のActionが呼ばれ、OnReceivedが実行されます。

## テスト

初めのテストは、2つの要素のAddとRemoveをUIスレッドで行うものです。Task.Delayで時間を挟んでいるのはViewで変化が見えるようにするためで、テスト上は必須ではありません。

```csharp
public async Task TestSingleThreadAsync()
{
    Trace.WriteLine("-- Add A & B --");
    var itemA = new ItemViewModel { Name = "A 0" };
    var itemB = new ItemViewModel { Name = "B 0" };
    Add(itemA);
    Add(itemB);

    await Task.Delay(TimeSpan.FromSeconds(1));

    Trace.WriteLine("-- Change names --");
    itemA.Name = "A 1";
    itemB.Name = "B 1";

    await Task.Delay(TimeSpan.FromSeconds(1));

    Trace.WriteLine("-- Remove A and then change names --");
    Remove(itemA);
    itemA.Name = "A 2";
    itemB.Name = "B 2";

    await Task.Delay(TimeSpan.FromSeconds(1));

    Trace.WriteLine("-- Remove B and then change names --");
    Remove(itemB);
    itemA.Name = "A 3";
    itemB.Name = "B 3";
}
```

この結果を見ると、Removeの後にはOnReceivedが実行されていないので、RemoveListenerが正常に機能したことが分かります。

```
-- Add A & B --
Changed: A 0
Changed: B 0
-- Change names --
Changed: A 1
Received: A 1
Changed: B 1
Received: B 1
-- Remove A and then change names --
Changed: A 2
Changed: B 2
Received: B 2
-- Remove B and then change names --
Changed: A 3
Changed: B 3
```

次のテストは、AddとRemoveをスレッドプールで行うものです。

```csharp
public async Task TestMultiThreadAsync()
{
    Trace.WriteLine("-- Add A & B --");
    var itemA = new ItemViewModel { Name = "A 0" };
    var itemB = new ItemViewModel { Name = "B 0" };
    await Task.Run(() => Add(itemA));
    await Task.Run(() => Add(itemB));

    await Task.Delay(TimeSpan.FromSeconds(1));

    Trace.WriteLine("-- Change names --");
    itemA.Name = "A 1";
    itemB.Name = "B 1";

    await Task.Delay(TimeSpan.FromSeconds(1));

    Trace.WriteLine("-- Remove A and then change names --");
    await Task.Run(() => Remove(itemA));
    itemA.Name = "A 2";
    itemB.Name = "B 2";

    await Task.Delay(TimeSpan.FromSeconds(1));

    Trace.WriteLine("-- Remove B and then change names --");
    await Task.Run(() => Remove(itemB));
    itemA.Name = "A 3";
    itemB.Name = "B 3";
}
```

この結果は試行によって変わり、RemoveListenerが2つとも成功した例では上記と変わりません。以下はAの方だけ成功した例で、Removeの後にもBのOnReceivedが実行されています。

```
-- Add A & B --
Changed: A 0
Changed: B 0
-- Change names --
Changed: A 1
Received: A 1
Changed: B 1
Received: B 1
-- Remove A and then change names --
Changed: A 2
Changed: B 2
Received: B 2
-- Remove B and then change names --
Changed: A 3
Changed: B 3
Received: B 3
```

また、以下は2つとも失敗した例で、Removeの後にもAとBのOnReceivedが実行されています。

```
-- Add A & B --
Changed: A 0
Changed: B 0
-- Change names --
Changed: A 1
Received: A 1
Changed: B 1
Received: B 1
-- Remove A and then change names --
Changed: A 2
Received: A 2
Changed: B 2
Received: B 2
-- Remove B and then change names --
Changed: A 3
Received: A 3
Changed: B 3
Received: B 3
```

これらの違いはスレッドプールでAddListenerのスレッドとRemoveListenerのスレッドがたまたま同じになったときには成功し、違うスレッドになったときは失敗すると見なしてよさそうです。

サンプルプロジェクトは以下。

[emoacht/WpfWeakEventManagerTest](https://github.com/emoacht/WpfWeakEventManagerTest)

## まとめ

スレッドプールではRemoveListenerが失敗するので（成功することもある）、`PropertyChangedEventManager`は実用的に使えないことが確認できました。