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.Combineexample now produces warnings CS8600 and CS8602, the event example produces warning CS8618, and that event example crashes withNullReferenceExceptionwhen nobody has subscribed. The original explanation also cited line numbers that did not match the code. Below are the fixed examples, what theeventkeyword 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 |
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.
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.