Roslyn lowering of high-level C# constructs into simpler C# and IL, and what Debug versus Release changes.
Roslyn parses C# into syntax trees, binds names and types into a semantic model, then lowers high-level constructs into simpler ones before emitting IL. foreach over an array becomes an index loop; over `List<T>` it uses the struct enumerator in a try/finally; using becomes try/finally with Dispose; lock becomes Monitor.Enter(obj, ref lockTaken) / Monitor.Exit (or Lock.EnterScope() for System.Threading.Lock); a + b + c on strings becomes one string.Concat; a switch on strings may become a length or hash-based jump. async methods and iterators become state machines. Roslyn does little optimisation; the JIT does the heavy work.
Roslyn compiles C# in phases: parse to syntax trees, bind names and types (semantic analysis), lower high-level constructs into simpler ones, then emit IL. Lowering is where much of C#'s convenience syntax disappears: foreach, using, lock, string interpolation/concatenation, switch, lambdas, async, iterators, pattern matching all become plain loops, try/finally blocks, method calls and generated classes. Knowing the lowered forms explains performance and behaviour.
foreachforeach (var line in orderLines) Process(line); // List<OrderLine>
lowers to:
List<OrderLine>.Enumerator e = orderLines.GetEnumerator(); // struct enumerator - no allocation
try
{
while (e.MoveNext()) { var line = e.Current; Process(line); }
}
finally { ((IDisposable)e).Dispose(); }
for loops with an index (no enumerator, bounds-check elimination).IEnumerable<T>, the enumerator is an interface → boxed if struct, interface calls.await foreach lowers to GetAsyncEnumerator, await MoveNextAsync(), await DisposeAsync() in finally.usingusing var stream = File.OpenRead(path);
Process(stream);
lowers to:
FileStream stream = File.OpenRead(path);
try { Process(stream); }
finally { if (stream != null) ((IDisposable)stream).Dispose(); }
For struct disposables, the compiler calls Dispose without boxing (constrained call). await using calls DisposeAsync() and awaits it. Pattern-based Dispose works for ref structs.
locklock (_sync) { _balance -= amount; }
lowers to (for object locks):
object obj = _sync; bool lockTaken = false;
try
{
Monitor.Enter(obj, ref lockTaken);
_balance -= amount;
}
finally { if (lockTaken) Monitor.Exit(obj); }
In C# 13 / .NET 9, if the lock expression is a System.Threading.Lock, it lowers to using (_lock.EnterScope()) { ... } — the new dedicated lock type with a faster implementation. (Converting a Lock to object and locking on it falls back to Monitor — the compiler warns.)
string a = first + " " + last; // string.Concat(first, " ", last) - single allocation
string b = $"Order {id} total {total:C}"; // C# 10+: DefaultInterpolatedStringHandler
b lowers to:
var h = new DefaultInterpolatedStringHandler(literalLength: 13, formattedCount: 2);
h.AppendLiteral("Order ");
h.AppendFormatted(id);
h.AppendLiteral(" total ");
h.AppendFormatted(total, "C");
string b = h.ToStringAndClear();
id/total (generic AppendFormatted<T>), pooled buffer, one final string.+= in loops still creates a new string each iteration (lowers to Concat per iteration) — use StringBuilder.[InterpolatedStringHandler]) let APIs like logging avoid work when disabled.switchstring Describe(OrderStatus s) => s switch { OrderStatus.Paid => "Paid", OrderStatus.Shipped => "Shipped", _ => "Other" };
switch jump table.ComputeStringHash + string.op_Equality), or length-based dispatch in newer compilers.isinst), property reads and comparisons, sharing tests between arms.SwitchExpressionException.async → state machine struct + AsyncTaskMethodBuilder.CollectionsMarshal operations.params ReadOnlySpan<T> → inline arrays on the stack.SharpLab's "C#" output shows lowered C#; ILSpy with "show compiler-generated code" settings; IL view for exact instructions.
A hot loop built log keys with key += part over ~50 parts per item, allocating ~50 strings per item. SharpLab's lowered view made the repeated string.Concat obvious; switching to string.Create/StringBuilder cut allocations dramatically. In another case, a team locked on a Lock instance typed as object after a refactor; the C# 13 warning showed it had silently fallen back to Monitor, and fixing the field type restored the faster EnterScope path.
The interviewer checks you understand that C# features are compiler transformations with costs.
Green flags: names the pipeline stages, gives several correct lowerings including lock and foreach, pattern-based features, Roslyn versus JIT optimisation, tools to view lowered code.
Red flags: believes Roslyn produces optimised machine code; cannot describe what using or lock become.
Lambdas inside async methods are lowered into closures first, then the async rewriter hoists locals into the state machine. That's why captured variables in async methods live in display classes referenced from the state machine.
string.Concat has overloads for 2–4 strings and ReadOnlySpan<char> (newer), avoiding intermediate strings. Mixed types ("a" + 42) call ToString() first (no boxing for value types since C# 8+ with Concat(string, string) and ToString calls inserted). Interpolation with $"" and string target uses DefaultInterpolatedStringHandler; with FormattableString target creates a FormattableString object (allocates, boxes).
lock and LockSystem.Threading.Lock (.NET 9) is a dedicated type; lock (myLock) with a Lock-typed expression calls EnterScope() returning a ref struct Scope disposed at the end. Benefits: no object header/sync block usage, faster uncontended path. C# 13 warns if a Lock is converted to another type and locked (would use Monitor).
For many string cases, the compiler emits a hash-based dispatch (ComputeStringHash using FNV-1a) followed by equality checks; for few cases, sequential comparisons. Newer compilers may use length-and-character switching to avoid hashing.
foreach over an array, a List<T> and an IEnumerable<T> into SharpLab and compare lowered C#.lock on object vs System.Threading.Lock.string vs FormattableString.public sealed class LoweringDemo
{
private readonly object _gate = new();
private readonly System.Threading.Lock _lock = new(); // .NET 9+, C# 13 lowers lock to EnterScope
private int _count;
public void Increment()
{
lock (_gate) { _count++; } // Monitor.Enter(_gate, ref lockTaken) in try/finally
}
public void IncrementWithLock()
{
lock (_lock) { _count++; } // using (_lock.EnterScope()) { ... }
}
public static string Join(string a, string b, string c) => a + b + c; // string.Concat(a, b, c)
public static int SumArray(int[] values)
{
int sum = 0;
foreach (int v in values) sum += v; // for (int i = 0; i < values.Length; i++)
return sum;
}
public static int SumEnumerable(IEnumerable<int> values)
{
int sum = 0;
foreach (int v in values) sum += v; // IEnumerator<int> via interface; allocates for List<int>
return sum;
}
public static string Describe(string status) => status switch
{
"new" => "Created",
"paid" => "Paid",
"shipped" => "On its way",
_ => "Unknown"
};
}
What happens: using += on strings in loops.
Why it is wrong: lowers to repeated allocations.
Do instead: StringBuilder/string.Create.
foreach over interfaces in hot pathsWhat happens: iterating IEnumerable<T> typed variables.
Why it is wrong: boxed enumerators and interface calls.
Do instead: concrete types or spans.
Lock via objectWhat happens: object _sync = new Lock();.
Why it is wrong: falls back to Monitor; loses benefits.
Do instead: keep the field typed as Lock.
FormattableString unintentionallyWhat happens: passing interpolated strings to APIs taking FormattableString (or object).
Why it is wrong: allocations and boxing.
Do instead: know the target type; use handlers where available.
using scopeWhat happens: using var disposing later than expected (end of scope).
Why it is wrong: resources held too long.
Do instead: use block using for narrow scopes.
switch on strings is slowWhat happens: replacing switches with dictionaries prematurely. Why it is wrong: compiled switches are efficient. Do instead: measure before changing.
finally semanticsWhat happens: surprised that foreach disposes enumerators on break.
Why it is wrong: lowering adds try/finally.
Do instead: rely on it for iterator cleanup.
What happens: debating what code does without checking lowered output. Why it is wrong: lowering clarifies closures, boxing and state machines. Do instead: use SharpLab/ILSpy.
Lowering knowledge lets you predict costs without a profiler, then confirm with one.
| Construct | Hidden cost to watch |
|---|---|
foreach over interface |
Enumerator allocation, interface calls |
| Lambda capturing locals | Display class allocation per call |
async method |
State machine and possible Task allocation |
String + in loops |
New string per iteration; use StringBuilder |
params arrays |
Array allocation per call (C# 13 params spans avoid it) |
Seniors use SharpLab in code reviews to settle debates with evidence.
IL (Common Intermediate Language, CIL) is the CPU-independent, stack-based instruction set that C# compiles to. Instructions push and pop values on an evaluation stack: ldarg.0, ldarg.1, add, ret. Calls use metadata tokens (call, callvirt, newobj), and conversions such as box are explicit. You read it with ILSpy, SharpLab or ildasm-style tools. Reading IL helps you spot hidden allocations (boxing, closures), see how async, foreach or using are lowered, and settle "what does the compiler really do" arguments.
A closure is a lambda or local function plus the variables it captures. The compiler moves those variables into fields of a hidden display class, so the method and the lambda share the same variable. That's why capture is by variable, not by value. In a for loop all lambdas share one loop variable, while foreach gets a fresh one per iteration since C# 5. Every lambda in a scope shares one display class, and capturing this keeps the whole object alive, which can leak memory through timers and caches. In hot paths I use static lambdas with explicit state.
A method that uses yield return is an iterator, and the compiler rewrites it into a state machine class that implements IEnumerable<T> and IEnumerator<T>. Locals become fields, a state field remembers where it paused, and each MoveNext runs to the next yield return. yield break ends the sequence. Execution is lazy, and finally and using blocks run when iteration finishes or the consumer disposes early. Iterators can't have ref or out parameters or yield inside a catch, and async iterators add await. They're ideal for streaming and large or infinite sequences.
How to tell it (STAR)
In [our validation library], allocations were high. I showed in SharpLab that foreach over IEnumerable boxed the list enumerator. We changed [N] hot methods to take spans or lists, and allocations per request dropped by [X%].
Last updated 31 Oct 2026.