不等待异步调用仍然是异步的,对吗?

20

如果这是一个愚蠢的问题(或者是重复的问题),请原谅。

我有一个函数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的调用不是一个“fire-and-forget”(点火并忘记)的调用 - B是这样被C调用的:

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

注意,在#1处没有使用await。我的同事声称这使得调用A同步,并且他不断提供Console.WriteLine日志,看起来证明了他的观点。

我试图指出,仅仅因为我们没有在B内等待结果,任务链仍然被等待,而且由于我们没有等待它,所以A内部代码的性质并不会改变。既然不需要从A获取返回值,就没有必要在调用点等待任务,只要任务在链的某个位置被等待(这发生在C中)。

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


6
我很乐意看到Console.WriteLine的代码,它似乎证明这是同步的。 - juharr
5
“Insistent”并非与“明显正确”相同。您的同事可能是对的,也可能完全错误。(您构造的示例也可能在整个操作中省略了其他内容。)从方程式中排除个性因素,并创建一个测试来展示您期望代码正在执行的操作。这种情况下,立即结束的控制台应用程序(不读取输入)尤其有用,因为如果异步操作需要一些时间而且没有被等待,则在可以观察到该操作的任何副作用之前,应用程序会终止。 - David
2
@David,上周在一个问题中有一个很好的例子,只不过是因为Main方法是async void,所以框架不知道是否还有任务在运行并终止了程序。尽管框架没有等待它,但它仍在异步运行。 - Gabriel Luci
1
@GabrielLuci:很好的例子。我曾经看到一些有趣的“测试”,我的同事们也是这样坚持不懈,我会收藏下面Eric Lippert的答案,以备将来发生这种情况时参考。在之前的一个项目中,有一个程序员确信他的“发送并忘记”的代码(将异步操作隐藏在同步接口后面并从未等待它)总是有效的,因为它在他的WinForms应用中“工作”了。但这只是巧合,因为该操作“工作”的时间正好是他关闭应用程序的时间。在控制台应用程序中,该操作永远不会完成。 - David
@David 啊,我明白你的意思了。这个例子只是在简化函数名称、删除与问题无关的日志和逻辑方面进行了编造。否则,这就是我与同事讨论过的实际代码。 - xxbbcc
显示剩余3条评论
2个回答

33
抱歉如果这是个愚蠢的问题。
这不是一个愚蠢的问题,它是一个重要的问题。
我有一个同事声称这会使调用A异步化,并且他一直在使用Console.WriteLine日志来证明他的观点。
那就是根本的问题所在,你需要教育你的同事,让他们停止误导自己和其他人。在C#中,没有所谓的异步调用。调用永远不会是异步的。跟我说:在C#中,调用不是异步的。在C#中,当你调用一个函数时,在计算完所有参数之后,该函数立即被调用。
如果你的同事或你认为有异步调用这样的事情,那么你将面临痛苦的世界,因为你对异步工作方式的信仰将与现实非常脱节。
那么,你的同事是正确的吗?当然是的。调用A是同步的,因为所有函数调用都是同步的。但是,他们相信存在“异步调用”的事实意味着他们在C#中对异步工作原理的认识是错误的。
如果特别是你的同事认为await M()会使对M()的调用“异步化”,那么你的同事是大错特错了。await是一个运算符。确实是一个复杂的运算符,但它是一个运算符,并且作用于值。await M()和var t = M(); await t;是一样的。等待发生在调用之后,因为await作用于返回的值。await不是编译器“生成对M()的异步调用”的指令或任何类似的东西;异步调用不存在。
如果这是他们错误信仰的本质,那么你有机会告诉你的同事await的含义。await意味着简单而强大的事情。它的意思是:
- 查看我正在操作的任务。 - 如果任务异常完成,则抛出该异常。 - 如果任务正常完成,则提取该值并使用它。 - 如果任务未完成,则将该方法的其余部分注册为等待任务的继续,并返回表示此调用的不完整异步工作流程的新任务到我的调用者。
那就是await所做的全部内容。它只是检查任务的内容,如果任务未完成,则会说“好吧,在该任务完成之前,我们无法在此工作流程上取得任何进展,因此返回给我的调用者,他将为此CPU找到其他事情做”。
引用:“A”内部代码的性质并不因为我们不等待它而改变。
这是正确的。我们同步调用A,它返回一个Task。调用站点后的代码直到A返回才运行。 A有趣的地方在于A允许向其调用者返回不完整的Task,该任务表示异步工作流中的节点。 工作流已经是异步的,正如您所指出的,对于A来说,您对其返回值进行的任何操作都没有影响,即使在它返回后你不知道是否会await返回的TaskA只要能运行就会一直运行,然后它会返回一个已正常完成的任务、已异常完成的任务或不完整的任务。但是,调用站点上执行的任何操作都不会改变这一点。
引用:“由于不需要从A返回值,因此在调用站点不需要等待任务。”
正确。
引用:“只要链条上有人等待它(在C中发生),在调用站点就不需要等待任务。”
现在你把我弄丢了。为什么有人必须等待A返回的Task说出你为什么认为某人需要awaitTask,因为你可能有错误的信念。 引用:“我的同事非常坚持,我开始怀疑自己。我的理解是否有误?”
你的同事几乎肯定是错的。你的分析在你说每个Task都需要await的那个部分似乎是正确的,但这并不正确。不await一个Task是很奇怪的,因为这意味着你编写了一个程序,在这个程序中你启动了一项操作,但并不关心它何时或如何完成,这确实让人感觉不太好。但并没有要求每个Task都必须await。如果你认为有这种要求,请再次说明你的想法,我们会搞清楚的。

1
@GabrielLuci:一点也不难。在C#中,调用非常简单。控制权离开调用者,进入被调用者,并且保留在被调用者中,直到被调用者返回抛出异常。异步方法很特殊,因为它们可以在工作流程完成之前返回。这是如何实现的?当它们这样做时,它们会创建一个委托,当调用该委托时,从离开的地方恢复工作流程,而该委托是返回任务的续集。当任务完成时,将调用续集并恢复工作流程。 - Eric Lippert
1
@GabrielLuci: 但是 异步方法可以在其工作流程完成之前返回 的事实并不改变 控制权仍然在调用方,直到它返回。 它怎么可能不是呢? 线程只有 一个 控制点; 这就是线程的本质! - Eric Lippert
1
没错,在实践中可以进行一些优化。但是从概念上来看,将继续作为一个委托来设想,它会神奇地接替工作流的位置,这更加清晰明了。 - Eric Lippert
1
@EricLippert 我也很抱歉,我觉得随意使用“调用”这个词给你留下了我认为函数调用可以是异步的印象 - 实际上,我并不这样认为。我想我可能应该使用“工作流”代替,就像你所做的那样。我认为你正确地假设我的同事认为await使调用异步化(我有几个类似信仰的同事)。正是他的坚持(我认为是错误的,但无法想到快速的方法来证明它)以及一些控制台输出让我质疑自己的理解。谢谢。 - xxbbcc
2
@GabrielLuci 我认为在日常讨论中,有意识地区分“调用”和“工作流程”是很困难的 - 我认为Eric与编译器的历史使他更容易这样做。我经常谈论“异步调用”,即使我了解语句如何逐个执行函数。(我不得不重写这条评论3次以求精确,但我仍然不确定我写出了我想说的话。) - xxbbcc
显示剩余3条评论

6
你是对的。创建任务仅完成此操作,并不关心谁会等待其结果以及何时等待其结果。尝试在 SomeOtherFuncAsync 中加入 await Task.Delay(veryBigNumber);,控制台输出应该与你预期的一样。
这被称为省略,我建议你阅读这篇博客文章,您可以了解为什么应该或者不应该这样做。
同时,还有一个最小化(有点复杂)的例子复制你的代码,证明你是正确的:
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,证明它确实是异步执行的。此外,您可以(很可能)看到函数的结尾运行在不同的线程上而不是程序的其他部分。由于这些内容是同步的(主函数开始,调用函数链,第三个函数返回(它可能在带有await关键字的行返回),然后主函数继续,好像从未涉及异步函数一样),因此始终会在同一个线程上运行主函数的开始和中间部分。在两个函数中await关键字之后的结束部分可能在ThreadPool中的任何线程上(或者您正在使用的同步上下文中)运行。

现在值得注意的是,如果Third中的Task.Delay没有花费太长时间并且实际上同步完成,则所有这些都将在单个线程上运行。更重要的是,即使它以异步方式运行,它也可能全部在单个线程上运行。没有规定异步函数将使用多个线程,它完全可以在等待某些I/O任务完成时执行其他工作。


您可以在每个步骤中打印线程 ID 以跟踪执行流程 Console.WriteLine("StepX" + Thread.CurrentThread.ManagedThreadId); - Kalten
1
@Kalten 我添加了它,但重要的是要注意,异步并不意味着并行,线程可能是相同的。 - Ordoshsen

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