C# Multicast Delegate: Syntax and Behavior
Understand how C# multicast delegates chain method calls, how invocation lists behave at runtime, and practical patterns for safe event handlers.
A multicast delegate in C# can hold references to more than one method and invokes them in sequence when the delegate is called. This behavior comes from the System.MulticastDelegate type; every C# delegate type derives from it. Understanding how the invocation list is built, traversed, and modified is essential for writing reliable event handlers and callback pipelines.
How the Invocation List Is Built
When you combine two non-null delegate instances with the + operator, the delegate combination operation creates a new delegate whose invocation list contains the targets from both sources. The order of invocation follows the order in which the delegates were combined.
Action first = () => Console.WriteLine("First"); Action second = () => Console.WriteLine("Second"); Action combined = first + second; combined();
Output:
First
Second
The + operator does not modify the original delegates. Delegate instances are immutable; combining them produces a new delegate instance. The same applies to the += operator for a field-like event, which is shorthand for assigning handler = handler + newMethod.
The Role of Delegate Immutability
Because delegate instances are immutable, any operation that appears to change an invocation list actually returns a new delegate. If you store a reference to a delegate and then use += on that same variable, the variable receives the new delegate instance. Any other reference that held the original delegate still points to the original list.
Action original = () => Console.WriteLine("Original"); Action copy = original; original += () => Console.WriteLine("Added"); copy(); // Prints: Original original(); // Prints: Original, Added
Delegate immutability means that a thread that reads a delegate reference sees a consistent snapshot even while another thread is publishing a new delegate. It does not remove all synchronization concerns for events by itself; the event accessors discussed later handle the common case of concurrent subscription changes.
Combining and Removing Methods
The + and - operators provide the standard chaining syntax. Removing a method from a delegate is done with -, which searches the invocation list and removes the last matching delegate instance.
Action handler = MethodOne; handler += MethodTwo; handler += MethodThree; handler -= MethodTwo; handler(); // Invokes MethodOne then MethodThree
When using -=, if the method to be removed appears multiple times in the list, only the last occurrence is removed. This asymmetry between += (append at the end) and -= (remove last) is important when the same method is subscribed multiple times through intermediate combining operations.
Invocation Order and Parameter Passing
Multicast delegates can carry parameters and return values, but the returned value comes only from the last method in the invocation list. The delegate invokes each method with the same argument values and discards the return values of all but the final method.
Func<int, int> increment = x => x + 1; increment += x => x * 10; int result = increment(5); // result = 50 (only last method's return is used)
If you need to collect return values from every method, you must manually iterate over the invocation list instead of invoking the delegate directly.
Func<int, int> combined = x => x + 1; combined += x => x * 10; foreach (Func<int, int> method in combined.GetInvocationList()) { int partial = method(5); Console.WriteLine(partial); }
This pattern is common when building validation chains or transformation pipelines where each stage contributes a result.
Exception Handling in Multicast Delegates
One of the most overlooked aspects of multicast delegates is exception behavior. If any method in the invocation list throws an exception, the delegate stops the invocation immediately and the remaining methods are not called. There is no built-in rollback or continue-on-error behavior.
Action safe = () => Console.WriteLine("Safe"); Action risky = () => throw new InvalidOperationException("Boom"); Action after = () => Console.WriteLine("After"); Action pipeline = safe + risky + after; // Safe is called, risky throws, after is never invoked. try { pipeline(); } catch (InvalidOperationException) { // handle }
If the remaining methods are essential, you must handle exceptions within each method or manually iterate through the invocation list with a per-method try/catch. Iterating manually gives you full control over whether to stop or continue.
foreach (Action method in pipeline.GetInvocationList()) { try { method(); } catch (InvalidOperationException) { Console.WriteLine($"Method failed: {method.Method.Name}"); } }
This is a deliberate design choice. The delegate does not guess what the correct behavior is after a failure. It preserves the exception propagation semantics of a regular method call.
Multicast Delegates Versus Events
Events in C# are built on multicast delegates, but the event keyword restricts the way the delegate can be used from outside the declaring class. With a public delegate field or property, any code can invoke the delegate directly and overwrite all subscribers. An event only allows += and -= from outside, keeping the invocation list protected.
public class Button { public Action OnClick { get; set; } // unsafe: external code can set to null }
public class SafeButton { public event Action OnClick; public void SimulateClick() { OnClick?.Invoke(); } }
Using event is the standard way to expose a multicast delegate as a subscription point. For a field-like event, the compiler generates a private backing delegate and thread-safe add/remove accessors. The generated accessors publish the updated delegate with interlocked operations rather than a lock, which is important when subscribers register from multiple threads. Direct delegate fields and ordinary delegate properties do not provide that synchronization by default; custom event accessors must define their own if needed.
Performance and Allocation Characteristics
Combining delegates typically allocates a new delegate instance that holds the combined invocation list. When you frequently add or remove handlers, these allocations accumulate. In event-heavy systems such as UI frameworks, that can add noticeable garbage collection pressure, but the cost is rarely the bottleneck compared with the actual method invocations.
The GetInvocationList() method returns an array of delegate references. Allocating this array for a large number of subscribers can be expensive if done on every invocation. If you need to iterate the list, keep the array reference when the delegate is not changing, rather than calling GetInvocationList() repeatedly.
// Expensive if called in a loop without changes foreach (Action method in currentEvent.GetInvocationList()) { method(); } // Reuse the array if the delegate is stable Action[] handlers = currentEvent.GetInvocationList(); foreach (Action method in handlers) { method(); }
One performance consideration that is often missed is the cost of null checks. The ?.Invoke() pattern checks for null before calling, which is safe but introduces a branch. The branch is predictable when the delegate is usually non-null, so the cost is minimal.
Common Pitfalls with Method Groups
A common source of bugs is the implicit conversion of a method group to a delegate. The following sequence appears to subscribe and unsubscribe the same method, but after these operations the invocation list still contains one entry:
Action handler = Handle; handler += Handle; handler -= Handle; // The list now contains one Handle reference, not zero.
+= appends a second entry, even though both entries refer to the same method and target. -= removes only the last matching entry, so the original subscription remains. This subtle behavior causes off-by-one subscription bugs in event handling. When debugging, inspect the invocation list with GetInvocationList() to see what is actually stored.
When to Manually Iterate Versus Letting the Delegate Act
Letting the delegate invoke its own list is concise and covers the common case where all methods have the same signature and no method needs to interrupt or collect results. Manual iteration becomes necessary when individual exceptions must be caught independently, when return values are needed, or when the same delegate is invoked on a different thread per method.
Choosing the right approach comes down to the contract your code enforces. If the pipeline must be all-or-nothing, direct invocation is appropriate. If partial failure is acceptable, manual iteration with per-method error handling gives more control. Document that choice in the method that exposes the delegate so callers understand what happens when a handler throws.
The multicast delegate is a foundational piece of the C# event model. Knowing how invocation lists are structured, how they react to exceptions, and how to inspect them at runtime lets you build callback systems that behave predictably in production.