Skip to main content

Command Palette

Search for a command to run...

Too early, too late: your C# method doesn't run in the order you wrote it

The heavy lifting the compiler does behind lambdas, yield and await

Updated
•18 min read•View as Markdown
Too early, too late: your C# method doesn't run in the order you wrote it
A
Hi, I'm Azat. I write code, mostly in C#, and I tend to go down rabbit holes. This blog is where I write down what comes out of them: things I've built, problems I've solved (or haven't yet), useful snippets, and ideas I want to think through properly. Some posts are polished, some are closer to notes. Either way, if something here saves you an hour of searching, it was worth posting.

There's a method you've probably written many times: check the arguments, return early if there's nothing to do, and hand the real work to LINQ with a lambda that uses one of the parameters.

We read a method like that from top to bottom and assume it runs in the same order: the checks first, the work last, and if the method returns early, nothing below the return has happened yet. That's usually true, but lambdas, yield and await make the compiler rewrite the code around them.

A lambda that captures a variable makes some work run too early. yield and await turn the method into a state machine, and the checks at its top fail too late. This post is mostly about that timing, but the rewrites also allocate, so we'll count those bytes too.

If you've read the .NET source, you've probably seen the BCL's fix for this: a public method Foo that does a couple of checks and then calls a private FooCore. Let's put one small method through both rewrites and see why that second method is there.

Too early: the closure

Say we look up a user by name, with the usual checks and an early exit for an empty collection. Every static method in this post lives in this Users class, even when a sample leaves the class out:

public sealed record User(string Name, bool IsActive);

public static class Users
{
    public static User? FindByName(IReadOnlyCollection<User> users, string name)
    {
        ArgumentNullException.ThrowIfNull(users);
        ArgumentException.ThrowIfNullOrEmpty(name);

        if (users.Count == 0)
            return null;

        return users.FirstOrDefault(u => u.Name == name);
    }
}

The lambda u => u.Name == name reads name, which is a parameter of FindByName. But the compiler turns the lambda into a separate method, and one method can't read another method's parameters. So the compiler creates a hidden class, called a closure, moves name into a field of that class and makes the lambda one of its methods. After that, every read of name in FindByName goes through that field, including the one in the validation.

The compiler creates the closure object when execution enters the scope of the captured variable. A parameter's scope is the whole method, so the closure is created as soon as the method starts, before any of the checks run.

You can see that in SharpLab, or in ILSpy with lambda decompilation turned off. The generated names aren't valid C# on purpose, so they can't clash with yours. The closure is <>c__DisplayClass0_0, which is why closures are also called display classes, and the lambda is <FindByName>b__0. Here's the same code with readable names:

// What the compiler generates
public static class Users
{
    private sealed class FindByNameClosure
    {
        public string? name;

        public bool Lambda(User u) => u.Name == name;
    }

    public static User? FindByName(IReadOnlyCollection<User> users, string name)
    {
        var closure = new FindByNameClosure();
        closure.name = name;

        ArgumentNullException.ThrowIfNull(users);
        ArgumentException.ThrowIfNullOrEmpty(closure.name);

        if (users.Count == 0)
            return null;

        return users.FirstOrDefault(closure.Lambda);
    }
}

The first two lines of FindByName create the closure and copy name into it, before any of the checks. Only the Func<User, bool> delegate is still created where the lambda was written, on the last line.

On .NET 10.0.12 (x64, Release), BenchmarkDotNet's [MemoryDiagnoser] shows 24 bytes per call to FindByName with an empty collection. That's the closure, allocated for a lambda that never runs, and every call that fails validation allocates it too.

Side note: when the collection isn't empty, the number depends on the runtime. On .NET 8, the call also allocates the 64-byte delegate and the enumerator that FirstOrDefault gets from the collection: 120 bytes in total for a User[] and 128 for a List<User>. On .NET 10, FirstOrDefault reads arrays and lists as spans and the JIT keeps the delegate on the stack, so only the closure's 24 bytes are left.

Fix: copy the parameter inside a block

A local declared inside an inner { } block has that block as its scope, so if a lambda captures it, the compiler creates the closure only when execution enters the block. That means we can copy name into such a local after the early return and let the lambda capture the copy instead of the parameter:

public static User? FindByName(IReadOnlyCollection<User> users, string name)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(name);

    if (users.Count == 0)
        return null;

    {
        string localName = name;
        return users.FirstOrDefault(u => u.Name == localName);
    }
}

The captured variable now belongs to the block, so the compiler creates the closure there, below the checks and the early return. The closure class is the same as before, except that its field is called localName:

// What the compiler generates
public static User? FindByName(IReadOnlyCollection<User> users, string name)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(name);

    if (users.Count == 0)
        return null;

    {
        var closure = new FindByNameClosure();
        closure.localName = name;
        return users.FirstOrDefault(closure.Lambda);
    }
}

On both .NET 8 and .NET 10, the empty path now allocates nothing, and the non-empty path allocates the same as before.

This fix is fragile, because it all depends on the braces. Without them, the scope of localName is the whole method again. The closure goes back to the first line, even though the copy is written after the return, and the empty path is back to 24 bytes. The method also allocates on entry again if the lambda reads anything else from the outer scope, like another parameter, or if another lambda in the method captures name. To a reader, the copy looks redundant, and nothing warns when someone inlines it or removes the braces.

Fix: split the method

The sturdier fix is to move the lambda out of the method. The public method keeps the checks and the early exit, and everything that captures a variable goes into a private method with the same name and a Core suffix:

public static User? FindByName(IReadOnlyCollection<User> users, string name)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(name);

    if (users.Count == 0)
        return null;

    return FindByNameCore(users, name);
}

private static User? FindByNameCore(IReadOnlyCollection<User> users, string name) =>
    users.FirstOrDefault(u => u.Name == name);

FindByName no longer contains a lambda, so it has nothing to capture, and the compiler leaves it as written. The closure moved into FindByNameCore:

// What the compiler generates
public static User? FindByName(IReadOnlyCollection<User> users, string name)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(name);

    if (users.Count == 0)
        return null;

    return FindByNameCore(users, name);
}

private static User? FindByNameCore(IReadOnlyCollection<User> users, string name)
{
    var closure = new FindByNameCoreClosure();
    closure.name = name;
    return users.FirstOrDefault(closure.Lambda);
}

The closure is still created on the first line of a method, but that method is now FindByNameCore, which only runs when the checks pass and the collection isn't empty. On both .NET 8 and .NET 10, the empty path went from 24 bytes to zero, and the non-empty path allocates the same as before.

💡
Note on static lambdas. Some APIs let you skip the second method. ConcurrentDictionary<TKey, TValue>.GetOrAdd has an overload that takes an extra argument and passes it on to the factory, so the lambda doesn't need to capture anything and can be static: cache.GetOrAdd(id, static (key, prefix) => prefix + key, prefix). With static, the compiler rejects any capture, and a lambda that captures nothing needs no closure. The compiler also caches the delegate of such a lambda in a static field, so when the key is already in the dictionary, the call allocates nothing. FirstOrDefault has no such overload, so it still needs the split.

FirstOrDefault runs the lambda during the call. Where doesn't call the lambda until someone enumerates the result, and a method you write with yield return doesn't run any of its code until then, not even the checks. That's where the timing goes wrong in the other direction.

Too late: the state machine

When a method contains yield return or await, the compiler rewrites the whole method. It generates a type, the state machine, and moves the body into its MoveNext method. The method you call becomes a stub that creates the state machine. Your checks move into MoveNext with everything else, so they run when MoveNext runs. Iterators and async methods run MoveNext at different times, so they go wrong in two different ways.

Iterators: the checks wait for the first MoveNext

Here's a method that returns every user whose name starts with a prefix, with the same kind of checks at the top:

public static IEnumerable<User> FindByPrefix(IReadOnlyCollection<User> users, string prefix)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(prefix);

    foreach (User user in users)
    {
        if (user.Name.StartsWith(prefix))
            yield return user;
    }
}

For an iterator, the state machine is a class that implements IEnumerable<User>. Calling FindByPrefix only creates an instance of that class and returns it. None of the code you wrote runs until someone starts enumerating. Here's the class, written by hand like the closure. The real one has more states and bookkeeping, and the IEnumerable<User> plumbing at the end is left out:

// What the compiler generates
public static IEnumerable<User> FindByPrefix(IReadOnlyCollection<User> users, string prefix)
{
    return new FindByPrefixIterator(users, prefix);
}

private sealed class FindByPrefixIterator(IReadOnlyCollection<User> users, string prefix)
    : IEnumerable<User>, IEnumerator<User>
{
    private enum State { NotStarted, PausedAtYield, Finished }

    private State state = State.NotStarted;
    private IEnumerator<User> usersEnumerator = null!;

    public User Current { get; private set; } = null!;

    public bool MoveNext()
    {
        switch (state)
        {
            case State.NotStarted:
                ArgumentNullException.ThrowIfNull(users);
                ArgumentException.ThrowIfNullOrEmpty(prefix);
                usersEnumerator = users.GetEnumerator();
                break;
            case State.PausedAtYield:
                break;
            case State.Finished:
                return false;
        }

        while (usersEnumerator.MoveNext())
        {
            User user = usersEnumerator.Current;
            if (user.Name.StartsWith(prefix))
            {
                Current = user;
                state = State.PausedAtYield;
                return true;
            }
        }

        state = State.Finished;
        usersEnumerator.Dispose();
        return false;
    }

    // GetEnumerator, Dispose and the rest of the IEnumerable<User> plumbing
}

The constructor only stores the arguments. The foreach became a while over usersEnumerator, and the yield return became three lines that store the user in Current, set the state to PausedAtYield and return true. The next MoveNext takes the PausedAtYield case and continues the loop. The checks ended up in the NotStarted case, so they run on the first MoveNext, not during the call.

So FindByPrefix(users, "") returns without complaint. The ArgumentException only shows up on the first MoveNext, maybe in a foreach three layers away, long after the code that passed the empty prefix has returned. And with an empty collection, the call still allocates a 72-byte iterator that has nothing to iterate.

Fix: split the iterator

There's no lambda this time, but the same split helps. The public method keeps the checks and gets an early exit for an empty collection, and the iterator moves into FindByPrefixCore:

public static IEnumerable<User> FindByPrefix(IReadOnlyCollection<User> users, string prefix)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(prefix);

    if (users.Count == 0)
        return [];

    return FindByPrefixCore(users, prefix);
}

private static IEnumerable<User> FindByPrefixCore(IReadOnlyCollection<User> users, string prefix)
{
    foreach (User user in users)
    {
        if (user.Name.StartsWith(prefix))
            yield return user;
    }
}

FindByPrefix has no yield, so the compiler leaves it almost as written. Only FindByPrefixCore turns into a stub, and its iterator class is the one from before, minus the two checks:

// What the compiler generates
public static IEnumerable<User> FindByPrefix(IReadOnlyCollection<User> users, string prefix)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(prefix);

    if (users.Count == 0)
        return Array.Empty<User>();

    return FindByPrefixCore(users, prefix);
}

private static IEnumerable<User> FindByPrefixCore(IReadOnlyCollection<User> users, string prefix)
{
    return new FindByPrefixCoreIterator(users, prefix);
}

The checks now run in a plain method, so FindByPrefix(users, "") throws on the spot. The [] became Array.Empty<User>(), a cached empty array, so the empty case allocates nothing. The iterator is only created when there's something to iterate.

An iterator's MoveNext waits for someone to enumerate. An async method's MoveNext starts during the call, so its checks run on time, but their exceptions still arrive late.

Async methods: the exception waits in the task

Here's FindByName again, but this time the users come from somewhere asynchronous:

public static async Task<User?> FindByNameAsync(IUserSource source, string name)
{
    ArgumentNullException.ThrowIfNull(source);
    ArgumentException.ThrowIfNullOrEmpty(name);

    IReadOnlyList<User> users = await source.LoadAsync();
    return users.FirstOrDefault(u => u.Name == name);
}

public interface IUserSource
{
    Task<IReadOnlyList<User>> LoadAsync();
}

Rewritten the same way, FindByNameAsync becomes a stub that sets up the state machine and starts it:

// What the compiler generates
public static Task<User?> FindByNameAsync(IUserSource source, string name)
{
    var stateMachine = new FindByNameAsyncStateMachine
    {
        builder = AsyncTaskMethodBuilder<User?>.Create(),
        source = source,
        name = name,
    };
    stateMachine.builder.Start(ref stateMachine);
    return stateMachine.builder.Task;
}

Start calls the state machine's MoveNext right away, on the caller's thread, and the stub returns the task only after that call comes back. MoveNext holds the body of your method, and everything it has to keep between calls, like the parameters, the closure and the awaiter, is a field of the state machine. Here's MoveNext, again with some bookkeeping left out:

// What the compiler generates
public void MoveNext()
{
    User? result;
    try
    {
        if (state == State.NotStarted)
        {
            closure = new FindByNameAsyncClosure();
            closure.name = name;

            ArgumentNullException.ThrowIfNull(source);
            ArgumentException.ThrowIfNullOrEmpty(closure.name);

            loadAwaiter = source.LoadAsync().GetAwaiter();
            if (!loadAwaiter.IsCompleted)
            {
                state = State.WaitingForLoad;
                builder.AwaitUnsafeOnCompleted(ref loadAwaiter, ref this);
                return;
            }
        }

        IReadOnlyList<User> users = loadAwaiter.GetResult();
        result = users.FirstOrDefault(closure.Lambda);
    }
    catch (Exception exception)
    {
        state = State.Finished;
        builder.SetException(exception);
        return;
    }

    state = State.Finished;
    builder.SetResult(result);
}

MoveNext runs until an await actually has to wait. If the task from LoadAsync isn't finished yet, MoveNext sets the state to WaitingForLoad, asks AwaitUnsafeOnCompleted to call it again when the task completes, and returns. The second call skips the NotStarted block and picks up the result. The state machine itself doesn't allocate too early: in Release builds it's a struct, and it only moves to the heap at that await.

What makes it late is the try around the whole body of MoveNext. It catches every exception, even one thrown before the first await, and passes it to SetException, which stores it in the task that the stub returns. So FindByNameAsync(source, "") returns normally, with a task that has already faulted.

If you await FindByNameAsync(source, name) directly, you won't notice, because the exception comes out on that same line anyway. The difference shows when the task isn't awaited right away: a task stored and awaited later, a fire-and-forget call, or several calls awaited together with Task.WhenAll. In that last case, the ArgumentException comes out of await Task.WhenAll(...), away from the call that passed the empty name.

The first section's problem is in there too: the first two lines of the NotStarted block create a closure and copy name into it, before the checks. That comes from the lambda, not from async, and an iterator with the same lambda would do the same at the top of its MoveNext. This method has both a lambda and an await, so it gets both problems.

Fix: split the async method

The split fixes both problems, as long as the public method itself isn't async:

public static Task<User?> FindByNameAsync(IUserSource source, string name)
{
    ArgumentNullException.ThrowIfNull(source);
    ArgumentException.ThrowIfNullOrEmpty(name);

    return FindByNameAsyncCore(source, name);
}

private static async Task<User?> FindByNameAsyncCore(IUserSource source, string name)
{
    IReadOnlyList<User> users = await source.LoadAsync();
    return users.FirstOrDefault(u => u.Name == name);
}

Now only FindByNameAsyncCore gets rewritten. FindByNameAsync stays a plain method that runs its checks and then calls the stub:

// What the compiler generates
public static Task<User?> FindByNameAsync(IUserSource source, string name)
{
    ArgumentNullException.ThrowIfNull(source);
    ArgumentException.ThrowIfNullOrEmpty(name);

    return FindByNameAsyncCore(source, name);
}

private static Task<User?> FindByNameAsyncCore(IUserSource source, string name)
{
    var stateMachine = new FindByNameAsyncCoreStateMachine
    {
        builder = AsyncTaskMethodBuilder<User?>.Create(),
        source = source,
        name = name,
    };
    stateMachine.builder.Start(ref stateMachine);
    return stateMachine.builder.Task;
}

The checks in FindByNameAsync are ordinary code now, so FindByNameAsync(source, "") throws at the call. If the arguments are valid, it returns the task from FindByNameAsyncCore. The MoveNext of that state machine is the one from before without the two checks. It still creates the closure first, but by then the checks have already passed.

💡
Note on .NET 11. .NET 11 isn't released at the time of writing. It brings runtime async, where the runtime handles async methods itself instead of running a compiler-generated state machine, so some of the allocation numbers in this post may not hold there. .NET 10 already has runtime async as an experimental feature. Turning it on didn't change the timing described here, but that doesn't guarantee anything for .NET 11. All measurements in this post are from .NET 8 and .NET 10.

Back to FooCore

Put the fixes next to each other and they all do the same thing: the checks and the cheap exits stay in a plain public method, and everything the compiler rewrites (the capturing lambda, the yield, the await) goes into a private one. The public method runs entirely during the call, so its checks throw right there and its early exits don't allocate.

That's the Foo and FooCore pair from the beginning, and it already has a name. Jon Skeet calls it the "split method" pattern in his Edulinq series, where he reimplements LINQ to Objects. The Microsoft Learn article on iterators recommends the same split: in some cases, you "split an iterator method into two different methods", one that uses return and one that uses yield return. In a few places, the .NET source explains in a comment why the pair exists.

Chunk checks its arguments, returns [] for an empty array, and only then calls the yield-based ArrayChunkIterator or EnumerableChunkIterator. A comment there says that one reason for this order is to avoid allocating an iterator for an empty array. The Where overload that passes an index to the predicate is built the same way. The plain Where doesn't use yield at all: after the same checks, it returns an iterator class like ArrayWhereIterator, so it's the same split with the second half written by hand.

On the async side, HttpClient.GetStringAsync creates and checks the request, then returns GetStringAsyncCore(...). The comment above the check explains that it runs outside the async state machine so that certain exceptions propagate even when nobody awaits the returned task. SseFormatter.WriteAsync is built the same way.

Not every second half ends in Core. When it uses yield, the BCL often calls it ...Iterator, like ArrayChunkIterator and WhereIterator, and Jon Skeet's Edulinq uses ...Impl. Some code doesn't make it a separate method at all and keeps it inside the public one as a local function. The AsyncEnumerable LINQ operators that come with .NET 10 do that in methods like AnyAsync: after the null check, they return Impl(...) and declare Impl right below as a static async local function. The same shape works for an iterator. Here's the FindByPrefix split with its second half as a local function:

public static IEnumerable<User> FindByPrefix(IReadOnlyCollection<User> users, string prefix)
{
    ArgumentNullException.ThrowIfNull(users);
    ArgumentException.ThrowIfNullOrEmpty(prefix);

    if (users.Count == 0)
        return [];

    return Core(users, prefix);

    static IEnumerable<User> Core(IReadOnlyCollection<User> users, string prefix)
    {
        foreach (User user in users)
        {
            if (user.Name.StartsWith(prefix))
                yield return user;
        }
    }
}

Core is static so that it can't capture anything. Without static, it could drop its parameters and read users and prefix straight from FindByPrefix. Because Core is an iterator, that capture would bring the closure class back, created on the first line of FindByPrefix, and the empty path would allocate 32 bytes instead of nothing. With static, any capture is error CS8421, so the closure can't come back by accident.

When to split

Not every method needs to be split. The strongest reason to split one is timing: in an iterator or an async method, the checks don't fail when the caller expects them to. A bad argument should cause an exception at the call that passed it, not in a foreach or a Task.WhenAll somewhere else, and that's reason enough for a second method.

Allocations are a weaker reason. In most code, 24 or 72 bytes is nothing. A split made only to save them pays off on a hot path where the early exit is the common case, like cache hits, empty inputs or operations that have already completed. If the early exit happens once a day, the allocation doesn't matter.

⚠️
Note on guarantees. The language doesn't specify when the closure is created. The C# standard (ECMA-334) only says that a local variable is instantiated when execution enters its scope. The section of the standard that shows lambdas compiled into hidden classes is marked informative and calls itself "by no means a mandated implementation". The cached delegate of the static lambda in the GetOrAdd note is the same story: the standard permits reusing the delegate but doesn't require it. So the byte counts in this post describe what today's compiler emits, and a future compiler could create the closure later or optimize it away entirely. The BCL splits methods anyway, because it has to be as fast and as efficient as possible with the compiler that exists now. In your own code, treat the saved bytes as an optimization, not as behavior to depend on. Iterators and async methods are different: the standard itself says that an iterator's body waits for the first MoveNext and that an async method's exceptions go into the returned task.

So reading a method from top to bottom doesn't always tell you when each line runs. Once you know which code the compiler moves, a Foo that does little more than call FooCore no longer looks pointless. It's a split method: Foo keeps the checks that have to happen at the call, and FooCore gets everything the compiler rewrites.

More from this blog

A

Asylkhan Azat

3 posts

Notes from things I'm building and breaking. I'm Azat, I write code (mostly C#), and this is where I share snippets, experiments, problems I've solved, and ideas worth thinking through.