Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Async-Await issue with local variable cleanup

I have encountered an issue where it appears that local resources may not be cleaned up during garbage collection if the resources are in an async-await method.

I have created some sample code to illustrate the issue.

SimpleClass

The SimpleClass uses a static counter to record number of active instances by incrementing a static _count field during construction and decrementing the same field during destruction.

using System;

namespace AsyncGarbageCollector
{
    public class SimpleClass
    {

        private static readonly object CountLock = new object();
        private static int _count;

        public SimpleClass()
        {
            Console.WriteLine("Constructor is called");
            lock (CountLock)
            {
                _count++;
            }
        }

        ~SimpleClass()
        {
            Console.WriteLine("Destructor is called");
            lock (CountLock)
            {
                _count--;
            }
        }

        public static int Count
        {
            get
            {
                lock (CountLock)
                {
                    return _count;
                }
            }
        }
    }
}

Program

Here is the main program which has three tests

  1. Standard call which initializes the class and then the variable is left to go out of scope
  2. Async call which initializes the class and then the variable is left to go out of scope
  3. Async call which initializes the class and then sets the variable to null before it goes out of scope

In each case, the variable will be out of scope before GC.Collect is called. I would therefore expect the destructor to be called during garbage collection.

using System;
using System.Threading.Tasks;

namespace AsyncGarbageCollector
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Press 1, 2 or 3 to start.\n\n");
            var code = Console.ReadKey(true);

            if (code.Key == ConsoleKey.D1)
                RunTest1();
            else if (code.Key == ConsoleKey.D2)
                RunTest2Async().Wait();
            else if (code.Key == ConsoleKey.D3)
                RunTest3Async().Wait();


            Console.WriteLine("\n\nPress any key to close.");
            Console.ReadKey();
        }

        private static void RunTest1()
        {
            Console.WriteLine("Test 1\n======");
            TestCreation();
            DisplayCounts();
        }

        private static async Task RunTest2Async()
        {
            Console.WriteLine("Test 2\n======");
            await TestCreationAsync();
            DisplayCounts();
        }

        private static async Task RunTest3Async()
        {
            Console.WriteLine("Test 3\n======");
            await TestCreationNullAsync();
            DisplayCounts();
        }

        private static void TestCreation()
        {
            var simple = new SimpleClass();
        }

        private static async Task TestCreationAsync()
        {
            var simple = new SimpleClass();
            await Task.Delay(50);
        }

        private static async Task TestCreationNullAsync()
        {
            var simple = new SimpleClass();
            await Task.Delay(50);

            Console.WriteLine("Setting Null");
            simple = null;
        }

        private static void DisplayCounts()
        {
            Console.WriteLine("Running GC.Collect()");
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();

            Console.WriteLine("Count: " + SimpleClass.Count);
        }
    }
}

Results

Test 1
======
Constructor is called
Running GC.Collect()
Destructor is called
Count: 0
Returned to Main
Running GC.Collect()
Count: 0

Test 2
======
Constructor is called
Running GC.Collect()
Count: 1
Returned to Main
Running GC.Collect()
Destructor is called
Count: 0

Test 3
======
Constructor is called
Setting Null
Running GC.Collect()
Destructor is called
Count: 0
Returned to Main
Running GC.Collect()
Count: 0

In Test 2, the destructor in the SimpleClass object is not called by garbage collection (even when it is out of scope) until garbage collection is called from the main function.

Is there a good reason for this? My guess is that it is that the async method itself is still 'alive' until all relevant asyncs have completed and hence its variables are kept alive as a result.

Question - Will local objects ever be collected during the lifetime of a async call?

  1. If so, how can this be demonstrated.
  2. If not, I am concerned that very large objects could cause out-of-memory exceptions as a result of using the async-await pattern.

Any answers/comments would be greatly appreciated.

like image 444
SholaOgundeHome Avatar asked Dec 25 '22 08:12

SholaOgundeHome


1 Answers

async/await is a bit tricky. Let's have a closer look at your method:

private static async Task RunTest2Async()
{
    Console.WriteLine("Test 2\n======");
    await TestCreationAsync();
    DisplayCounts();
}

The method prints something on the console. Then it calls TestCreationAsync() and a Task handle is returned. The method registers itself as the task's successor and returns a task handle itself. The compiler converts the method to a state machine to keep track of the entry points.

Then when the task returned by TestCreationAsync() has finished, it calls RunTest2Async() again (with the specified entry point). You can see this in the call stack when you're in debug mode. So the method is still alive, thus the created simple is still in scope. That's why it is not collected.

If you're in Release mode, simple is already collected in the await continuation. Probably because the compiler found out that it is not used any more. So in practice, this should not be a problem.

Here is a visualization:

Visualization

like image 64
Nico Schertler Avatar answered Jan 02 '23 02:01

Nico Schertler