Some C# statements map to a single IL instruction. using maps to a control-flow shape — a try/finally — that the compiler writes on your behalf so a resource is always cleaned up, even when the body throws. There is no using opcode; what ships is the try/finally, and a decompiler's job is to recognize that shape and fold it back into the one keyword you wrote. Walking through the lowering explains both what you see in a raw decompile and a couple of behaviours (await using, struct enumerators, the null check) that surprise people.
The basic lowering
Take the canonical case:
using (var stream = File.OpenRead(path))
{
Process(stream);
}
The compiler lowers this to a try/finally whose finally disposes the resource. Decompiled without using recognition, it reads roughly:
FileStream stream = File.OpenRead(path);
try
{
Process(stream);
}
finally
{
if (stream != null)
((IDisposable)stream).Dispose();
}
Three things to notice. The resource is acquired before the try, so a failure in the acquiring expression doesn't enter the protected region. The body goes in the try. And the finally disposes — unconditionally with respect to how the body exited (normal, return, or exception), which is the entire point of using: deterministic cleanup. The disposal is cast to IDisposable because that's the contract using targets, and it's guarded by a null check so a null resource is a harmless no-op rather than a NullReferenceException in the cleanup path.
A using declaration — the brace-less form added in C# 8 — lowers identically; the finally just runs at the end of the enclosing scope rather than a block you wrote:
using var stream = File.OpenRead(path);
Process(stream);
// finally { if (stream != null) stream.Dispose(); } runs at end of scope
Same generated shape; the decompiler decides between the block and declaration forms by where the scope ends.
The null check, and the struct exception to it
That if (stream != null) guard is a reliable tell, but it isn't always present — and its absence is informative. When the resource is a value type that implements IDisposable, the compiler does two things differently: it calls Dispose() directly on the struct (no null check — a struct is never null) and it avoids boxing it to IDisposable, calling the interface method on the value directly to keep the struct's semantics. This matters for the most common hidden using of all: foreach.
A foreach over a collection whose enumerator is a struct (like List<T>.Enumerator) lowers to a using-shaped try/finally around the enumeration, and because the enumerator is a struct, the finally calls Dispose() on it without a null check:
// foreach (var item in list) { Use(item); } lowers to:
List<int>.Enumerator e = list.GetEnumerator();
try
{
while (e.MoveNext())
Use(e.Current);
}
finally
{
e.Dispose(); // struct enumerator — direct call, no null check, no boxing
}
So when a decompiler shows you a try/finally with a MoveNext/Current loop and an unguarded Dispose on a value, that's a foreach, and the missing null check tells you the enumerator was a struct. (If the enumerator were a class, you'd see the familiar if (e != null) e.Dispose() instead.)
await using and IAsyncDisposable
The async sibling, await using, lowers to the same try/finally — but the finally awaits DisposeAsync() instead of calling Dispose():
// await using (var conn = await OpenAsync()) { await Use(conn); } lowers to (conceptually):
var conn = await OpenAsync();
try
{
await Use(conn);
}
finally
{
if (conn != null)
await conn.DisposeAsync(); // a ValueTask, awaited in the finally
}
Because there's an await in the finally, the disposal becomes part of the method's async state machine — the DisposeAsync() call produces a ValueTask that the generated MoveNext parks on like any other await. In a raw decompile this looks like a state-machine resume point inside the fault/finally region, which is exactly how a decompiler identifies it: a DisposeAsync awaited in a finally is the signature of await using, and it's reconstructed as such. The same struct-vs-class null-check rule applies.
How the decompiler folds it back
Recognizing using is pattern-matching on the generated shape, and a good decompiler is careful about it because not every try/finally is a using. The markers it looks for:
-
A
finallywhose sole effect is aDispose()/DisposeAsync()call on a resource acquired immediately before thetry. That coincidence of "acquire, try, finally-dispose" is the fingerprint. -
The null guard (or its struct-shaped absence) around the Dispose, matching the compiler's own codegen — a hand-written
try/finally { x.Dispose(); }that doesn't match the exact guarded shape is left as an explicit try/finally rather than mis-folded intousing. -
For
foreach, the additionalGetEnumerator/MoveNext/Currentstructure inside thetry, which lets it reconstructforeachrather than a bareusingover an enumerator.
When the shape matches exactly, the decompiler collapses five or six lines of try/finally into the single using (or foreach, or await using) you wrote. When it doesn't — a finally that does more than dispose, or a disposal that isn't guarded the way the compiler guards it — it honestly shows the try/finally, because turning an arbitrary cleanup block into using would misrepresent the code.
Why it's worth knowing
The folded using is what you want to read, but the try/finally underneath explains real behaviour. It's why using guarantees cleanup on an exception — the disposal is in a finally, not after the body. It's why disposing a null resource doesn't throw — the compiler guards it. It's why a foreach over a List<T> has zero disposal overhead you can see as boxing — the struct enumerator is disposed directly. And when you're reading someone else's assembly without symbols and you see a try with a lone guarded Dispose in the finally, you can read it straight back as a using even if the decompiler chose to show the raw shape. Glass.NET folds these patterns back by default and lets you drop to the raw try/finally when you want to confirm exactly how the cleanup was generated — which is the view you want the moment a Dispose or DisposeAsync does something surprising.
Top comments (0)