使用Async-Await时的本地变量清理问题

6

我遇到了一个问题,似乎在异步等待方法中,如果本地资源在垃圾回收期间没有被清除,则可能会出现问题。

我创建了一些示例代码来说明这个问题。

SimpleClass

SimpleClass使用静态计数器来记录活动实例的数量,通过在构造期间递增静态字段_count并在销毁期间递减相同字段来实现。

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;
                }
            }
        }
    }
}

程序

这里是主程序,它有三个测试:

  1. 标准调用,它初始化类,然后将变量留给超出范围
  2. 异步调用,在初始化类后将变量留给超出范围
  3. 异步调用,在初始化类后将变量设置为 null 然后将其留给超出范围

在每种情况下,变量都会在 GC.Collect 被调用之前超出范围。因此,我预计在垃圾回收期间将调用析构函数。

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);
        }
    }
}

结果

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

在测试2中,即使SimpleClass对象超出范围,垃圾回收也不会调用其析构函数,直到从主函数调用垃圾回收为止。这是有好的原因吗?我猜测这是因为异步方法本身仍然“存活”,直到所有相关的异步方法都完成,因此它的变量也因此保持存活状态。
问题 - 本地对象在异步调用期间是否会被收集?
1.如果是,如何证明?
2.如果不是,我担心使用异步等待模式可能会导致内存不足异常,尤其是针对很大的对象。
非常感谢任何答案/评论。
2个回答

8

async/await 微妙又棘手。现在我们仔细看一下你的方法:

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

该方法在控制台上打印一些内容,然后调用TestCreationAsync(),并返回一个Task句柄。该方法将自身注册为任务的后继者,并返回一个任务句柄。编译器将该方法转换为状态机以跟踪入口点。
然后,当由TestCreationAsync()返回的任务完成时,它再次调用RunTest2Async()(使用指定的入口点)。在调试模式下可以在调用堆栈中看到这一点。因此该方法仍然存活,因此创建的simple仍在作用域内,因此它不会被收集。
如果您处于 Release 模式,则simple已在await继续执行中被收集。可能是因为编译器发现它不再使用。因此,在实践中,这应该不是一个问题。
以下是可视化效果图:

很棒的答案。我知道它应该如何工作,但我没有意识到在调试和发布模式下会产生不同的结果。可惜了,因为我所有的诊断都在 #debug 预处理器语句中。 - SholaOgundeHome

4

异步是与状态机交互的一种方便方式。

简单来说,当你写下以下代码时:

void async MyMethod()
{
    int k = await Some1();
    await Some2();
}

实际上,您有一个类似这样的结构(简化)
struct MyMethodState
{
    int k;
    int stage;
    Task currentTaskToWaitFor;
}

这种方法是通过编译器重新编写代码以在阶段之间移动(阶段是由您使用async定义的位置)。

一个特别好的方法是使用Ildasm查看内部。

所以,是的,你要保留对对象的引用。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接