Back to Blog
C#

C# Multicast Delegate: Syntax and Behavior

c# multicast delegate: Understand how C# multicast delegates chain method calls, the runtime behavior of their invocation lists, and practical patterns for safe event...

delegateseventsinvocation listexception handlingC# language
Illustration of multiple method references chaining together in a delegate invocation list

A c# multicast delegate holds references to more than one method and invokes them in sequence when the delegate is called. This behavior comes from the System.MulticastDelegate type, which all delegates inherit from in C#. 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 delegate instances with the + operator, the runtime 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 is not modifying the original delegates. Each delegate is immutable; combining them produces a new delegate instance. The same applies to the += operator when subscribing to an event, which is shorthand for assigning handler = handler + newMethod.

The Role of Delegate Immutability

Because delegates are immutable, any operation that appears to change an invocation list actually returns a new delegate. This has practical consequences. 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

This immutability makes delegate usage safe in multithreaded scenarios where one thread might be reading the invocation list while another subscribes or unsubscribes. The reader sees a consistent snapshot, because the delegate instance itself never 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 runtime 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 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, 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. The compiler generates a private backing delegate and guards the add/remove accessors with a lock, which is important when subscribers register from multiple threads. Direct delegate fields do not provide that synchronization.

Performance and Allocation Characteristics

Creating a multicast delegate involves allocating 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 to 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 statement appears to subscribe the same method twice, but the delegate retains only one reference because the same method group creates an equivalent delegate each time.

Action handler = Handle; handler += Handle; handler -= Handle; // The list now contains one Handle reference, not zero.

Removing the method once leaves the first subscription intact because -= removes only the last matching occurrence. 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 is more control-oriented. 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.

c# multicast delegate: Practical Usage and Code Examples | RYUSLOG DEV