You already know how to use a delegate. This lesson opens it up — the class hierarchy underneath, the exact object it wraps, and the two gotchas (exceptions mid-chain, races on +=) that only show up under production load.
Back in lessons 096 and 097 you learned that a delegate is a type-safe reference to a method, that every delegate is secretly a multicast delegate capable of holding a chain of subscribers, and that +=/-= add and remove entries from that chain. That's everything you need to use delegates correctly.
This lesson is about what happens when that knowledge isn't enough — when a production incident report says "one of our five event handlers threw, and the other four silently never ran," or a code review flags _handlers += SomeHandler; as a possible race condition, or a teammate asks why Func<Dog> can be assigned to a Func<Animal> variable without a cast. All three of those are answerable once you know exactly what a delegate is, as a real .NET type, not just how to write one.
You'll come out of this lesson able to reason precisely about multicast failure modes, thread-safety, and delegate variance — the kind of detail that separates "I can use delegates" from "I can debug delegates in production."
Every delegate type you've ever declared or used — MathOperation from lesson 096, Action<string>, Func<int, bool>, even your own custom event delegate types — is, underneath the delegate keyword's friendly syntax, an honest-to-goodness class in a real inheritance chain rooted in System.Object. Nothing about a delegate is compiler magic that stops at the IL boundary; it's a first-class .NET type you can inspect, reflect over, and reason about exactly like any other object.
Every delegate type declared with the delegate keyword — including the compiler-generated ones behind Action and Func — compiles to a sealed class deriving from System.MulticastDelegate, which itself derives from System.Delegate, which derives from System.Object:
System.Object
└── System.Delegate (abstract)
└── System.MulticastDelegate (abstract)
└── YourDelegateType (sealed, compiler-generated)
Every delegate type in C# — every single one — derives from MulticastDelegate, not directly from Delegate. There is no such thing, in current C#, as a "single-cast-only" delegate; the multicast capability (an internal invocation list, +=/-=, sequential invocation of every subscriber) is baked into the base class every delegate type inherits from, whether you ever actually add more than one subscriber or not.
+=/-= add/remove subscribersObject → Delegate → MulticastDelegate → your type+=/-= aren't automatically thread-safe"Delegates call the methods you assign to them" is true, and it's enough to write correct code in the happy path. It stops being enough the moment you hit any of these, all of which are ordinary production scenarios rather than edge cases:
+= broken, or is something else going on?Func<Dog>, but the caller's variable is typed Func<Animal> — and it compiles, with no cast. Is that a hole in the type system, or something principled?Every one of those questions has a precise, correct answer — but only if you know what a delegate instance actually contains, how MulticastDelegate invokes its list, and how C#'s generic variance rules (which you already studied in depth in lesson 181) apply to delegate types specifically. That's the gap this lesson closes.
None of what follows is a new feature bolted onto delegates. It's the same mechanism from 096/097, examined closely enough to explain why it behaves the way it does — which is exactly what lets you predict its behavior under conditions you haven't personally tested yet.
null for a static method)This is the shape every delegate has, regardless of whether it's a custom type, Action<T>, or Func<T, TResult>.
op = Add;) has an invocation list — it just has exactly one entry.GetInvocationList() returns that list as a Delegate[], letting you inspect or manually drive it.notify += LogToFile; // entry 1 — runs fine
notify += SendEmail; // entry 2 — throws
notify += UpdateMetrics; // entry 3 — NEVER RUNS
notify(); // LogToFile runs, SendEmail throws,
// the exception propagates out of notify() immediately,
// UpdateMetrics is skipped entirely
foreach (Action handler in notify.GetInvocationList().Cast<Action>())
{
try { handler(); }
catch (Exception ex) { LogFailure(handler, ex); }
}
using System;
class Program
{
static void Main()
{
Action pipeline = Step1;
pipeline += Step2; // throws
pipeline += Step3;
Console.WriteLine($"Invocation list has {pipeline.GetInvocationList().Length} entries.");
// 3 — every subscriber is registered, regardless of what happens later
try
{
pipeline(); // Step1 runs, Step2 throws, Step3 NEVER RUNS
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Pipeline aborted: {ex.Message}");
}
Console.WriteLine("--- Running defensively instead ---");
foreach (Action step in pipeline.GetInvocationList().Cast<Action>())
{
try { step(); }
catch (InvalidOperationException ex) { Console.WriteLine($"Step failed, continuing: {ex.Message}"); }
}
// Step1, then Step2 fails but is caught, then Step3 DOES run this time
}
static void Step1() => Console.WriteLine("Step1 ran.");
static void Step2() => throw new InvalidOperationException("Step2 failed.");
static void Step3() => Console.WriteLine("Step3 ran.");
}
Code → Meaning → Result: GetInvocationList().Length proves all three subscribers were registered — the failure isn't in registration, it's in invocation. Calling pipeline() directly stops dead after Step2. Only the manual foreach-with-try/catch loop gives every subscriber a turn, because that loop — not the delegate itself — is what supplies the fault isolation.
An order-processing service raises an internal "order placed" notification that three independent, unrelated subsystems subscribe to: inventory reservation, email confirmation, and analytics. If the email provider has an outage, inventory reservation and analytics should still run — one subsystem's failure shouldn't silently disable the other two.
using System;
using System.Linq;
public record Order(int Id, decimal Total);
public class OrderPublisher
{
private Action<Order>? _onOrderPlaced;
public void Subscribe(Action<Order> handler) => _onOrderPlaced += handler;
public void PublishOrderPlaced(Order order)
{
if (_onOrderPlaced is null) return;
// Drive the invocation list manually so one failing subscriber
// never prevents the others from running.
foreach (Action<Order> handler in _onOrderPlaced.GetInvocationList().Cast<Action<Order>>())
{
try
{
handler(order);
}
catch (Exception ex)
{
Console.WriteLine($"Subscriber failed for order {order.Id}: {ex.Message} — continuing with remaining subscribers.");
}
}
}
}
class Program
{
static void Main()
{
var publisher = new OrderPublisher();
publisher.Subscribe(o => Console.WriteLine($"Inventory reserved for order {o.Id}."));
publisher.Subscribe(o => throw new InvalidOperationException("Email provider timeout."));
publisher.Subscribe(o => Console.WriteLine($"Analytics recorded for order {o.Id}, total {o.Total:C}."));
publisher.PublishOrderPlaced(new Order(1042, 89.99m));
// Inventory reserved for order 1042.
// Subscriber failed for order 1042: Email provider timeout. — continuing with remaining subscribers.
// Analytics recorded for order 1042, total $89.99.
}
}
This is exactly the gap between "how delegates behave by default" and "how a resilient pub/sub mechanism needs to behave" — and it's why real event-raising code in production systems often loops over GetInvocationList() rather than invoking the delegate directly, whenever independent subscribers genuinely shouldn't be able to break each other.
A multicast delegate's default invocation is a relay race: the baton (control flow) passes from runner to runner (subscriber to subscriber) in a fixed order, on one track (one thread). If runner #2 trips and doesn't get up, runner #3 never gets the baton — the race simply stops there. Nobody radios ahead to say "skip #2, go straight to #3."
Driving GetInvocationList() yourself with a try/catch per entry is like giving every runner their own independent track: if one falls, the others still finish their own leg, because nothing about their run depended on the failed one physically handing anything off.
Delegate.Combine (what += compiles to) and Delegate.Remove (-=) don't mutate anything — each call produces a brand-new delegate instance with a new invocation list, leaving the old instance untouched.Delegate.Combine on the same two delegate objects at the same instant don't corrupt either input.Combine/Remove themselves — it's in the read-modify-write sequence that field += handler; expands to: read the current value of field, combine it with handler, then write the result back into field. That's three separate steps, not one atomic operation.// Two threads run this concurrently on the SAME field, with no lock:
_handlers += HandlerA; // Thread 1
_handlers += HandlerB; // Thread 2
_handlers before either writes back. Thread 1 computes "original + A" and writes it. Thread 2, still holding the stale original it read earlier, computes "original + B" and writes that — silently overwriting Thread 1's update. HandlerA is gone, with no exception, no warning.int field without Interlocked or a lock.lock, or use Interlocked.CompareExchange in a retry loop — the same tools you'd reach for around any other shared mutable field.Lesson 181 showed that IEnumerable<out T> is covariant because T only ever appears in output positions, and IComparer<in T> is contravariant because T only ever appears in input positions. Delegate types support the exact same generic variance mechanism — because a delegate, structurally, is also just a pure contract (a signature) with no storage of its own, the same proof from 181 applies unchanged:
public delegate TResult Producer<out TResult>(); // TResult is output-only → covariant
public delegate void Consumer<in T>(T item); // T is input-only → contravariant
class Animal { }
class Dog : Animal { }
Producer<Dog> dogFactory = () => new Dog();
Producer<Animal> animalFactory = dogFactory; // covariant — a Dog producer IS a valid Animal producer
Consumer<Animal> animalHandler = a => Console.WriteLine("handled");
Consumer<Dog> dogHandler = animalHandler; // contravariant — anything that can handle any Animal can handle a Dog
This isn't a special case invented for delegates — it's literally why System.Func<out TResult> (single type parameter) is declared covariant in the BCL, and it's exactly why Func<Dog> dogFactory = ...; Func<Animal> animalFactory = dogFactory; compiles with no cast. The next lesson looks at Func and Action's actual BCL variance annotations directly.
There is no hidden try/catch anywhere in MulticastDelegate's invocation loop. If you want fault isolation between subscribers, you write it — via GetInvocationList() and your own loop, as shown above. Nothing about declaring a delegate, an Action, or even a C# event changes this default.
This is a genuinely easy conflation. Immutability of the delegate object only guarantees you never corrupt an in-flight invocation by adding a subscriber elsewhere — it says nothing about whether reading, combining, and writing back a field concurrently is safe. Those are two different claims, and only the first one is automatically true.
Producer<Animal> x = dogFactory; works because the delegate type itself is generically variant — not because Dog silently converts to Animal in some new way. The underlying reference conversion (Dog → Animal) has always been legal; what 181 and this lesson establish is that the compiler can prove it's safe to apply that same conversion one level up, to the generic type argument of an interface or delegate, precisely because of where the type parameter appears in the signature.
Wrapping the entire notify() call in one outer try/catch and assuming "no exception reached here" means every subscriber executed successfully.
If a later subscriber's failure to run is itself a problem worth knowing about (not just "swallow and move on"), drive GetInvocationList() yourself so you can observe and log each subscriber's outcome individually.
publicField += handler; called concurrently from several threads, with no lock and no Interlocked, then being surprised when a subscriber silently goes missing under load.
// Unsafe under concurrent access
public Action? OnComplete;
// Safe — guard the read-modify-write with a lock
private readonly object _lock = new();
private Action? _onComplete;
public void Subscribe(Action handler)
{
lock (_lock) { _onComplete += handler; }
}
Treat a delegate field shared across threads exactly like any other shared mutable field — guard reads and writes with a lock (or Interlocked.CompareExchange in a retry loop) rather than trusting +=/-= alone.
public delegate T Transformer<in T>(T input); — this won't compile, because T appears in both an input position (the parameter) and an output position (the return type), which violates the "used exclusively one direction" rule from 181 that applies identically to delegates.
Only mark a type parameter out if it's used exclusively as output across the whole delegate signature, and in only if it's used exclusively as input — exactly the same rule you already learned for interfaces.
GetInvocationList() and manual per-subscriber invocation whenever independent subscribers genuinely must not be able to break each other — notification fan-out, plugin hooks, audit/logging chains.lock (or use it via a proper C# event, whose compiler-generated add/remove accessors handle this for you — covered in a later lesson) any time subscription can happen from more than one thread.out/in) when you're designing a reusable factory- or handler-shaped delegate and want callers to substitute more/less derived type arguments without casting — the same judgment call as designing a variant interface.Object → Delegate → MulticastDelegate → your type.+=/-= = safe delegate objects, but an unsafe field update without a lock.System.MulticastDelegate → System.Delegate → System.Object. A delegate instance holds a target reference (or null), a method pointer, and an invocation list — even with one subscriber.GetInvocationList() plus your own try/catch per entry when independent subscribers must not be able to block each other.Delegate.Combine/Remove are safe in isolation, but field += handler; is a read-modify-write on the field — unsynchronized concurrent access can silently lose a subscriber.out), and its parameter types are contravariant (in) — which is exactly why Func<Dog> assigns to Func<Animal> with no cast.You've gone from "delegates hold methods" to precise reasoning about invocation, thread-safety, and variance. Let's confirm it landed.
1. A delegate field has three subscribers, A, B, and C, added in that order. Invoking the delegate directly (myDelegate();) causes B to throw an uncaught exception. What happens to C?
Correct: B
Why B is correct: MulticastDelegate invocation is an unprotected sequential loop. When B throws, the exception propagates out of the direct call immediately — the loop never reaches C. This is documented, correct .NET behavior, not a bug.
Why A is incorrect: There is no automatic fault isolation anywhere in default multicast invocation — you must build it yourself with GetInvocationList() and a per-entry try/catch.
Why C is incorrect: Invocation order is strictly the order subscribers were added; nothing reorders around an exception.
Why D is incorrect: There is no built-in retry mechanism in delegate invocation.
Reinforcement: "One throw stops everyone after it" is the single most important production gotcha about multicast delegates.
2. Two threads concurrently execute _handlers += SomeHandler; on the same field, with no lock and no Interlocked usage. What is the realistic risk?
Correct: C
Why C is correct: += expands to read-current-value, combine, write-back — three separate steps. Two threads can both read the same original value before either writes, so the second write silently discards the first thread's update. This is a classic lost-update race condition.
Why A is incorrect: Delegate.Combine itself doesn't corrupt anything, but that safety doesn't extend to the field's read-modify-write sequence around it — that's the actual source of the race.
Why B is incorrect: Nothing about this race throws or crashes — it fails silently, which is precisely what makes it dangerous and hard to diagnose.
Why D is incorrect: The field ends up holding a valid delegate — just possibly missing one of the two handlers that should have been added.
Reinforcement: Delegate object immutability protects the objects, not the field holding a reference to one — synchronize the field yourself under concurrent access.
3. Given public delegate TResult Producer<out TResult>();, why does Producer<Animal> p = someDogProducer; (where someDogProducer is a Producer<Dog>) compile without a cast?
Correct: B
Why B is correct: Marking TResult as out is only legal because it's used exclusively in output position across the delegate's signature — exactly the structural proof from lesson 181 that makes IEnumerable<out T> sound. A Producer<Dog> can never produce anything a caller expecting an Animal couldn't accept.
Why A is incorrect: Unrelated delegate types (like Predicate<T> and Func<T, bool>) are not implicitly convertible to each other — only generically variant type arguments of the same delegate type are.
Why C is incorrect: This conversion is checked entirely at compile time with zero runtime cast — that's precisely what makes it provably safe rather than merely hopeful.
Why D is incorrect: A shared base class alone means nothing without the out/in variance annotation — an invariant generic delegate with the same type hierarchy would not support this assignment.
Reinforcement: Delegate variance is the identical output-only/input-only proof from interface variance, just applied to a delegate's parameter and return positions.
4. You need every independent subscriber on a notification delegate to run, even if one throws. Which approach correctly achieves this?
Correct: C
Why C is correct: Only driving the invocation list yourself gives you a chance to catch each subscriber's exception individually and continue to the next one — that's the one place fault isolation can actually be introduced.
Why A is incorrect: One outer try/catch stops the exception from crashing the caller, but it doesn't let any subscriber after the failing one run — the loop already aborted before the catch is reached.
Why B is incorrect: volatile affects memory visibility across threads, not exception propagation during invocation — it's unrelated to this problem.
Why D is incorrect: The event keyword restricts who can invoke and subscribe from outside the declaring type — it does not change the underlying invocation loop's exception behavior at all.
Reinforcement: Fault isolation between subscribers is something you build explicitly with GetInvocationList() — it is never automatic, regardless of delegate vs. event.
You can now reason precisely about multicast invocation failure, delegate field thread-safety, and delegate variance. Next: a close look at Action and Func's actual BCL declarations — including the variance annotations you just saw in action here.
dotnetmadeeasy.com — Learn C# and .NET, the right way.