How to Invoke C# Delegates Safely
c# delegate invoke: Learn how to invoke C# delegates correctly, handle null checks, manage multicast behavior, and avoid common pitfalls in production code.
When you write myDelegate() in C#, the compiler translates that call into myDelegate.Invoke(). Because a delegate is a type that holds a reference to one or more methods, invocation is not as simple as a direct method call. The most common issue developers hit is a NullReferenceException when the delegate has no target. This article shows how to c# delegate invoke safely, what happens under the hood, and where the compiler and runtime differ from a regular method call.
The Basic Invocation Syntax
A delegate instance can be invoked with the same syntax you would use for a method, even if you only have a delegate variable. Consider a delegate that takes two integers and returns a sum:
public delegate int BinaryOperation(int left, int right); BinaryOperation add = (a, b) => a + b; int result = add(3, 4); // 7
The compiler treats add(3, 4) as add.Invoke(3, 4). The parenthesized call is just convenience; using .Invoke explicitly makes it clear that you are calling the delegate's method. Both forms produce identical IL.
When the delegate is null, calling it with parentheses causes a NullReferenceException. This is the first thing to guard against.
Invoking with Null Checks
Before invoking, check that the delegate is not null. The straightforward way is an explicit check:
if (add != null) { add(3, 4); }
This works but is verbose and can become error-prone in larger codebases. Modern C# provides a cleaner approach using the null-conditional operator:
add?.Invoke(3, 4);
This expression checks whether add is null; if it is, the call is skipped. If it is not null, Invoke runs. The result is int? because when the delegate is null, the expression evaluates to null. The null-conditional operator guarantees thread-safety in a sense: the check and the invocation happen atomically with respect to the delegate reference, preventing a race where another thread sets the delegate to null between the check and the call.
In a single-threaded context, an if check is sufficient. In multithreaded code, ?. is more robust because it avoids the nifty race condition:
// Race condition: another thread could set add to null after the if check if (add != null) { add(3, 4); }
Using ?. eliminates that risk at the language level.
Understanding Multicast Delegates
A delegate is a multicast delegate by default. That means you can chain multiple method references using + or +=:
Action log = () => Console.WriteLine("First"); log += () => Console.WriteLine("Second"); log?.Invoke(); // Prints both, in order
The invocation list is kept in the delegate's InvocationList property. When you call Invoke, the runtime iterates over this list in order and calls each method synchronously. If a method throws an exception, the iteration stops and the remaining methods are not called. This behavior is often surprising when you expect all subscribers to receive the event.
The exception behavior is critical for event-like patterns. In a publish-subscribe scenario, if one subscriber throws, the rest of the subscribers miss the notification. There is no built-in mechanism to continue after an exception; you must handle that yourself.
Invocation List and Manual Iteration
You can iterate the delegate's invocation list manually to control error handling. This is useful when you need to ensure each subscriber runs independently:
Action log = () => Console.WriteLine("First"); log += () => throw new InvalidOperationException("Boom"); log += () => Console.WriteLine("Third"); foreach (Action subscriber in log.GetInvocationList()) { try { subscriber(); } catch (Exception ex) { Console.WriteLine($"Subscriber failed: {ex.Message}"); } } // Prints: First, then error message, then Third
This manual loop gives you granular control. However, it changes the semantics of the delegate invocation: GetInvocationList() returns a snapshot of the invocation list at that moment, so changes made by another thread after the snapshot won't affect the iteration. The order of invocation remains the same as with Invoke.
The Return Value of a Multicast Invoke
When a multicast delegate returns a value, Invoke returns only the result of the last method in the invocation list. Earlier return values are discarded. This is often unexpected.
Func<int> f = () => 1; f += () => 2; int result = f(); // 2
If you need every return value, you must iterate the invocation list manually. This limitation also applies to ref and out parameters; each method will see the modified value from the previous call, but only the last method's modifications are available after Invoke. For most scenarios, this is acceptable, but you should know the behavior when designing event-driven APIs.
Performance and Allocation Considerations
Invoking a delegate has a slight overhead compared to a direct method call because it involves an indirect call through the delegate's method pointer. However, modern JIT compilers often devirtualize or inline the call when the delegate type is known and the target is a simple lambda. In practice, for most applications, the difference is negligible.
The bigger performance concern is DynamicInvoke. This method is part of the Delegate base class and uses reflection to invoke the delegate. It is slow because it parses the argument array, performs type checking, and may box value types.
delegate. DynamicInvoke(argsArray);
Use DynamicInvoke only when you do not know the delegate signature at compile time, such as when building a scripting engine or a plugin system. For normal code, use a typed invocation.
Compatibility and Runtime Behavior Across .NET Versions
C# delegates work consistently across .NET Framework, .NET Core, and .NET 5+. The null-conditional operator is available from C# 6.0, so it works in all modern codebases. Older versions of the compiler (pre‑2015) require explicit null checks.
The async and await pattern with delegates can be tricky. If you invoke a delegate that returns a Task, you get a Task back, but the delegate invocation itself is synchronous. For a reliable async pattern, use Func<Task> and avoid async void unless you are writing event handlers.
When writing library code, consider whether the delegate should be invoked on the caller's thread or on a different synchronization context. The Invoke method runs synchronously on the current thread. If you need asynchronous execution, use Task.Run or similar, but be careful about capturing the synchronization context.
Advanced: Using Invoke with ref and out Parameters
Delegates can have ref and out parameters, but the syntax requires you to specify the parameter modifiers explicitly. When invoking, the caller must pass variables with matching modifiers:
delegate void TryGetValue(string key, out int value); TryGetValue handler = (key, out int val) => { val = key == "a" ? 1 : 0; }; int result; handler("a", out result);
This works, but it is more fragile than returning a value. Prefer delegates that return a value or use a mutable container class when possible.
Handling Exceptions and Thread Safety
When a delegate throws during Invoke, the exception propagates to the caller. If you want to isolate subscriber errors, you must catch them inside each subscriber or use the manual iteration pattern shown earlier.
For thread safety, remember that delegates are immutable reference types. When you add or remove a method, you create a new delegate instance. Do not attempt to modify a delegate while another thread is invoking it; instead, assign a new delegate to a field using Interlocked.CompareExchange or the null-conditional safe pattern.
Choosing Between Invoke Patterns
The following table summarizes common invocation patterns and their appropriate use:
| Pattern | Use when | Note |
|---|---|---|
delegate?.Invoke(args) | General-purpose safe invocation | Skips call if null |
delegate(args) inside if (delegate != null) | Single-threaded code | Risk of race in multithreaded |
foreach over GetInvocationList() | Need per-subscriber error handling | Unordered manual iteration |
DynamicInvoke(args) | Unknown delegate signature compile-time | Slow, use sparingly |
The null-conditional pattern is usually the best default because it is concise and eliminates the most common bug: null reference on invocation. In performance-critical loops, a simple null check may be marginally faster because ?. also performs a null check, but the difference is minimal.
Production Considerations for Event Handling
In production, delegates are often used to implement events. The event keyword adds a layer of safety: from outside the class, you can only subscribe or unsubscribe; you cannot invoke the delegate directly. This prevents external code from clearing the invocation list accidentally. Inside the class, you still need to handle null and thread safety, but the event wrapper reduces misuse.
Be mindful of memory leaks due to delegates holding strong references. If a subscriber is long-lived and the publisher is short-lived, a subscriber referencing the publisher prevents garbage collection. Always unsubscribe when the subscriber is no longer needed.
Final Section: Invocation Within Asynchronous Workflows
A delegate may return a Task to represent asynchronous work. Invoking such a delegate returns the Task, but the delegate method starts executing synchronously until the first await. If you want to await the completion of the returned task, use await:
Func<Task> asyncOperation = async () => { await Task.Delay(100); }; await asyncOperation();
If the delegate is in an invocation list, await only awaits the last task returned. Other tasks are not automatically awaited. Use the manual iteration pattern to await each task in order:
Func<Task>[] handlers = ...; foreach (Func<Task> handler in handlers) { await handler(); }
When you need to run handlers in parallel, use Task.WhenAll and GetInvocationList().
Understanding the distinction between synchronous and asynchronous invocation is essential for building responsive applications. The delegate invocation itself is never asynchronous; it only returns a Task. The async and await keywords are syntactic sugar that allow you to compose those tasks. This distinction often causes confusion when delegates are used in library code that expects blocking behavior versus event-driven code that expects fire-and-forget.
Choosing the correct invocation pattern depends on whether you need deterministic ordering, exception isolation, or asynchronous continuation. The delegate model gives you the tools to implement all three, but you must be explicit about which one you are using.