Click here to Skip to main content
14,493,585 members

Self-Cleanable Collection and Self-Cleanable Event

Rate this:
5.00 (6 votes)
Please Sign up or sign in to vote.
5.00 (6 votes)
27 Jan 2020CPOL
Use of System.WeakReference for self-cleanable registration of (short-life) objects

Introduction

In a classic memory leak pattern in .NET, you register a short-life object in a long-life one and cannot (or forget to) unregister it before its life cycle is about to end. Here, I use weak references to implement a self-cleanable collection and a self-cleanable event, so as to resolve this issue (and to eventually create some others 😊).

Overview

Weak reference in .NET (just in case) is a class that wraps a reference to an object that you gullibly entrusted to it, but doesn't increase the reference counter of the wrapped instance. If at some later point, you would like to get the wrapped object the result depends upon, whether this object is still referenced (in the strong meaning) somewhere else in your application or not. In the former case, you just get your sweetheart back, in the latter one, it is not sure. If it has been garbage-collected in the meantime, you just get nothing. "Sorry, it is gone". In this way, using weak references may bring some non-determinism into your program.

With this in mind, I have implemented a collection (an enumerable, to be accurate) where I can store references without impacting their reference counters:

public class SelfCleanableCollection<T> : IEnumerable<T> where T class
{ 
    public void Add(T obj){...}
    public void Add(IEnumerable<T> objs){...}
    public void Clear(){...}
    public void RemoveFirst(T obj){...}
    public void RemoveLast(T obj){...} 
    public void RemoveAllt(T obj){...}
// and the IEnumerable<T> stuff
}

Another class with a similar motivation is SelfCleanableEventHost<T> which registers event handlers and, in the same way as SelfCleanableCollection<T>, doesn't prevent them from being garbage-collected:

public class SelfCleanableEventHost<T> 
{ 
    public event EventHandler<T> Event
    { 
        add{..} 
        remove{..}
    }
    public event Func<object, T, Task> EventAsync 
    { 
        add{..} 
        remove{..}
    }
    public void Add(IEnumerable<T> objs){...}
    public void Raise(T arg){...}
    public void Raise(object sender, T arg){...}
    public async Task RaiseAsync(T arg){...}
    public async Task RaiseAsync(object sender, T arg){...} 
}

In both classes, if the stored instance (e.g., an event subscriber) has been garbage-collected, its storage place is automatically cleaned.

In the Core of It

In the core of the self-cleaning behaviour of both classes is:

internal class SelfCleanableEnumerator<S, W> : IEnumerator<S> where S : class 
{
        internal SelfCleanableEnumerator(Func<W, S> toStrongFunc, 
                                         Func<S, W> toWeakFunc, 
                                         Func<S, W, bool> matchFunc)
        {
            this.list = new List<W>();
            this.strongReferenceFunc = toStrongFunc;
            this.weakReferenceFunc = toWeakFunc;
            this.matchFunc = matchFunc;
        }
}

Here, we have two parameter types:

  • S (like strong) stands for the type of references the consumer thinks they store in the enumerator,
  • W (like weak) is the type of really stored objects

The three passed functions are, as their names imply, converters between types S and W and an equality function between them.

The use of generic type W instead of mere System.WeakReference is because of the reuse in SelfCleanableEventHost<T>, where the event handler is stored as an object of type W which is actually a pair <WeakReference, MethodInfo>.

The self-cleaning behavior is implemented in SelfCleanableEnumerator<S, W>.MoveNext(), straightforward:

public bool MoveNext()
{
    while (this.counter < this.list.Count)
    {
        S currentStrong =  this.strongReferenceFunc(this.list[this.counter]);
        if (currentStrong != null)
        {
           this.Current = currentStrong;
           this.counter++;
           return true;
        }
        else
        {
           this.list.RemoveAt(this.counter);
        }
    }
    this.Current = default;
    return false;
}

Self-Cleanable Collection

SelfCleanableCollection<T> is a simpler example of how the above enumerator is used. It's type W is System.WeakReference, thus making it easier to understand:

public class SelfCleanableCollection<T> : 
       SelfCleanableCollection<T, WeakReference> where T : class
{
    public SelfCleanableCollection() : base(el => (T)el.Target,          //to-strong: get the 
                                                       //wrapped object from the weak reference
                                            el => new WeakReference(el), // to-weak: wrap the 
                                                       //object into a weak reference
                                            (s, w) => s == w.Target)     // match: compare 
                                                                         // strong references 
    {}
}

So, the to-strong, to-weak, and match-functions are rather trivial. The to-weak function specifies the no-reference-counter storing mechanism, thus making it possible for the stored objects to be silently garbage-collected.

The mysterious class SelfCleanableCollection<T, W> is nothing else than an implementation of IEnumerable<T> that has an instance of SelfCleanableEnumerator<S, W> (see above) and routes the IEnumerable<T> calls to the contained enumerator.

Self-Cleanable Event Host

To my taste, this is the most funny part of all this. Firstly, because the event host pretends to replicate the native event behavior (a caprice of mine). It was a special challenge to write tests for native-like recursive/concurrent subscription/unsubscription. If you have sufficient patience to take a glance at the unit tests, don't be surprised to find some that actually check the native .NET behavior.

Let's start with the less complicated stuff, though:

public class SelfCleanableEventHost<TEventArgs>
{
   public SelfCleanableEventHost()
   {
      this.subscribers = 
         new SelfCleanableCollection<StrongSubscriber, WeakSubscriber>(w => ToStrong(w), 
                                                                       s => ToWeak(s),
                                                                       (s,w)=> s.Reference == 
                                                                       w.Reference.Target);
   }
}

Here, StrongSubscriber is merely a pair <context, method>:

internal class StrongSubscriber
{
    public StrongSubscriber(object context, MethodInfo methodInfo)
    {
        this.Reference = context;
        this.Method = methodInfo;
    }
    public object Reference { get; }
    public MethodInfo Method { get; }
}

WeakSubscriber is its counterpart that stores the context (the invocation target) as a weak reference:

internal class WeakSubscriber
{
    public WeakSubscriber(object context MethodInfo methodInfo)
    {
        this.Reference = new WeakReference(context);
        this.Method = methodInfo;
    }
    public WeakReference Reference { get; }
    public MethodInfo Method { get; }
}

As we have seen it in the discussion about SelfCleanableEnumerator<S, W>, for the self-cleanable behavior, it is sufficient that the to-strong function returns null if the weakly-referenced object has been garbage-collected. It does:

private static StrongSubscriber ToStrong(WeakSubscriber wr)
{
    var context = wr.Reference.Target;
    if (context != null)
    {
        return new StrongSubscriber(context, wr.Method);
    }
    else
    {
        return null;
    }
}

Unfortunately, I didn't get to proving the self-cleaning behavior of the event host class in the unit tests, like I did it for the collection. The "dying" event subscribers remained not-collected, so the related unit tests are attributed with [Ignore]. However, the similar collection tests work fine. Besides, the self-cleaning behavior of the event host works as expected in the corresponding demo app (downloadable).

Registering and unregistering of event handlers takes place in AddInvocations:

private void AddInvocations(Delegate[] invocations)
{
    lock (this.syncObject)
    {
        foreach (var del in invocations.Where(el => el != null))
        {
            var target = del.Target;
            if (target != null)
            {
                subscribers.Add((new StrongSubscriber(target, del.GetMethodInfo())));
            }
            else
            {
                var obj = new object();
                this.staticSubscribers.Add(obj);
                this.subscribers.Add((new StrongSubscriber(obj, del.GetMethodInfo())));
            }
        }
    }
}

and RemoveInvocations respectively:

private void RemoveInvocations(Delegate[] invocations)
{
    lock (this.syncObject)
    {
        foreach (var del in invocations.Where(el => el != null))
        {
            var target = del.Target;
            if (target != null)//is non-static
            {
                var toRemove = this.subscribers
                    .LastOrDefault(x =>
                        object.ReferenceEquals(x.Reference, del.Target) &&
                        x.Method == del.GetMethodInfo());
                if (toRemove != null)
                    this.subscribers.RemoveLast(toRemove);
            }
            else//is static
            {
                var toRemove = this.subscribers.LastOrDefault
                               (el => el.Method == del.GetMethodInfo());
                if (toRemove != null)
                {
                    this.subscribers.RemoveLast(toRemove);
                    this.staticSubscribers.Remove(toRemove.Reference);
                }
            }
        }
    }
}

Note that static handlers (should work with them too, although static subscribers will never be collected until the application exits) have null invocation target (i.e., context). So, for being easily identified at the moment of an invocation, they are stored in a separate list.

For having a natural look, the event host class exposes two events, which share the same invocation list:

public event EventHandler<TEventArgs> Event
{
    add    { AddInvocations(value.GetInvocationList());   }

    remove { RemoveInvocations(value.GetInvocationList());}
}

public event Func<object, TEventArgs, Task> EventAsync
{
    add  { AddInvocations(value.GetInvocationList()); }

    remove { RemoveInvocations(value.GetInvocationList());}
}

The reason of implementing the latter event is that, for being awaitable, the event handler should return Task. Classic event handlers return nothing, as in general, it is difficult to make any reasonable agreement about how to aggregate the return values from multiple handlers. But in the specific case of async Task handlers, there is (at least) one, namely when-all:

public async Task RaiseAsync(object sender, TEventArgs args)
{
    StrongSubscriber[] snapshotSubscribers = null;
    object[] snapshotStaticSubscribers = null;

    lock (this.syncObject)
    {
        snapshotSubscribers = this.subscribers.ToArray();
        snapshotStaticSubscribers = this.staticSubscribers.ToArray();
    }
    var tasks = new List<Task>();
    foreach (var subscriber in snapshotSubscribers.Where(el=> el != null))
    {
        var context = subscriber.Reference;
        Task ret = default;
        if (!this.staticSubscribers.Contains(context))
        {
            ret = subscriber.Method.Invoke(context, new[] { sender, args }) as Task;
        }
        else
        {
            ret = subscriber.Method.Invoke(null, new[] { sender, args }) as Task;
        }
        if (ret != null)
        {
            tasks.Add(ret);
        }
    }
    await Task.WhenAll(tasks);
}

As it follows from that code, within this method, both classic and awaitable subscribers are called in the order of their registration.

Taking snapshots of the static and non-static invocation lists before invocation is for providing the native-like behavior in the case of recursive subscriptions/unsubscriptions.

All other Raise overloads route to the above method:

public void Raise(TEventArgs args)
{
    Raise(this, args);
}

public void Raise(object sender, TEventArgs args)
{
    RaiseAsync(sender, args).Wait();
}

public async Task RaiseAsync(TEventArgs args)
{
    await RaiseAsync(this, args);
}

How Long Does Captured Context Stay Alive?

Well, as any other object, as long as it is referenced. However, captured context isn't something we can feel with our hands. So, if you use a lambda expression as an event handler for this event host, like below:

var target = new SelfCleanableEventHost<int>();
target.Event += el => Console.WriteLine(el); 

// and, to make the thing worse, 
return Task.FromResult(target);

and combine it with some async behavior after returning, it might happen that on Raise(...) the call won't take place.

Indeed, somewhere in the second line of the above code, a captured context object is created. It is then passed to the event host as the invocation target, wrapped into a weak reference and... isn't referenced any more. So, at the earliest opportunity, it will be garbage-collected. It was a hilarious bug, indeed.

Using the Code

The downloadable code includes a VS2019 solution with source code of the above classes, two graphical (WPF) apps that demonstrate the self-cleaning behavior and the way synchronous and asynchronous event invocations work. A curious reader can check the unit tests for more details.

What is the Value?

Well, the applied value of SelfCleanableEventHost<T> is limited, namely because it cannot be predicted, which of the dying subscribers will make it to the next Raise(...) and which won't. Unless you have some special cases, where it isn't critical. I used it to work-around some legacy code issues. Beyond this, I consider it rather as a "C# fantasy".

History

  • 28th January, 2020: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Vassili Kravtchenko-Berejnoi
Software Developer (Senior) Vassili Kravtchenko-Berejnoi Technical Computing
Austria Austria
No Biography provided

Comments and Discussions

 
PraiseGood work Pin
Member 40244530-Jan-20 22:44
MemberMember 40244530-Jan-20 22:44 
GeneralRe: Good work Pin
Vassili Kravtchenko-Berejnoi31-Jan-20 8:14
professionalVassili Kravtchenko-Berejnoi31-Jan-20 8:14 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

Tip/Trick
Posted 27 Jan 2020

Stats

11.3K views
259 downloads
4 bookmarked