Multicast delegates: Delegate.Combine and Remove, immutable invocation lists, return values, exceptions stopping the chain, thread safety and invoking handlers individually.
Any delegate can hold several methods. += calls Delegate.Combine, which creates a new immutable delegate with an invocation list, and invoking it calls each method synchronously in subscription order. For a delegate with a return value you only get the last handler's result, and if one handler throws, the rest are skipped. -= removes the last matching entry, which only works for lambdas if you kept the same instance. When I need every result or want one failing handler not to stop the others, I loop over GetInvocationList().
Every .NET delegate is a multicast delegate: it can hold an invocation list of one or more methods, invoked in order by a single call. += and -= build that list. The interesting parts are what happens to return values and exceptions, and why reading a delegate is thread-safe while subscribing to events needs care.
+= and -= build the listAction<string> notify = s => Console.WriteLine($"Email: {s}");
notify += s => Console.WriteLine($"SMS: {s}");
notify += LogToAudit;
notify("Order 42 shipped"); // runs all three, in order
notify -= LogToAudit; // removes the LAST matching occurrence
a += b compiles to a = (Action<string>)Delegate.Combine(a, b);.a -= b compiles to a = (Action<string>)Delegate.Remove(a, b);, which removes the last occurrence of b's invocation list from a's (by delegate equality: same method and target).null, so always invoke with ?.Invoke.Handlers run synchronously, in the order they were added, on the calling thread. Duplicates are allowed (adding the same handler twice runs it twice). Don't rely on order across independently registered components — it's an implementation detail of subscription order.
For a delegate with a return type, invoking a multicast delegate returns only the last handler's return value; the others are discarded:
Func<Order, bool> validators = o => o.Total > 0;
validators += o => o.Lines.Count > 0;
bool ok = validators(order); // ONLY the second validator's result!
If you need all results, iterate the invocation list.
If a handler throws, invocation stops: later handlers don't run, and the exception propagates to the caller. One faulty subscriber breaks all subscribers after it. To isolate handlers:
foreach (var handler in OrderShipped?.GetInvocationList() ?? [])
{
try { ((EventHandler<OrderShippedEventArgs>)handler)(this, args); }
catch (Exception ex) { _logger.LogError(ex, "Handler {Handler} failed", handler.Method.Name); }
}
GetInvocationListGetInvocationList() returns a Delegate[] copy of the individual delegates. Use it to:
Func<T, Task> handlers awaited with Task.WhenAll),handler.Target).It allocates an array, so avoid it on very hot paths unless needed.
handler?.Invoke(...) reads the field once into a temporary, avoiding the race where another thread unsubscribes the last handler between a null check and the call.+=/-=) is a read-modify-write: read the field, combine, write back. Two threads subscribing simultaneously could lose one subscription. Field-like events solve this: the compiler-generated add/remove accessors use an Interlocked.CompareExchange loop, making subscription thread-safe.= or invoke it.An in-process notification hub exposed public Action<Order> OrderPaid; (a field). One module's handler threw a NullReferenceException for orders without an email, and every handler registered after it — including the warehouse dispatch — silently stopped running for those orders. A second bug: a background module did OrderPaid = Handle; (assignment instead of +=), wiping all other subscribers. We changed it to an event, invoked handlers via GetInvocationList() with per-handler try/catch and logging, and eventually moved to a mediator with explicit handler registration and retries.
Probes deeper delegate knowledge that explains real bugs in events and plug-in systems.
Green flags: Combine/Remove create new delegates; last return value; exceptions stop the chain; lambda unsubscription pitfall; GetInvocationList for isolation; immutability enabling safe invocation.
Red flags: thinks handlers run in parallel; unaware of return value semantics; believes -= with a lambda works.
MulticastDelegate
_target / _methodPtr (for single-cast)
_invocationList: object[] (for multicast: array of single-cast delegates)
_invocationCount: nint (number of entries used)
Delegate.Combine creates a new delegate whose invocation list is the concatenation (it may over-allocate the array to make repeated += cheaper). Delegate.Remove searches from the end for a matching sub-list and creates a new delegate without it; if nothing remains, returns null.
Delegate.Combine(a, b) throws ArgumentException if a.GetType() != b.GetType(). With variance (Action<object> assigned to Action<string>), runtime types can differ from static types — a known gotcha.
public event EventHandler? Changed;
// compiler generates roughly:
private EventHandler? _changed;
public void add_Changed(EventHandler value)
{
EventHandler? current = _changed, prior;
do
{
prior = current;
var combined = (EventHandler?)Delegate.Combine(prior, value);
current = Interlocked.CompareExchange(ref _changed, combined, prior);
} while (current != prior);
}
This lock-free loop retries if another thread changed the field concurrently. Before C# 4, field-like events used lock(this), which was problematic.
event Func<T, Task> handlers invoked directly only return the last task — earlier handlers' tasks are ignored (exceptions lost, completion not awaited). Proper async events iterate the invocation list:
var tasks = handler.GetInvocationList().Cast<Func<OrderPaid, Task>>().Select(h => h(e));
await Task.WhenAll(tasks);
Or sequentially with foreach + await to preserve order.
+=/-= allocates a new delegate and array (O(N) copy) — fine for typical subscription patterns, not for high-frequency subscribe/unsubscribe.List<Action<T>> with explicit locking or an observer/mediator library.Func<int> delegates returning 1, 2, 3 and print the result.Action throw and observe that the third doesn't run.GetInvocationList() to collect all results and isolate exceptions.Func<Task> handlers and compare invoking directly vs iterating and Task.WhenAll.namespace Delegates.Multicast;
public sealed record Document(string Title);
public sealed class PluginHost
{
private Func<Document, bool>? _beforeSave;
public void Register(Func<Document, bool> veto) => _beforeSave += veto;
public void Unregister(Func<Document, bool> veto) => _beforeSave -= veto;
// Bug: only the last plug-in's answer counts
public bool CanSaveNaive(Document doc) => _beforeSave?.Invoke(doc) ?? true;
// Fix: ask every plug-in, isolate failures, stop on the first veto
public bool CanSave(Document doc)
{
var handlers = _beforeSave;
if (handlers is null) return true;
foreach (var d in handlers.GetInvocationList())
{
var plugin = (Func<Document, bool>)d;
try
{
if (!plugin(doc))
{
Console.WriteLine($"Vetoed by {plugin.Method.Name}");
return false;
}
}
catch (Exception ex)
{
Console.WriteLine($"Plug-in {plugin.Method.Name} failed: {ex.Message}");
}
}
return true;
}
}
public static class Program
{
private static bool RequireTitle(Document d) => !string.IsNullOrWhiteSpace(d.Title);
private static bool Broken(Document d) => throw new InvalidOperationException("plug-in bug");
private static bool AlwaysOk(Document d) => true;
public static void Main()
{
var host = new PluginHost();
host.Register(RequireTitle);
host.Register(Broken);
host.Register(AlwaysOk);
var doc = new Document("");
try { Console.WriteLine(host.CanSaveNaive(doc)); }
catch (InvalidOperationException) { Console.WriteLine("Naive: exception stopped the chain"); }
Console.WriteLine(host.CanSave(doc)); // Vetoed by RequireTitle, False
host.Unregister(Broken); // works: same method group, same (null) target
Func<Document, bool> lambda = d => true;
host.Register(lambda);
host.Unregister(lambda); // works only because we kept the instance
}
}
= instead of +=What happens: notifier.Changed = Handler; on a public delegate field.
Why it is wrong: replaces all existing subscribers.
Do instead: use event, which only allows += and -= from outside.
What happens: using a multicast Func<bool> as a combined validator.
Why it is wrong: only the last result is returned.
Do instead: iterate GetInvocationList() or use a list of validators.
What happens: an exception in one subscriber stops later ones. Why it is wrong: unrelated features silently stop working. Do instead: invoke handlers individually with error handling where isolation matters.
What happens: Func<Task> events invoked directly and awaited.
Why it is wrong: only the last handler's task is awaited; others' exceptions are lost.
Do instead: iterate the invocation list and await all.
What happens: invoking a delegate field after all handlers were removed.
Why it is wrong: NullReferenceException.
Do instead: handler?.Invoke(...).
What happens: multiple threads += on a public delegate field.
Why it is wrong: lost subscriptions due to races.
Do instead: use events (thread-safe accessors) or explicit locking.
What happens: handler B assumes handler A already ran. Why it is wrong: order depends on subscription order, which is fragile. Do instead: make handlers independent or use an explicit pipeline.
What happens: handlers assume they won't run after unsubscription. Why it is wrong: a raiser may have taken a snapshot before removal. Do instead: handlers should tolerate late invocation (check disposed state).
Multicast delegates are a simple, synchronous, in-process observer mechanism. Seniors know the sharp edges (last return value wins, exceptions stop the chain, removal by identity) and choose a different mechanism when they matter: an explicit list of handlers, MediatR-style notifications, or a message bus for cross-process events. For extensibility points I always isolate handlers, log which one failed, and avoid async handlers through plain delegates.
An event is a delegate member with access restricted to subscribe and unsubscribe. Outside the class you can only use += and -=; only the owner can raise it or clear it, whereas a public delegate field can be invoked or overwritten by anyone. For a field-like event the compiler generates a private delegate field and thread-safe add and remove accessors. I raise it with Changed?.Invoke(this, e), which reads the field once. I avoid static events because they keep every subscriber alive until it unsubscribes.
The convention is EventHandler<TEventArgs> with object? sender and an immutable args object, EventArgs.Empty when there is no data, and a protected virtual OnXxx method that raises it. The big production risk is leaks: the publisher's delegate holds a strong reference to every subscriber, so a singleton or static publisher keeps short-lived subscribers alive forever. I find them with a heap dump and gcroot, which shows the path through the event's delegate, and fix them by unsubscribing in Dispose or using a weak event. Async handlers are async void, so I keep them thin and catch everything inside.
How to tell it (STAR)
**S:** Our DMS was saving documents that a compliance plug-in had rejected. **T:** Find out why and fix it before the audit. **A:** I reproduced it with two plug-ins and found the multicast delegate returned only the last plug-in's answer. I changed the host to evaluate each plug-in separately with error isolation and logging. **R:** Vetoes were honoured from then on, and we passed the [audit name] review.
Last updated 30 Oct 2026.