Search code examples
c#static

Objective benefit of static local function that's only called once?


Recently I was looking at the source code of Stream.CopyToAsync, which is available here on github.

The author first validates the parameters and then calls a static local function that does the actual processing. I find static local functions to be quite useful when I need to reuse a functionality, but don't wan't to add yet another private method.

But in Stream.CopyToAsync the static local function is called just once at the very end of the declaring method. I don't see any benefit here.

=> Is there any objective benefit (i.e. performance, security, better compiler optimizations, memory usage, ...) to use a static local function here?

Please note that I'm not asking about readability, because that's somewhat opinionated.

Code:

public virtual Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
{
    ValidateCopyToArguments(destination, bufferSize);
    if (!CanRead)
    {
        if (CanWrite)
        {
            ThrowHelper.ThrowNotSupportedException_UnreadableStream();
        }

        ThrowHelper.ThrowObjectDisposedException_StreamClosed(GetType().Name);
    }

    return Core(this, destination, bufferSize, cancellationToken);

    static async Task Core(Stream source, Stream destination, int bufferSize, CancellationToken cancellationToken)
    {
        byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
        try
        {
            int bytesRead;
            while ((bytesRead = await source.ReadAsync(new Memory<byte>(buffer), cancellationToken).ConfigureAwait(false)) != 0)
            {
                await destination.WriteAsync(new ReadOnlyMemory<byte>(buffer, 0, bytesRead), cancellationToken).ConfigureAwait(false);
            }
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }
}

Which is in my opinion exactly the same as without a static local function:

public virtual async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken)
{
    ValidateCopyToArguments(destination, bufferSize);
    if (!CanRead)
    {
        if (CanWrite)
        {
            ThrowHelper.ThrowNotSupportedException_UnreadableStream();
        }

        ThrowHelper.ThrowObjectDisposedException_StreamClosed(GetType().Name);
    }

    // return Core(this, destination, bufferSize, cancellationToken);

    byte[] buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
    try
    {
        int bytesRead;
        while ((bytesRead = await this.ReadAsync(new Memory<byte>(buffer), cancellationToken).ConfigureAwait(false)) != 0)
        {
            await destination.WriteAsync(new ReadOnlyMemory<byte>(buffer, 0, bytesRead), cancellationToken).ConfigureAwait(false);
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

Solution

  • Sweeper gave a good explanation in the comments, which I will summarize here. Also this question and this microsoft devblog post and especially this blog post helped me understand the great idea behind this. Key points:

    • The static local function Core encapsulates all async operations of Stream.CopyToAsync -> the compiler will transform Core to an async state machine.
    • Stream.CopyToAsync itself is not async and will not be transformed to a state machine.
    • If an exception is thrown during validation (befor the call to Core) the exception will directly be passed on to the caller.
    • Exceptions thrown in Core will only be passed to the caller when the resulting Task is awaited.

    Minmalistic example, as of my understanding:

    Task Foo(Argument arg)
    {
        if(!arg.IsValid) throw new ArgumentException();
    
        return Core(arg);
    
        static async Task Core(Argument arg) { await SomeAsyncOperations(); }
    }
    
    async Task Bar(Argument arg)
    {
        if(!arg.IsValid) throw new ArgumentException();
    
        await SomeAsyncOperations();   
    }
    
    // no difference in usage when awaited immediately
    
    await Foo(invalidArg); // throws here
    await Bar(invalidArg); // throws here
    
    // big difference if awaited somewhere else (or not awaited at all)
    
    var fooTask = Foo(invalidArg); // throws here (!)
    await fooTask;
    
    var barTask = Bar(invalidArg); // no throw here (!)
    await barTask; // throws here