public event EventHandler Changed; is one of the shortest declarations in C#, and one of the most heavily desugared. The CLR has no concept of event as a storage location. What it has is an Event metadata row that names a delegate type and points at two methods, and a convention that the C# compiler fills in around it: a hidden field to hold the delegate, an accessor that adds a handler, and an accessor that removes one. The accessor bodies are where it gets interesting, because the compiler has changed its mind about how to write them, and both versions are in the wild.
A decompiler has to fold four metadata entities back into the one line you wrote, and it can only do that if it recognises the accessor bodies as the compiler's rather than yours. Get that wrong and the output either shows you a field plus two methods with a strange loop in them, or — worse — collapses a hand-written add/remove pair that does something important into a one-liner that hides it.
Four pieces of metadata
Compile this:
public sealed class Counter
{
public event EventHandler Changed;
}
and the module contains the following, which ildasm shows in a form that already hints at the structure:
.field private class [System.Runtime]System.EventHandler Changed
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.method public hidebysig specialname instance void
add_Changed(class [System.Runtime]System.EventHandler 'value') cil managed { ... }
.method public hidebysig specialname instance void
remove_Changed(class [System.Runtime]System.EventHandler 'value') cil managed { ... }
.event [System.Runtime]System.EventHandler Changed
{
.addon instance void Counter::add_Changed(class [System.Runtime]System.EventHandler)
.removeon instance void Counter::remove_Changed(class [System.Runtime]System.EventHandler)
}
In the metadata tables that is one row in Event (0x14) holding the name and the delegate TypeDefOrRef, one row in EventMap (0x12) linking the owning type to its run of events, and two rows in MethodSemantics (0x18) whose Semantics flags are AddOn (0x0008) and RemoveOn (0x0010), each pointing at an accessor method. The field is an ordinary Field row — nothing in the metadata ties it to the event except its name and the fact that the accessors use it.
The accessor body since C# 4: a lock-free loop
Here is the complete add_Changed as the current compiler emits it:
.method public hidebysig specialname instance void
add_Changed(class [System.Runtime]System.EventHandler 'value') cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 )
.maxstack 3
.locals init (class [System.Runtime]System.EventHandler V_0, // current
class [System.Runtime]System.EventHandler V_1, // snapshot
class [System.Runtime]System.EventHandler V_2) // combined
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.EventHandler Counter::Changed
IL_0006: stloc.0 // current = this.Changed
IL_0007: ldloc.0
IL_0008: stloc.1 // snapshot = current
IL_0009: ldloc.1
IL_000a: ldarg.1
IL_000b: call class [System.Runtime]System.Delegate
[System.Runtime]System.Delegate::Combine(class Delegate, class Delegate)
IL_0010: castclass [System.Runtime]System.EventHandler
IL_0015: stloc.2 // combined = snapshot + value
IL_0016: ldarg.0
IL_0017: ldflda class [System.Runtime]System.EventHandler Counter::Changed
IL_001c: ldloc.2
IL_001d: ldloc.1
IL_001e: call !!0 [System.Threading]System.Threading.Interlocked::CompareExchange
<class [System.Runtime]System.EventHandler>(!!0&, !!0, !!0)
IL_0023: stloc.0 // current = CAS(ref field, combined, snapshot)
IL_0024: ldloc.0
IL_0025: ldloc.1
IL_0026: bne.un.s IL_0007 // if field had changed, retry
IL_0028: ret
}
Read it as the C# it stands for:
void add_Changed(EventHandler value)
{
EventHandler current = Changed, snapshot;
do
{
snapshot = current;
var combined = (EventHandler)Delegate.Combine(snapshot, value);
current = Interlocked.CompareExchange(ref Changed, combined, snapshot);
}
while (current != snapshot);
}
The loop exists because delegates are immutable: Combine returns a new multicast delegate, and if two threads subscribe at the same moment, a naive read-combine-write loses one of them. CompareExchange publishes the new delegate only if the field still holds the snapshot it was computed from; otherwise it returns the field's actual value and the loop recomputes from that. remove_Changed is byte-for-byte the same with Delegate::Remove in place of Combine.
Note the generic instantiation CompareExchange<EventHandler>(!!0&, !!0, !!0) — the !!0 tokens are the method's own generic parameter. A decompiler pattern-matching this body must match on the shape (ldfld, loop, Combine/Remove, castclass, ldflda, generic CompareExchange, bne.un back to the snapshot store) rather than on specific tokens, since the delegate type, the field, and the method's maxstack/local ordering all vary.
Static events use the same loop with ldsfld/ldsflda and no ldarg.0.
The older shape: [MethodImpl(Synchronized)]
Assemblies compiled with the C# 1–3 compilers — still plentiful in long-lived codebases and third-party references — carry a different accessor:
.method public hidebysig specialname instance void
add_Changed(class [mscorlib]System.EventHandler 'value') cil managed synchronized
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldarg.0
IL_0002: ldfld class [mscorlib]System.EventHandler Counter::Changed
IL_0007: ldarg.1
IL_0008: call class [mscorlib]System.Delegate
[mscorlib]System.Delegate::Combine(class Delegate, class Delegate)
IL_000d: castclass [mscorlib]System.EventHandler
IL_0012: stfld class [mscorlib]System.EventHandler Counter::Changed
IL_0017: ret
}
The synchronized flag in the method header (MethodImplOptions.Synchronized) makes the runtime take a lock on this — or on the type object, for static events — around the whole body, so the plain ldfld/Combine/stfld is safe. It was replaced because locking on this is a public lock anyone can contend, and because the lock is far more expensive than a CAS that almost never retries.
Both bodies mean exactly the same thing: this is the compiler's field-like event. A decompiler that only recognises the modern loop will show the older assembly's event as a field, two methods, and a mysterious synchronized flag.
Raising: the shapes of ?.Invoke
The subscription side is only half the story. The author also raises the event, and C# has had three idioms, each with its own IL. The modern one:
Changed?.Invoke(this, EventArgs.Empty);
ldarg.0
ldfld class [System.Runtime]System.EventHandler Counter::Changed
dup
brtrue.s INVOKE
pop // null: discard the duplicate and skip
br.s END
INVOKE:
ldarg.0
ldsfld class [System.Runtime]System.EventArgs [System.Runtime]System.EventArgs::Empty
callvirt instance void [System.Runtime]System.EventHandler::Invoke(object, class EventArgs)
END:
The dup/brtrue/pop triple is the null-conditional operator's fingerprint: load once, test the copy, use the original. The pre-C# 6 idiom reads the field into a local and tests the local — a stloc/ldloc/brfalse instead of dup/brtrue/pop — and is semantically identical. The third form is the one with no test at all:
Changed(this, EventArgs.Empty); // throws if nobody subscribed
which is a bare ldfld + callvirt Invoke. A decompiler should render each of these the way it was written, because the difference is information: the bare form in a decompiled dependency is a NullReferenceException waiting for a subscriber-less call, and the local-copy form in a modern codebase tells you the file predates C# 6. Folding all three to ?.Invoke would be tidier and wrong.
When the author wrote the accessors
The folding rule has a precondition: the accessors must be the compiler's. Classes that expose many events — WinForms controls are the classic case — avoid allocating a field per event by keeping subscribers in an EventHandlerList keyed by a static object:
private static readonly object ChangedKey = new();
public event EventHandler Changed
{
add => Events.AddHandler(ChangedKey, value);
remove => Events.RemoveHandler(ChangedKey, value);
}
There is no Changed field, no [CompilerGenerated] on the accessors, and the bodies are a ldsfld ChangedKey / ldarg.1 / callvirt EventHandlerList::AddHandler. The Event row and MethodSemantics rows look identical to the field-like case — the metadata genuinely cannot tell you who wrote the accessors. Only the body can. A decompiler that sees anything other than the two known shapes must emit add { … } / remove { … } verbatim, because whatever is in there — a key lookup, a forwarding call, a weak-reference registration — is behaviour the author chose.
Two more variants round out the family. An explicit interface implementation of an event has accessors named IObservable.add_Changed with the .event row's .addon pointing at them and a .override directive, and must render as event EventHandler IObservable.Changed { add … remove … } — explicit implementations can't be field-like. An abstract or interface event has an Event row and accessor declarations with no bodies at all, and renders as event EventHandler Changed; with the abstract modifier — the same text as a field-like event, distinguished only by the missing field and the abstract flag on the accessors.
What Glass.NET emits
Put the recognition rules together and the obligation for a field-like event is clear. Stopping at the metal shows this:
// Wrong: four entities rendered literally
[CompilerGenerated] private EventHandler Changed;
[CompilerGenerated]
public void add_Changed(EventHandler value)
{
EventHandler current = Changed, snapshot;
do { snapshot = current;
current = Interlocked.CompareExchange(ref Changed,
(EventHandler)Delegate.Combine(snapshot, value), snapshot); }
while (current != snapshot);
}
// ... remove_Changed, and an event declaration that references both
while recovering the author's intent yields:
// Right: the line the author wrote
public event EventHandler Changed;
Glass.NET matches both the lock-free loop and the synchronized shape as field-like, folds the field, the accessors and the Event row into one declaration, and keeps hand-written add/remove bodies exactly as written — including the EventHandlerList pattern, explicit interface events and abstract events. At the raise site it preserves which of the three idioms the code used, so a ?.Invoke, a local-copy check or a bare invoke each read back as themselves. The event keyword was always a source-level fiction over an Event row and two methods; the job is to put the fiction back without losing the one case where the methods weren't fiction.
See metadata tables explained for the Event, EventMap and MethodSemantics tables this reads, and lambdas and closures for the delegate instances that usually end up on the other side of +=.
Top comments (0)