Memory retention issue with WeakEventManager and Destructor

334 views Asked by At

I'm encountering a memory retention problem related to the usage of WeakEventManager. After profiling my application using DotMemoryProfiler, I discovered that whenever I add a handler using WeakEventManager.AddHandler, it results in adding an instance to the ConditionalWeakTable<object, object>, and the entry is retained unless the handler is removed manually (even removing in destructor won’t work, you got to have a function to remove it explicitly).

From the code below, you can see that calling the Detach method manually vs calling it via the destructor makes a difference. When it is called manually, the ConditionalWeakTable is properly collected and marked as "Dead" in the DotMemoryProfiler snapshot. However, when it is called from the destructor (~Subscriber), the ConditionalWeakTable survives. Note that the snapshot is taken after Detach is executed (as the sequence is printed in the console). I also ensure that it is run in Release Mode with "Suppress JIT optimization" option unticked.

Here's a comparison of DotMemory snapshots showing the survived object:

enter image description here

You might think that the surviving bytes aren’t many, but it is causing an issue in our real application because we extensively use WeakEventManager and unsubscribe it via destructor, resulting in the retention of a substantial amount of memory (more than 12MB). Here's the screenshot showing an example that it can retain such a huge amount of memory in our real application.

enter image description here

To provide more context, here's the relevant code snippet:

class Program
{
    static void Main()
    {
        var isolator = new Action(() => { 
            var publisher = new Publisher();
            var subscriber = new Subscriber();

            subscriber.Init(publisher);
            MemoryProfiler.GetSnapshot();

            //subscriber.Detach(publisher); // detach it manually won't cause issue
      });
            isolator();

        for (int i = 0; i < 10; i++)
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }

        MemoryProfiler.GetSnapshot();
        Console.WriteLine("2nd snapshot captured");

    }

    private static void EventHandlerMethod(object sender, EventArgs e) { }
}

public class Publisher : INotifyPropertyChanged
{
    private string name;
    public string Name
    {
        get => name;
        set
        {
            name = value;
            OnPropertyChanged(nameof(Name));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(string propertyName) => 
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

public class Subscriber : IWeakEventListener
{
    Publisher _publisher;
    public void Init(Publisher publisher)
    {
        _publisher = publisher;
        WeakEventManager<Publisher, PropertyChangedEventArgs>.AddHandler(publisher, "PropertyChanged", OnPublisherPropertyChanged);
    }

    public void Detach(Publisher publisher)
    {
        WeakEventManager<Publisher, PropertyChangedEventArgs>.RemoveHandler(publisher, "PropertyChanged", OnPublisherPropertyChanged);
        Console.WriteLine("Detach called");
    }

    ~Subscriber()
    {
        Console.WriteLine("Destructor called");
        Detach(_publisher);
    }

    private void OnPublisherPropertyChanged(object sender, PropertyChangedEventArgs e) { }

    public bool ReceiveWeakEvent(Type managerType, object sender, EventArgs e)
    {
        return true;
    }
}

This is the Console output for both scenarios:

  • Call Detach via destructor
    Destructor called
    Detach called
    2nd snapshot captured
  • Disable destructor and call Detach manually
    Detach called
    2nd snapshot captured

Note that in both cases, the Detach method is called. YES, it is called! But why the RemoveHandler doesn’t work if we call it from the destructor?

My questions:

  1. Why does the ConditionalWeakTable retain memory even after removing event handlers/GC when it is called via the destructor?
  2. How to workaround to clear it effectively?
1

There are 1 answers

0
Nikolay On

WeakEventManager was designed to work in an exclusive thread. For example, to register handlers in a UI thread.

Internal implementation of this class will create a new instance for each managed thread (due to static field marked with ThreadStatic attribute).

Therefore, to work properly with WeakEventManager, it is necessary to ensure that each invocation to the methods of this class will come from the same thread.

The easiest way to do this is to use an instance of Dispatcher class, which will be run in a dedicated thread (just like we do in any UI application when we want to synchronize with a UI thread).

ThreadPool.QueueUserWorkItem(_ =>
{
    _main = Dispatcher.CurrentDispatcher;
    Dispatcher.Run();
});

After that, we can synchronize with this thread anywhere in the code

_main.Invoke(() =>
{
    var publisher = new Publisher();
    var subscriber = new Subscriber();
    subscriber.Init(publisher);
    Console.WriteLine("isolator completed";)
});

With this usage, there is no need to unsubscribe from the event in the destructor, because the internal method DoCleanup will do it for us, which scans all added handlers wrapped with WeakReference.

Your case. In the code there is a subscription to an event in thread 1, but it is unsubscribed in thread 2. That is, you are subscribing to another instance of WeakEventManager, but this is half of the problem. The main problem is that you do not have a dispatcher running for the thread in which you use WeakEventManager. Starting the removal of handlers that have been collected by GC is done through the dispatcher, without the dispatcher running you will get a memory leak. The process is initiated either after adding a new listener or if no valid handler is found when the event is triggered