C# Delegates and Events, Re-tested on .NET 10

C# delegates and events on .NET 10: what event blocks, EventHandler<T>, unsubscribe leaks, thread-safe ?.Invoke, and measured multicast cost.

Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled its three examples in a default .NET 10.0.14 console project (C# 14, nullable reference types on). All three still compile and print the same output, but the Delegate.Combine example now produces warnings CS8600 and CS8602, the event example produces warning CS8618, and that event example crashes with NullReferenceException when nobody has subscribed. The original explanation also cited line numbers that did not match the code. Below are the fixed examples, what the event keyword actually protects, unsubscribing and leaks, thread-safe raising, and a measurement of multicast cost.

This article is for developers who know the delegate and event syntax but are not sure whether an event is "just a delegate", or whether they really need to unsubscribe. In short: a delegate is an object holding a method and the instance to call it on, and an event is a delegate wrapped so that code outside the class can only += and -=. In production code, the problems are rarely the syntax. They are null handling, forgotten unsubscriptions and races between threads. For Func/Action details (chains that return values, out parameters, call cost against interfaces), see our Func and Action delegates article; this one does not repeat them.

A delegate is a method you can pass around

A delegate type is declared like a method signature with the delegate keyword in front:

delegate ReturnType Name(parameters);

The 2021 example compiles on .NET 10 with no warnings and prints the same values. Here it is shortened to top-level statements, with a lambda added for comparison:

PDelegate pd1 = Plus;                                         // method group
PDelegate pd2 = delegate (int a, int b) { return a / b; };    // anonymous method (C# 2)
PDelegate pd3 = (a, b) => a / b;                              // lambda (C# 3), same meaning
Console.WriteLine($"{pd1(5, 10)} {pd2(10, 5)} {pd3(10, 5)}");

static int Plus(int a, int b) => a + b;
delegate int PDelegate(int a, int b);
15 2 2

The original described a delegate as "an area that contains a method reference". More precisely, it is an instance of a class derived from System.MulticastDelegate. It remembers which method to call (Method) and, for instance methods, which object to call it on (Target). That Target reference is what causes the memory leak covered below. The rule that parameter types, parameter count and return type must match the delegate is still exactly right. For the lambda syntax itself, see C# lambda expressions.

Multicast delegates, and what they cost (measured)

One delegate can hold several methods and call them in order. The 2021 post used Delegate.Combine:

PDelegate pd = (PDelegate)Delegate.Combine(new PDelegate(Plus), new PDelegate(Minus),
    new PDelegate(Division), new PDelegate(Multiplication));
pd(20, 10);

The output was unchanged (20 + 10 = 30 through 20 * 10 = 200), but a nullable-enabled project now warns twice:

Program.cs(22,28): warning CS8600: Converting null literal or possible null value to non-nullable type.
Program.cs(23,13): warning CS8602: Dereference of a possibly null reference.

Delegate.Combine returns Delegate?. Use += instead; the compiler emits the same Delegate.Combine call, with no cast and no warning:

PDelegate pd = Plus;
pd += Minus;
pd += Division;
pd += Multiplication;
pd(20, 10);
20 + 10 = 30
20 - 10 = 10
20 / 10 = 2
20 * 10 = 200

Three multicast traps (only the last return value survives, an exception stops the rest of the chain, and -= with a lambda removes nothing) are demonstrated in the Func and Action article.

Is a multicast call expensive? We invoked an empty static method marked NoInlining 10 million times. Method: .NET 10.0.14, Release build, Stopwatch, 10 million iterations, one warm-up pass then three timed runs, on an Apple Silicon Mac (M1 Pro).

Action<int> single = Sink.A;
Action<int> multi = null!;
for (int k = 0; k < 10; k++) multi += Sink.A;      // 10 handlers
var array = new Action<int>[10];                   // 10 separate delegates
…
for (int i = 0; i < N; i++) multi(i);
for (int i = 0; i < N; i++) foreach (var d in array) d(i);
for (int i = 0; i < N; i++) foreach (var d in multi.GetInvocationList()) ((Action<int>)d)(i);
Invocation Run 1 Run 2 Run 3
1 handler 24.3 ms 24.5 ms 24.9 ms
multicast, 10 handlers 241.3 ms 239.7 ms 245.5 ms
array of 10 delegates 241.2 ms 241.5 ms 247.9 ms
loop over GetInvocationList(), 10 881.5 ms 917.0 ms 893.1 ms
Bar chart of time for 10 million invocations: one handler 24.6 ms; a multicast delegate with 10 handlers 242.2 ms; 10 separate delegates in an array 243.5 ms; looping over GetInvocationList with 10 handlers 897.2 ms.
10 million invocations, average of three timed runs. .NET 10.0.14, Release, Stopwatch, Apple Silicon Mac (M1 Pro). Lower is better.

Multicast cost scaled linearly with the number of handlers (about 2.4 ns each) and matched a hand-written loop over an array. There is no hidden penalty. GetInvocationList() is the expensive part: it builds a new array on every call. With 10 handlers it allocated 104 bytes per call (measured with GC.GetAllocatedBytesForCurrentThread; a normal multicast call allocated 0) and took about 3.7 times as long. Use it only when each handler needs its own try/catch, and avoid it for events that fire tens of thousands of times per second.

What the event keyword actually blocks

An event announces that something happened, and in C# it is built on a delegate. The 2021 example counts from 1 to 49 and raises an event on every multiple of 10:

public delegate void MyEventHandler(string message);
class Publisher {
    public event MyEventHandler Active;
    public void DoActive(int number) {
        if (number % 10 == 0) Active("Active!" + number);
        else Console.WriteLine(number);
    }
}
// Main: publisher.Active += new MyEventHandler(MyHandler); then DoActive(1..49)

The output matched the original (1 … 9, Active!10, … Active!40, … 49), with one new warning:

Program.cs(9,37): warning CS8618: Non-nullable event 'Active' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the event as nullable.

That warning points at a real bug. With no subscribers the backing field is null, so calling DoActive(9) and then DoActive(10) on a fresh Publisher ends like this:

9
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Publisher.DoActive(Int32 number) in Program.cs:line 10

The fix has two parts: declare public event MyEventHandler? Active; so the type says "there may be no subscribers", and raise it with Active?.Invoke("Active!" + number);. With both changes it compiled without warnings, printed 9, and exited normally.

So what would change if we dropped event and used a public delegate field? We tried both from outside the class:

var c = new Counter();
c.Changed += (s, e) => Console.WriteLine("A");
c.Changed = null;                      // event: overwrite
c.Changed(c, EventArgs.Empty);         // event: raise from outside

var f = new FieldCounter();
f.Changed += (s, e) => Console.WriteLine("A");
f.Changed += (s, e) => Console.WriteLine("B");
f.Changed = (s, e) => Console.WriteLine("C");   // A and B are gone
f.Changed(f, EventArgs.Empty);                  // anyone can raise it

class Counter { public event EventHandler? Changed; }
class FieldCounter { public EventHandler? Changed; }
Program.cs(3,3): error CS0070: The event 'Counter.Changed' can only appear on the left hand side of += or -= (except when used from within the type 'Counter')
Program.cs(4,3): error CS0070: The event 'Counter.Changed' can only appear on the left hand side of += or -= (except when used from within the type 'Counter')

With the two event lines removed, the program compiled and printed only C. On the field, one = silently dropped subscribers A and B, and any class could send a fake "it happened" notification. Blocking exactly those two things, overwriting and raising from outside, is the job of event.

The standard pattern with EventHandler

Instead of declaring your own delegate type, the .NET convention is EventHandler<TEventArgs>. The signature (object? sender, TEventArgs e) carries both who raised the event and the data, and you can add data later by adding a property to the EventArgs subclass without breaking subscribers.

var counter = new Counter();
EventHandler<ThresholdEventArgs> onHit = (sender, e) =>
    Console.WriteLine($"hit {e.Value} (sender: {sender?.GetType().Name})");

counter.ThresholdReached += onHit;
for (int i = 1; i <= 25; i++) counter.Add(i);   // 10, 20
counter.ThresholdReached -= onHit;
counter.Add(30);                                 // nobody listening: no exception
Console.WriteLine("done");

public sealed class ThresholdEventArgs(int value) : EventArgs
{
    public int Value { get; } = value;
}

public class Counter
{
    public event EventHandler<ThresholdEventArgs>? ThresholdReached;

    public void Add(int n)
    {
        if (n % 10 == 0) OnThresholdReached(new ThresholdEventArgs(n));
    }

    protected virtual void OnThresholdReached(ThresholdEventArgs e)
        => ThresholdReached?.Invoke(this, e);
}
hit 10 (sender: Counter)
hit 20 (sender: Counter)
done

The primary constructor on ThresholdEventArgs is C# 12 syntax. The protected virtual void OnEventName(...) method is also a convention: derived classes can override it to intercept or extend raising. When you subscribe a lambda, keep it in a variable as above and unsubscribe with that same instance, or nothing is removed.

Diagram: the publisher's Add method calls ThresholdReached?.Invoke(this, e). The event holds an invocation list. Subscriber A and subscriber B, added with +=, are called in order. Subscriber C was removed with -=, so the dashed link shows it is no longer called and the garbage collector can reclaim it.
A publisher raises its event; only the handlers currently in the invocation list run. A handler removed with -= is no longer called and no longer kept alive by the publisher.

Forgetting to unsubscribe leaks memory

An event holds a reference to each handler and its target object. So when the publisher lives longer than the subscriber, the subscriber cannot be collected even when nothing else uses it. The classic cases are an app-wide event bus, a static event, or a singleton service that screens or view models subscribe to. We checked it:

var bus = new Bus();                                  // long-lived publisher
Console.WriteLine($"forgot -= : alive = {Run(bus, unsubscribe: false)}");
Console.WriteLine($"called -= : alive = {Run(bus, unsubscribe: true)}");
GC.KeepAlive(bus);

[MethodImpl(MethodImplOptions.NoInlining)]
static bool Run(Bus bus, bool unsubscribe)
{
    var weak = Open(bus, unsubscribe);
    GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
    return weak.IsAlive;
}

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference Open(Bus bus, bool unsubscribe)
{
    var screen = new Screen(bus);                     // short-lived subscriber
    if (unsubscribe) screen.Dispose();
    return new WeakReference(screen);
}

class Bus { public event EventHandler? Tick; }

class Screen : IDisposable
{
    private readonly Bus _bus;
    private readonly byte[] _buffer = new byte[1_000_000];
    public Screen(Bus bus) { _bus = bus; _bus.Tick += OnTick; }
    private void OnTick(object? s, EventArgs e) { }
    public void Dispose() => _bus.Tick -= OnTick;
}
forgot -= : alive = True
called -= : alive = False

(Release build; using System.Runtime.CompilerServices; omitted. Bus never raises its event, so you get warning CS0067, which does not affect the test.) The 1 MB Screen that forgot -= survived a forced collection; the one that unsubscribed in Dispose was collected. Open and close a screen a few hundred times and these pile up, and the closed screens' handlers keep running, which is often the first visible symptom. The rule: if the subscriber lives shorter than the publisher, unsubscribe in the matching place (Dispose, screen close). When both have the same lifetime, such as a form and a button on it, they are collected together and unsubscribing is optional.

Why ?.Invoke is the thread-safe way to raise

A common pattern until a few years ago was if (Changed != null) Changed(this, e);. It reads the field twice. If another thread removes the last subscriber in between, the second read invokes null. We had one thread subscribe and unsubscribe in a loop while another raised the event 20 million times each way:

class Pub
{
    public event EventHandler? Changed;
    public void RaiseUnsafe() { if (Changed != null) Changed(this, EventArgs.Empty); }
    public void RaiseSafe() => Changed?.Invoke(this, EventArgs.Empty);
}
// thread A: p.Changed += h; p.Changed -= h;  20 million times
// thread B: RaiseUnsafe() and RaiseSafe() 20 million times each, counting NullReferenceException
Build if (Changed != null) Changed(...) Changed?.Invoke(...)
Debug, run 1 113 0
Debug, run 2 512 0
Release, 2 runs 0, 0 0, 0

?.Invoke copies the field into a temporary once, then checks and calls that copy, so this race cannot happen. The Release result of 0 for the unsafe form most likely means the JIT merged the two reads. That is not guaranteed, so do not rely on it. Subscribing is also thread-safe: for a field-like event the compiler generates add/remove accessors with an Interlocked.CompareExchange loop. With 4 threads adding 2,000 handlers each, the event ended with exactly 8,000 handlers; the same code on a public delegate field kept only 5,416, and 5,507 on a second run, because concurrent read-combine-write lost updates.

?.Invoke only prevents the null call. It invokes the list it copied, so a handler can still run once just after it was removed on another thread. Handlers that touch disposed resources should check for that themselves.

Choosing the right tool

Situation Use
Pass a method as a callback Func/Action (details)
Notify any number of listeners that something happened event EventHandler<TEventArgs>?
Raise the event protected virtual void OnXxx(e) => Xxx?.Invoke(this, e);
Subscriber lives shorter than the publisher always -= in Dispose or on close
Subscribing a lambda keep it in a variable; -= the same instance
Each handler needs its own error handling loop over GetInvocationList() (allocates per call)
Is an array of delegates faster than multicast? no measurable difference; pick the clearer one

Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14, macOS on Apple Silicon (M1 Pro).

FAQ

What does the event keyword change compared with a public delegate field?

Outside the declaring class you can only use += and -=. Assigning c.Changed = null; or raising it with c.Changed(...) from outside fails with error CS0070. With a plain public delegate field both compile, and a single = silently removed every other subscriber in our test.

Does forgetting to unsubscribe from an event really leak memory?

Yes, when the publisher outlives the subscriber. A 1 MB subscriber that only did += on a long-lived publisher was still alive after a forced GC (IsAlive = True); the same object that called -= in Dispose was collected (False). The event keeps a reference to the handler's target object.

Isn't if (Changed != null) Changed(...) the same as Changed?.Invoke(...)?

Only on one thread. The first form reads the field twice. In a 20-million-iteration race with another thread subscribing and unsubscribing, a Debug build threw NullReferenceException 113 and 512 times; ?.Invoke, which reads the field once, threw 0 times.

  • #C#
  • #delegate
  • #event
  • #.NET

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑