异步 CTP 和 "finally"

20
这是代码:

这里是代码:

static class AsyncFinally
{
    static async Task<int> Func( int n )
    {
        try
        {
            Console.WriteLine( "    Func: Begin #{0}", n );
            await TaskEx.Delay( 100 );
            Console.WriteLine( "    Func: End #{0}", n );
            return 0;
        }
        finally
        {
            Console.WriteLine( "    Func: Finally #{0}", n );
        }
    }

    static async Task Consumer()
    {
        for ( int i = 1; i <= 2; i++ )
        {
            Console.WriteLine( "Consumer: before await #{0}", i );
            int u = await Func( i );
            Console.WriteLine( "Consumer: after await #{0}", i );
        }
        Console.WriteLine( "Consumer: after the loop" );
    }

    public static void AsyncTest()
    {
        Task t = TaskEx.RunEx( Consumer );
        t.Wait();
        Console.WriteLine( "After the wait" );
    }
}

这是输出结果:
Consumer: before await #1
    Func: Begin #1
    Func: End #1
Consumer: after await #1
Consumer: before await #2
    Func: Begin #2
    Func: Finally #1
    Func: End #2
Consumer: after await #2
Consumer: after the loop
    Func: Finally #2
After the wait

如您所见,finally块的执行时间比您预期的要晚得多。
有什么解决方法吗?
提前感谢您!

看看反编译器生成的C#代码,以了解编译器生成的状态机的样子,这将非常有趣。这就是答案所在的地方。 - btlog
@btlog:我试过了。它将我的“return 0”转换为一些“SetResult()”调用,该调用在同一线程上从内部调用“Consumer”的等待块。有趣的是,在出现异常时,“finally”块会按预期执行,即在控制流离开“Func”之前执行。 - Soonts
这是一个很棒的发现 - 正在撰写我的答案 :) - Theo Yaung
2个回答

15

这是一个很好的发现 - 我同意在 CTP 中实际上存在一个 bug。我深入研究了一下,以下是它的原因:

这是 CTP 实现异步编译器转换和 .NET 4.0+ TPL(任务并行库)现有行为的组合。以下是影响因素:

  1. 源代码中的 finally 体被翻译成真正的 CLR-finally 体的一部分。这是有益的,其中一个原因是我们可以让 CLR 执行它,而不需要额外地捕获/重新抛出异常。这也在某种程度上简化了我们的代码生成 - 简单的代码生成会在编译后产生更小的二进制文件,这绝对是我们很多客户希望看到的:)
  2. Func(int n) 方法的总体 Task 是一个真正的 TPL 任务。当你在 Consumer() 中使用 await,那么 Consumer() 方法的其余部分实际上是安装在从 Func(int n) 返回的 Task 完成之后的继续执行。
  3. CTP 编译器转换异步方法的方式导致一个 return 被映射到一个真正的返回之前的 SetResult(...) 调用。 SetResult(...) 归结为调用 TaskCompletionSource<>.TrySetResult
  4. TaskCompletionSource<>.TrySetResult 信号 TPL 任务的完成。立即启用它的继续执行 "有时"。 这个 "有时" 可能意味着在另一个线程上或者在某些情况下,TPL 很聪明地说 "嗯,我可能会在这个同一线程上立即调用它"。
  5. 在 finally 运行之前,Func(int n) 的总体 Task 技术上变成 "已完成"。这意味着等待异步方法的代码可能在并行线程中运行,甚至可能在 finally 块之前运行。

考虑到总体 Task 应该代表方法的异步状态,因此它本质上不应该被标记为已完成,直到按照语言设计执行了所有用户提供的代码为止。我将与 Anders、语言设计团队和编译器开发人员讨论此问题。


表现范围/严重性:

你通常不会在 WPF 或 WinForms 中受到这个 bug 的影响,因为你有某种托管的消息循环正在进行。原因是 Task 实现上的 await 延迟到 SynchronizationContext。这导致异步继续被排队等待在预先存在的消息循环上,以在同一线程上运行。你可以通过以下方式更改代码来验证这一点:运行 Consumer()

    DispatcherFrame frame = new DispatcherFrame(exitWhenRequested: true);
    Action asyncAction = async () => {
        await Consumer();
        frame.Continue = false;
    };
    Dispatcher.CurrentDispatcher.BeginInvoke(asyncAction);
    Dispatcher.PushFrame(frame);

一旦在WPF消息循环的上下文中运行,输出将显示为您所期望的样子:

Consumer: before await #1
    Func: Begin #1
    Func: End #1
    Func: Finally #1
Consumer: after await #1
Consumer: before await #2
    Func: Begin #2
    Func: End #2
    Func: Finally #2
Consumer: after await #2
Consumer: after the loop
After the wait

解决方法:

可惜的是,这个解决方法意味着修改您的代码,不再在 try/finally 块中使用 return 语句。我知道这意味着您的代码流程会失去很多优雅。您可以使用异步辅助方法或帮助 Lambda 来解决此问题。个人而言,我更喜欢使用帮助 Lambda,因为它会自动关闭包含方法中的局部变量/参数,并使相关代码更加接近。

帮助 Lambda 方法:

static async Task<int> Func( int n )
{
    int result;
    try
    {
        Func<Task<int>> helperLambda = async() => {
            Console.WriteLine( "    Func: Begin #{0}", n );
            await TaskEx.Delay( 100 );
            Console.WriteLine( "    Func: End #{0}", n );        
            return 0;
        };
        result = await helperLambda();
    }
    finally
    {
        Console.WriteLine( "    Func: Finally #{0}", n );
    }
    // since Func(...)'s return statement is outside the try/finally,
    // the finally body is certain to execute first, even in face of this bug.
    return result;
}

辅助方法(Helper Method)方法:

static async Task<int> Func(int n)
{
    int result;
    try
    {
        result = await HelperMethod(n);
    }
    finally
    {
        Console.WriteLine("    Func: Finally #{0}", n);
    }
    // since Func(...)'s return statement is outside the try/finally,
    // the finally body is certain to execute first, even in face of this bug.
    return result;
}

static async Task<int> HelperMethod(int n)
{
    Console.WriteLine("    Func: Begin #{0}", n);
    await TaskEx.Delay(100);
    Console.WriteLine("    Func: End #{0}", n);
    return 0;
}
作为一个厚颜无耻的广告:我们在微软语言领域招聘人才,并且一直在寻找优秀的人才。博客文章这里列出了所有空缺职位的完整清单 :)

+1。这就是我对这个问题所期待的答案类型。我相信我需要一些时间来消化它,但至少你提供了解释(有些答案甚至没有超越“你的代码有问题,你还期望什么?”的蔑视)。 - paercebal

2

编辑

请参考Theo Yaung的答案

原始答案

我不熟悉async/await,但阅读了这篇文章:Visual Studio Async CTP Overview和阅读了你的代码后,我看到Func(int n)函数中的await,意味着从await关键字之后的代码直到函数结束将被作为委托稍后执行。

因此,我的猜测(这是一个未经教育的猜测)是Func:BeginFunc:End可能在不同的“上下文”(线程?)中执行,即异步执行。

因此,在Consumer中的int u = await Func( i );行将在达到Func中的await时继续执行。因此很可能会出现:

Consumer: before await #1
    Func: Begin #1
Consumer: after await #1
Consumer: before await #2
    Func: Begin #2
Consumer: after await #2
Consumer: after the loop
    Func: End #1         // Can appear at any moment AFTER "after await #1"
                         //    but before "After the wait"
    Func: Finally #1     // will be AFTER "End #1" but before "After the wait"
    Func: End #2         // Can appear at any moment AFTER "after await #2"
                         //    but before "After the wait"
    Func: Finally #2     // will be AFTER "End #2" but before "After the wait"
After the wait           // will appear AFTER the end of all the Tasks
Func: EndFunc: Finally可以出现在日志的任何位置,唯一的限制是Func: End #X会出现在其关联的Func: Finally #X之前,并且两者都应该出现在After the wait之前。正如Henk Holterman有点突兀地解释的那样,你在Func主体中放置await的事实意味着之后的所有内容将在某些时候执行。由于awaitFuncBeginEnd之间,因此没有变通方法,这是按设计的结果。这只是我不受过教育的2欧分。

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