【问题标题】:Not awaiting an async call is still async, right?不等待异步调用仍然是异步的,对吗?
【发布时间】:2019-09-21 08:34:00
【问题描述】:

如果这是一个愚蠢的问题(或重复),我很抱歉。

我有一个函数A

public async Task<int> A(/* some parameters */)
{
    var result = await SomeOtherFuncAsync(/* some other parameters */);

    return (result);
}

我还有另一个函数B,调用A但没有使用返回值:

public Task B(/* some parameters */)
{
    var taskA = A(/* parameters */); // #1

    return (taskA);
}

请注意,B 未声明为 async,并且未等待对 A 的调用。对A 的调用不是一劳永逸的调用——BC 调用,如下所示:

public async Task C()
{
    await B(/* parameters */);
}

请注意,在 #1 处,没有 await。我有一位同事声称这使得对A 的调用是同步的,并且他不断提出Console.WriteLine 日志,这似乎证明了他的观点。

我试图指出,仅仅因为我们不等待 B 中的结果,任务链 等待并且 A 中的代码性质不会改变因为我们不等待它。由于不需要来自A 的返回值,因此无需在调用站点等待任务,只要链上的某个人等待它(这发生在C)。

我的同事非常坚持,我开始怀疑自己。是不是我的理解错了?

【问题讨论】:

  • 我很想看到Console.WriteLine 代码似乎证明这是同步的。
  • “坚持”与“明显正确”不同。您的同事可能有观点,或者可能完全错了。 (您设计的示例也可能在整个操作中删除了其他内容。)从等式中删除个性并创建一个测试来演示您期望代码执行的操作。立即结束(不读取输入)的控制台应用程序对此特别有用,因为如果异步操作需要一些时间并且没有等待,那么应用程序将在可以观察到该操作的副作用之前终止。
  • @David 实际上在a question last week 中有一个很好的例子,只是因为Main 方法是async void,因此框架不知道任何任务仍在运行和终止该程序。它异步运行,即使框架没有等待它。
  • @GabrielLuci:很好的例子。我已经看到了一些有趣的“测试”,我同样坚持不懈的同事,我在下面为 Eric Lippert 的答案添加了书签,以备下次发生时参考。上一个项目的一个例子是一位程序员,他确信他的“即发即弃”代码(在同步接口后面隐藏一些异步的东西,从不等待它)总是有效的,因为它在他的 WinForms 应用程序中“有效”。这只是一个巧合,因为该操作在他关闭应用程序的时间内“起作用”。在控制台应用程序中,操作从未完成。
  • @David 为什么你说这个例子是做作的?它准确地演示了所讨论的场景(当然,实际功能不同,它们采用实际参数等)

标签: c# .net asynchronous async-await


【解决方案1】:

如果这是一个愚蠢的问题,我很抱歉

这不是一个愚蠢的问题。这是一个重要的问题。

我有一位同事声称这使得对 A 的调用是同步的,他不断提出 Console.WriteLine 日志,这似乎证明了他的观点。

这是根本问题,您需要教育您的同事,以免他们误导自己和他人。 没有异步调用这种东西调用不是异步的,永远。跟我说吧。 C# 中的调用不是异步的。在 C# 中,当您调用一个函数时,该函数会在计算完所有参数后立即调用

如果您或您的同事或您认为存在异步调用之类的东西,那么您将陷入痛苦的世界,因为您对异步工作原理的信念将与现实脱节。

那么,你的同事说得对吗?他们当然是。 A 的调用是同步的,因为所有函数调用都是同步的。但是他们相信存在“异步调用”这样的东西,这意味着他们对异步在 C# 中的工作方式有很大的误解。

如果您的同事特别认为await M() 以某种方式调用M()“异步”,那么您的同事有很大的误解。 await 是一个操作员。可以肯定的是,它是一个复杂的运算符,但它是一个运算符,它对值进行操作。 await M()var t = M(); await t;同一个东西。 await 发生在调用之后,因为await对返回的值进行操作await不是对编译器的“生成对 M() 的异步调用”或任何此类事情的指令;没有“异步调用”之类的东西。

如果这是他们错误信念的本质,那么您就有机会让您的同事了解await 的含义。 await 意味着简单而强大的东西。意思是:

  • 看看我正在操作的Task
  • 如果任务异常完成,则抛出该异常
  • 如果任务正常完成,提取该值并使用它
  • 如果任务不完整,注册这个方法的剩余部分作为等待任务的延续,并返回一个新的Task代表这个调用的不完整的异步我的来电者的工作流程。

这就是await 所做的一切。它只是检查任务的内容,如果任务不完整,它会说“好吧,在该任务完成之前我们无法在这个工作流上取得任何进展,所以返回给我的调用者,他会为这个 CPU 找到其他东西去做”。

A 中代码的性质不会因为我们不等待而改变。

没错。我们同步调用A,它返回一个Task。调用站点之后的代码在A 返回之前不会运行。 A 的有趣之处在于,A 可以向其调用者返回一个不完整的Task,并且该任务表示异步工作流中的一个节点。工作流已经是异步的,正如您所注意到的,它与A 的返回值返回之后的操作没有区别; A 不知道你是否要去await 返回的TaskA 只运行尽可能长的时间,然后它要么返回一个正常完成的任务,要么返回一个异常完成的任务,或者它返回一个不完整的任务。但是您在呼叫站点所做的任何事情都不会改变这一点。

由于不需要A的返回值,所以不需要在调用点等待任务

正确。

没有必要在调用站点等待任务,只要链上的某个人在等待它(这发生在 C 中)。

现在你失去了我。为什么有人必须等待A 返回的Task告诉awaitTask,你为什么认为某人是必需,因为你可能有一个错误的信念。

我的同事非常坚持,我开始怀疑自己。是不是我的理解错了?

你的同事几乎肯定是错的。您的分析似乎是正确的,直到您说每个Task 都是awaited 的要求,这是不正确的。不是awaitTask奇怪,因为这意味着您编写了一个程序,您在其中开始操作并且不关心它何时或如何完成,而且它肯定闻起来编写这样的程序不好,但是对于每个Taskawait 没有要求。如果您相信存在,请再次说出这种信念是什么,我们会解决的。

【讨论】:

  • @GabrielLuci:一点也不。 C# 中的调用非常简单。控制离开调用者,进入被调用者,并保持在被调用者中直到被调用者返回抛出。异步方法很特别,因为它们可以在工作流完成之前返回。这是如何运作的? 当他们这样做时,他们会创建一个委托,该委托在被调用时会从其中断处恢复工作流,并且该委托是返回任务的继续。任务完成后,将调用 continuation 并恢复工作流。
  • @GabrielLuci:但是,异步方法可以在其工作流完成之前返回这一事实并没有改变控制权保留在被调用者中直到它返回的事实我>。怎么可能没有?线程只有一个控制点;这就是线程!
  • 对,在实践中可以进行一些优化。但从概念上讲,将延续设想为神奇地从工作流程中断的地方开始的委托会更加清晰。
  • @EricLippert 我也很抱歉,我认为松散地使用“调用”这个词给你的印象是我认为函数调用可以是异步的——事实上,我不这么认为。我想我可能应该像你一样使用“工作流程”。我认为您正确地假设我的同事认为await 使调用异步(我有几个具有相似信念的同事)。正是他的坚持(我认为这是错误的,但想不出一种快速的方法来反驳它)与一些控制台输出让我质疑我的理解。谢谢。
  • @GabrielLuci 我认为在日常讨论中很难有意识地将“调用”和“工作流程”分开 - 我认为 Eric 与编译器的历史让他更容易做到这一点。我经常谈论“异步调用”,尽管我了解如何在一个函数中一次执行一个语句。 (为了精确起见,我不得不重写此评论 3 次,但我仍然不确定我是否写下了我想说的。)
【解决方案2】:

你是对的。创建一个任务仅此而已,它并不关心何时以及谁将等待其结果。尝试将await Task.Delay(veryBigNumber); 放入SomeOtherFuncAsync 中,控制台输出应该是您所期望的。

这称为省略,我建议您阅读this blogpost,您可以在其中了解为什么应该或不应该这样做。

还有一些最小(有点复杂)的例子,复制你的代码来证明你是对的:

class Program
    {
        static async Task Main(string[] args)
        {
            Console.WriteLine($"Start of main {Thread.CurrentThread.ManagedThreadId}");
            var task = First();
            Console.WriteLine($"Middle of main {Thread.CurrentThread.ManagedThreadId}");
            await task;
            Console.WriteLine($"End of main {Thread.CurrentThread.ManagedThreadId}");
        }

        static Task First()
        {
            return SecondAsync();
        }

        static async Task SecondAsync()
        {
            await ThirdAsync();
        }

        static async Task ThirdAsync()
        {
            Console.WriteLine($"Start of third {Thread.CurrentThread.ManagedThreadId}");
            await Task.Delay(1000);
            Console.WriteLine($"End of third {Thread.CurrentThread.ManagedThreadId}");
        }
    }

这在End of third之前写了Middle of main,证明它实际上是异步的。此外,您可以(很可能)看到函数的末端与程序的其余部分在不同的线程上运行。 main 的开始和中间总是在同一个线程上运行,因为它们实际上是同步的(main 开始,调用函数链,第三次返回(它可能在带有 await 关键字的行返回)然后 main 继续为如果没有涉及异步函数。两个函数中 await 关键字之后的结尾可以在 ThreadPool 中的任何线程上运行(或在您正在使用的同步上下文中)。

现在有趣的是,如果Third 中的Task.Delay 没有花费很长时间并且实际上是同步完成的,那么所有这些都将在单个线程上运行。更重要的是,即使它会异步运行,它可能都在单个线程上运行。没有规则规定异步函数将使用多个线程,它很可能只是在等待某些 I/O 任务完成时做一些其他工作。

【讨论】:

  • 您可以在每一步打印线程ID以遵循执行流程Console.WriteLine("StepX" + Thread.CurrentThread.ManagedThreadId);
  • @Kalten 我在那里添加了它,但需要注意的是,异步并不意味着并行,线程可能相同。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-06-30
  • 2019-03-03
  • 2023-03-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多