Skip to content

Instantly share code, notes, and snippets.

@jnm2
Last active August 27, 2019 07:40
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jnm2/e9703624ef5ca7cef74f to your computer and use it in GitHub Desktop.
Save jnm2/e9703624ef5ca7cef74f to your computer and use it in GitHub Desktop.
Replaces a multicast delegate as an event's backing store. Duplicate behavior, except it is thread safe and holds weak references.
// MIT license, copyright 2015 Joseph N. Musser II
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
namespace jnm2
{
/// <summary>
/// Replaces a multicast delegate as an event's backing store. Duplicate behavior, except it is thread safe and holds weak references.
/// </summary>
public sealed class WeakEventSource<TDelegate> where TDelegate : class
{
// ReSharper disable once StaticMemberInGenericType
private static readonly DynamicMethod raiseMethod;
private readonly ConditionalWeakTable<object, List<object>> keepAliveDelegatesWithTarget = new ConditionalWeakTable<object, List<object>>();
static WeakEventSource()
{
if (!typeof(TDelegate).IsSubclassOf(typeof(Delegate))) throw new InvalidOperationException("TDelegate must derive from System.Delegate.");
// Cache raise method per delegate type
var raiseSignature = typeof(TDelegate).GetMethod("Invoke");
var parameters = raiseSignature.GetParameters();
var raiseParameterTypes = new Type[parameters.Length + 1];
raiseParameterTypes[0] = typeof(WeakEventSource<TDelegate>); // First parameter is raise delegate target (this)
for (var i = 0; i < parameters.Length; i++)
raiseParameterTypes[i + 1] = parameters[i].ParameterType;
raiseMethod = new DynamicMethod("Raise", raiseSignature.ReturnType, raiseParameterTypes, typeof(WeakEventSource<TDelegate>));
var il = raiseMethod.GetILGenerator();
il.DeclareLocal(typeof(List<TDelegate>)); // loc_0
il.DeclareLocal(typeof(int)); // loc_1
if (raiseSignature.ReturnType != typeof(void))
{
il.DeclareLocal(raiseSignature.ReturnType); // loc_2
// var r = default(TReturn)
if (raiseSignature.ReturnType.IsClass)
{
il.Emit(OpCodes.Ldnull);
il.Emit(OpCodes.Stloc_2);
}
else
{
il.Emit(OpCodes.Ldloca_S, 2);
il.Emit(OpCodes.Initobj, raiseSignature.ReturnType);
}
}
// var handlerList = this.GetLiveHandlersReverseOrder();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, typeof(WeakEventSource<TDelegate>).GetMethod("GetLiveHandlersReverseOrder", BindingFlags.Instance | BindingFlags.NonPublic));
il.Emit(OpCodes.Stloc_0);
// var i = handlerList.Count - 1;
il.Emit(OpCodes.Ldloc_0);
il.Emit(OpCodes.Callvirt, typeof(List<TDelegate>).GetMethod("get_Count"));
il.Emit(OpCodes.Ldc_I4_1);
il.Emit(OpCodes.Sub);
il.Emit(OpCodes.Stloc_1);
var loopConditionLabel = il.DefineLabel();
var loopBodyLabel = il.DefineLabel();
il.Emit(OpCodes.Br_S, loopConditionLabel);
il.MarkLabel(loopBodyLabel);
// handlerList[i]
il.Emit(OpCodes.Ldloc_0);
il.Emit(OpCodes.Ldloc_1);
il.Emit(OpCodes.Callvirt, typeof(List<TDelegate>).GetMethod("get_Item"));
// .Invoke(p1, p2, p3, ...)
for (var parameterIndex = 1; parameterIndex < raiseParameterTypes.Length; parameterIndex++)
il.Emit(raiseParameterTypes[parameterIndex].IsByRef ? OpCodes.Ldarga_S : OpCodes.Ldarg_S, parameterIndex);
il.Emit(OpCodes.Callvirt, typeof(TDelegate).GetMethod("Invoke"));
if (raiseSignature.ReturnType != typeof(void)) il.Emit(OpCodes.Stloc_2);
// i--;
il.Emit(OpCodes.Ldloc_1);
il.Emit(OpCodes.Ldc_I4_1);
il.Emit(OpCodes.Sub);
il.Emit(OpCodes.Stloc_1);
il.MarkLabel(loopConditionLabel);
// i >= 0;
il.Emit(OpCodes.Ldloc_1);
il.Emit(OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Bge_S, loopBodyLabel);
if (raiseSignature.ReturnType != typeof(void)) il.Emit(OpCodes.Ldloc_2);
il.Emit(OpCodes.Ret);
}
public WeakEventSource()
{
this.Raise = (TDelegate)(object)raiseMethod.CreateDelegate(typeof(TDelegate), this);
}
public TDelegate Raise { get; private set; }
private readonly List<WeakReference<TDelegate>> handlers = new List<WeakReference<TDelegate>>();
public void Subscribe(TDelegate addend)
{
if (addend == null) return;
lock (handlers)
{
handlers.Add(new WeakReference<TDelegate>(addend));
// Ensure that the addend stays alive as long as the target
keepAliveDelegatesWithTarget.GetOrCreateValue(((Delegate)(object)addend).Target).Add(addend);
}
}
public void Unsubscribe(TDelegate subtrahend)
{
if (subtrahend == null) return;
lock (handlers)
{
for (var i = handlers.Count - 1; i >= 0; i--)
{
TDelegate del;
if (!handlers[i].TryGetTarget(out del))
{
handlers.RemoveAt(i); // May as well clean up the list while we search, since we have to call TryGetTarget anyway
continue;
}
// Once we've removed the subtrahend exactly once, return.
if (del.Equals(subtrahend))
{
List<object> delegates;
if (keepAliveDelegatesWithTarget.TryGetValue(((Delegate)(object)subtrahend).Target, out delegates)) delegates.Remove(subtrahend);
handlers.RemoveAt(i);
return;
}
}
}
}
// Retrieve the current list of live handlers to be invoked.
// (NB! Never invoke a delegate while holding a lock; it raises the chance of contention and often causes reentrancy errors in hard-to-predict places of the host program.)
// ReSharper disable once UnusedMember.Local
private List<TDelegate> GetLiveHandlersReverseOrder()
{
var r = new List<TDelegate>(handlers.Count);
lock (handlers)
{
for (var i = handlers.Count - 1; i >= 0; i--)
{
TDelegate del;
if (handlers[i].TryGetTarget(out del))
r.Add(del);
else
handlers.RemoveAt(i);
}
}
return r;
}
}
}
@thomaslevesque
Copy link

Not sure if you saw my reply on my blog, so I repost it here:

I just had a look at your solution. Actually, keeping a weak reference to the delegate itself is something I tried before, but it has a serious drawback: the subscriber needs to keep a strong reference to the delegate, otherwise the delegate is garbage collected too early!

Here’s a snippet that demonstrates the issue:
https://gist.github.com/thomaslevesque/fc8e141f377967c0fcfe

As you can see, the Subscriber.OnFoo method is never invoked, because the delegate has already been collected.

@jnm2
Copy link
Author

jnm2 commented Aug 28, 2015

That is an excellent point. I was mistaken- delegates are removed by value, not reference. So my code oversimplifies. I updated my code to resolve this issue using a ConditionalWeakTable to guarantee that as long as the target is alive, the delegate will stay alive. If the target does not stay alive, the delegate gets collected.

I want to be sure I understand the way MulticastDelegate.Remove works for multicast delegates.

How do you like the Raise pseudomethod?

@jnm2
Copy link
Author

jnm2 commented Aug 28, 2015

And here is a version that introduces WeakDelegate<TDelegate> and does not need ConditionalWeakTable.

@thomaslevesque
Copy link

Hi Joseph,

Using a ConditionalWeakTable is a great idea! I think I would use it a bit differently, though; I don't think it's really necessary to have a list of WeakReference in addition to the CWT.

I want to be sure I understand the way MulticastDelegate.Remove works for multicast delegates.

Not quite; a delegate is immutable, and the Remove method doesn't actually affect the delegate instance on which it's called. It creates a new MulticastDelegate without the removed delegate. (similarly to String.Replace)

How do you like the Raise pseudomethod?

It's a good approach; actually I think it's the only way to expose a Raise "method" with the correct signature, since the signature isn't known in advance.

@jnm2
Copy link
Author

jnm2 commented Aug 28, 2015

You can't enumerate a ConditionalWeakTable, so I couldn't think of anything more efficient than a separate list of weak references. What do you think of the WeakDelegate version?

Also did I explain well enough what can happen if you invoke a delegate within a lock? Since locks are reentrant by the same thread, you lose thread safety features. If one of the handlers happens to cause a call to Subscribe or Unsubscribe (not uncommon), complicated bad stuff happens. I kept all list operations atomic by making a temporary list and then invoking from that.

Not quite; a delegate is immutable...

Understood. It still would be good to mimic the add/remove behavior. It looks like multicast delegate math is not commutative; am I right that A + B - (A + B) = (A + B)? If I'm right, this means we don't have to do anything special since our subscribe/unsubscribe works that way as well.

@jnm2
Copy link
Author

jnm2 commented Sep 2, 2015

Anyway, feel free to copy/take anything you like.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment