AsyncEx icon indicating copy to clipboard operation
AsyncEx copied to clipboard

The AsyncLazy<T> does not release resources closed over by the factory delegate

Open theodorzoulias opened this issue 3 years ago • 5 comments

Description

Hi! I am reporting an issue that I discovered after reading a comment by Servy here. In case the factory delegate of an AsyncLazy<T> instance closes over some expensive resource, this resource will not be eligible for garbage collection after the completion of the delegate.

Reproduction Steps

public static async Task Main()
{
    Console.WriteLine($"TotalMemory: {GC.GetTotalMemory(true):#,0} bytes");
    var lazy = new Nito.AsyncEx.AsyncLazy<int>(GetFunction());
    Console.WriteLine($"AsyncLazy result: {await lazy:#,0}");
    await Task.Yield();
    GC.Collect();
    Console.WriteLine($"TotalMemory: {GC.GetTotalMemory(true):#,0} bytes");
    GC.KeepAlive(lazy);

    static Func<Task<int>> GetFunction()
    {
        byte[] bytes = new byte[20_000_000];
        return new Func<Task<int>>(async () =>
        {
            await Task.Delay(200);
            return bytes.Length;
        });
    }
}

Output:

TotalMemory: 86,192 bytes
AsyncLazy result: 20,000,000
TotalMemory: 20,133,704 bytes

Online demo.

Expected behavior

The GC.GetTotalMemory() after awaiting the AsyncLazy<T> should be roughly the same as before.

Actual behavior

The GC.GetTotalMemory() after awaiting the AsyncLazy<T> is around 20 MB more than before.

Configuration

  • .NET 6.0
  • Nito.AsyncEx 5.1.2
  • Console application
  • Release built

Other information

After switching to a simpler AsyncLazy<T> implementation like the one below, the problem is not reproduced:

private class AsyncLazySimple<T>
{
    private readonly Lazy<Task<T>> _lazyTask;
    public AsyncLazySimple(Func<Task<T>> factory) => _lazyTask = new(() => factory());
    public Task<T> Task => _lazyTask.Value;
    public TaskAwaiter<T> GetAwaiter() => Task.GetAwaiter();
}

Output:

TotalMemory: 86,192 bytes
AsyncLazy result: 20,000,000
TotalMemory: 133,728 bytes

theodorzoulias avatar Jun 13 '22 11:06 theodorzoulias

Does this reproduce the problem?

public static async Task Main()
{
    Console.WriteLine($"TotalMemory: {GC.GetTotalMemory(true):#,0} bytes");
    var lazy = new Nito.AsyncEx.AsyncLazy<int>(GetFunction);
    Console.WriteLine($"AsyncLazy result: {await lazy:#,0}");
    await Task.Yield();
    GC.Collect();
    Console.WriteLine($"TotalMemory: {GC.GetTotalMemory(true):#,0} bytes");
    GC.KeepAlive(lazy);

    static Task<int> GetFunction()
    {
        byte[] bytes = new byte[20_000_000];
        await Task.Delay(200);
        return bytes.Length;
    }
}

DaveVdE avatar Dec 08 '22 15:12 DaveVdE

@DaveVdE no, it doesn't. The new byte[20_000_000] in your example is not captured by a lambda, like in my example. Btw the GetFunction() in your example returns a Task, not a Func, so it's not properly named. It is also missing the async keyword.

theodorzoulias avatar Jan 11 '23 12:01 theodorzoulias

Sure, it's missing the async keyword, and I didn't change the name.

Oh I see, your point is that the function passed to the AsyncLazy constructor should be forgotten after the task has been complete.

DaveVdE avatar Jan 12 '23 11:01 DaveVdE

@DaveVdE yep, as it does the native Lazy<T> class.

theodorzoulias avatar Jan 13 '23 16:01 theodorzoulias

I sent a fix #269.

timcassell avatar Jan 14 '23 03:01 timcassell