📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a multicast delegate? Explain how += and -= build the invocation list, the order handlers run in, what happens to return values and exceptions, why delegates are thread-safe to read but events need care, and how GetInvocationList helps.

Multicast delegates: Delegate.Combine and Remove, immutable invocation lists, return values, exceptions stopping the chain, thread safety and invoking handlers individually.

Mid-Level Theory Intermediate csharp delegates events exceptions multicast

🎯 Key points

  • `+=` calls `Delegate.Combine`, which returns a new delegate with an invocation list; delegates are immutable
  • Handlers run synchronously, in subscription order, on the caller's thread
  • For non-void delegates only the last handler's return value is returned
  • If a handler throws, later handlers do not run and the exception propagates to the caller
  • `-=` removes the last matching occurrence; removing a lambda only works with the same delegate instance
  • `GetInvocationList()` lets you call each handler separately, collect results or isolate failures
  • Copy-then-invoke (`handler?.Invoke(...)`) is safe because the delegate itself is immutable

⏱ 30-second answer

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().

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. How += and -= build the list

Action<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).
  • Delegates are immutable: combining creates a new delegate with a new invocation list array; the original is unchanged.
  • Removing the last handler yields null, so always invoke with ?.Invoke.

2. Order of execution

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.

3. Return values

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.

4. Exceptions

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); }
}

5. GetInvocationList

GetInvocationList() returns a Delegate[] copy of the individual delegates. Use it to:

  • collect all return values (e.g. aggregate validators),
  • isolate exceptions per handler,
  • run handlers asynchronously or in parallel (e.g. Func<T, Task> handlers awaited with Task.WhenAll),
  • inspect subscribers when debugging leaks (handler.Target).

It allocates an array, so avoid it on very hot paths unless needed.

6. Thread safety: delegates vs events

  • Reading/invoking a delegate is thread-safe: the delegate object is immutable, so once you've read a reference you have a consistent snapshot. The idiom 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.
  • Writing (+=/-=) 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.
  • A plain public delegate field (not an event) has no such protection — and anyone can overwrite it with = or invoke it.
  • Even with events, a handler can still be invoked after it has been removed (if the raiser had already taken a snapshot) — handlers must tolerate late calls.

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Internal representation

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.

2. Combine requires exact runtime types

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.

3. Field-like event accessors

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.

4. Async handlers

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.

5. Performance

  • Invoking a multicast delegate with N handlers is N indirect calls.
  • Each +=/-= allocates a new delegate and array (O(N) copy) — fine for typical subscription patterns, not for high-frequency subscribe/unsubscribe.
  • For many subscribers or dynamic subscriptions, consider List<Action<T>> with explicit locking or an observer/mediator library.

Try it yourself

  1. Combine three Func<int> delegates returning 1, 2, 3 and print the result.
  2. Make the second handler of an Action throw and observe that the third doesn't run.
  3. Use GetInvocationList() to collect all results and isolate exceptions.
  4. Write Func<Task> handlers and compare invoking directly vs iterating and Task.WhenAll.

💻 Code

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
    }
}

⚠ Common mistakes

1. Using = 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.

2. Expecting all return values

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.

3. One failing handler breaks the rest

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.

4. Async multicast delegates

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.

5. Null invocation

What happens: invoking a delegate field after all handlers were removed. Why it is wrong: NullReferenceException. Do instead: handler?.Invoke(...).

6. Non-atomic subscription on delegate fields

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.

7. Relying on handler order

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.

8. Assuming removed handlers are never called

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).

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions public delegate fields being unsafe. Why: Events wrap multicast delegates; tests why the event keyword exists.
    How to answer

    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.

  2. When: Candidate mentions -= with lambdas not working. Why: Unsubscribing correctly is the key to avoiding event leaks.
    How to answer

    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.

🛠 Real-world problems: how you handle them

Plug-in veto ignored because only the last result counted
Situation
A document management system let plug-ins veto saves through a multicast `Func<Document, bool>`. Compliance reported that documents without mandatory metadata were being saved.
Signals
Audit logs showed the metadata plug-in returned false, yet the save went ahead; only happened when another plug-in was installed after it.
Investigation
Wrote a unit test with two plug-ins and observed the naive invoke returned the second plug-in's true. Confirmed with the C# spec that only the last return value is returned.
Resolution
Replaced the single invoke with a loop over GetInvocationList that stops at the first veto, isolates exceptions and logs the plug-in name.
Prevention
Added tests for veto ordering and failing plug-ins, and documented that extensibility points must not rely on multicast return values.

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.

↩ Usually asked after

More in Delegates and Events

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me