在后台中等待异步方法会发生什么?

3

我已经阅读了许多关于异步等待的文章,正在尝试深入理解等待异步方法。我的问题是,我发现等待异步方法并不会创建一个新线程,而是只是使UI响应。如果是这样,使用等待异步没有什么时间上的优势,因为没有使用额外的线程。

据我所知,只有Task.Run()创建一个新线程。对于Task.WhenAll()或Task.WhenAny()也是如此吗?

假设我们有以下代码:

    async Task<int> AccessTheWebAsync()
            {
                using (HttpClient client = new HttpClient())
                {
                    Task<string> getStringTask = client.GetStringAsync("https://learn.microsoft.com");

                    DoIndependentWork();

                    string urlContents = await getStringTask;

                    return urlContents.Length;
                }
            }

我所期望的:

  1. 创建getStringTask任务时,另一个线程将复制当前上下文并开始执行GetStringAsync方法。

  2. 当等待getStringTask时,我们将查看另一个线程是否已完成其任务,如果没有完成,则控制权将返回AccessTheWebAsync()方法的调用者,直到另一个线程完成任务以恢复控制权。

因此,我真的不理解当等待任务时如何不会创建额外的线程。有人能解释一下等待任务时究竟发生了什么吗?


Task.Run将使用线程池,因此它很可能会在*不同的线程上运行,但这并不一定意味着它总是会为您的任务创建一个新线程。 - Lasse V. Karlsen
好的,那么 Task.WhenAll 呢?它会在线程池中使用不同的多个线程吗? 你也有对我上一个问题的答案吗? - Soufien Hajji
任务的整个意义在于允许您在后台运行某些内容,从而不会阻塞用户界面。您还可以使用它们做其他事情,例如并行运行多个任务,但一般来说,不要将任务视为与线程有关的任何东西(尽管它们确实是)。 - Reinstate Monica Cellio
2
可能是[如果async-await不创建任何额外的线程,那么它如何使应用程序响应?]的重复问题(https://dev59.com/jloU5IYBdhLWcg3we24R) - Robert Perry
3
阅读Stephen Cleary的《没有线程》,你会发现它几乎被所有关于async/await的好回答在SO中引用。 - Brett Caswell
显示剩余4条评论
4个回答

22
我已阅读关于async await的各种文章,现在正在尝试深入理解await async。我的问题是我发现等待异步方法并不会创建新线程,而只是使UI响应。这是正确的。非常重要的一点是要意识到await的含义是异步等待。它并不意味着“使此操作异步化”。而是指: - 此操作已经是异步的。 - 如果操作完成,请获取其结果。 - 如果操作未完成,请将其余部分作为不完整操作的继续项返回给调用方。 - 当不完整操作变为完成状态时,它将安排继续执行。 这是错误的。您没有正确考虑时间优势。想象这种情况:假设一个没有ATM的世界,我成长于那个世界,那是一个奇怪的时代。银行通常有一排人在等待存取款。假设该银行只接受和提供单美元纸币。现在,假设排队有三个人,他们每个人都想要十美元,而你只想要一个美元。下面是两个算法......

另一个优势是:银行柜员很昂贵;这个系统只雇用一个柜员就能完成小任务并获得良好的吞吐量。而在同步系统中,要想获得良好的吞吐量,就需要雇佣更多的柜员,这非常昂贵。

Task.WhenAll() 或 Task.WhenAny() 是否也适用此原理?

它们不会创建线程,只是接受一堆任务,在所有/任意任务完成时完成。

创建 getStringTask 任务时,另一个线程会复制当前上下文并开始执行 GetStringAsync 方法。

绝对不是这样的。该任务已经是异步的,并且由于它是 IO 任务,所以不需要线程。IO 硬件已经是异步的。因此不需要新工人。

等待 getStringTask 时,我们会看看其他线程是否已完成其任务。

不,没有其他线程。我们查看 IO 硬件是否已完成其任务。没有线程。

当你把面包放进烤面包机,然后去查看电子邮件时,烤面包机里没有人运行烤面包机。你可以启动一个异步作业,然后去做其他事情,因为你有特殊目的的硬件,其本质是异步的。网络硬件和烤面包机一样都是如此。没有线程。没有小人在你的烤面包机里运行它。它自己运行。

如果没有完成任务,控制权将返回 AccessTheWebAsync() 方法的调用方,直到另一个线程完成任务以恢复控制。

再次强调,没有其他线程。

但控制流正确。 如果任务已完成,则获取任务的值。如果未完成,则将控制返回给调用者,并将当前工作流的剩余部分分配为任务的后续过程。 任务完成后,后续过程将被安排运行。

我真的不明白等待任务时如何不会创建其他线程。

再次思考一下,当你在生活中因为受阻而停止做某个任务,然后做其他事情一段时间,最后在解除阻塞后再次开始执行第一个任务的时候,是否需要雇佣一个工人?当然不需要。但是你还是可以在烤面包机里烤面包的同时煮鸡蛋。基于任务的异步只是把这种现实生活的工作流程放到了软件中。

我总是惊叹于你们这些年轻人,你们听着奇怪的音乐却表现得好像线程一直存在,而做多任务没有其他方法。我学编程时的操作系统没有线程。如果你想让两个事情看起来同时发生,你必须自己构建异步;它不是内置在语言或操作系统中的。但我们成功了。

合作式单线程异步是回归到引入线程作为控制流结构之前的世界,这是一个更优雅、更简单的世界。在协作式多任务系统中,await 是一个挂起点。在没有引入线程的 Windows 中,你需要调用 Yield() 来实现,而我们也没有语言支持来创建 Continuations 和 Closures;如果你想让状态在 yield 之间保持不变,你需要编写代码来实现。你们现在可真幸福啊!

正如你所说的那样,只是没有使用线程。检查任务是否完成,如果完成了,你就完成了。如果没有完成,将剩余的工作流程安排为任务的后续操作,并返回。这就是 await 的全部内容。

我们在设计该功能时担心人们会像你现在可能仍然相信一样,认为 "await" 对其后面的 调用 做了某些事情。它没有。Await 对的是返回值。同样,当你看到:

int foo = await FooAsync();

你应该在脑海中想象:

Task<int> task = FooAsync();
if (task is not already completed) 
   set continuation of task to go to "resume" on completion
   return;
resume: // If we get here, task is completed
int foo = task.Result;

使用await调用方法并不是一种特殊的调用方式。“await”并没有启动任何线程等操作,它只是对返回值进行操作的运算符。

所以等待任务并不会启动一个线程。等待任务(1)检查任务是否完成,(2)如果没有完成,则将剩余的方法作为任务的继续项分配,并返回。这就是全部内容。await并不会创建线程。现在,也许被调用的方法会启动一个线程,但那是它自己的事情。这与await无关,因为await发生在调用返回之后。被调用函数不知道其返回值正在被等待

假设我们等待一个需要大量计算的CPU绑定任务。目前我所知道的是,如果是I/O绑定代码,它将在低级别CPU组件上执行(比线程低得多),并且仅使用一个线程简短地通知上下文有关已完成任务的状态。

关于上面对FooAsync的调用,我们知道它是异步的并且返回一个任务。我们不知道它是如何异步的。这是FooAsync作者的事情!但是,FooAsync作者可以使用三种主要技术来实现异步。正如您所指出的那样,主要技术有两种:

  • 如果任务因需要在当前机器上另一个CPU上进行长时间计算而具有高延迟性,则获得工作线程并开始在另一个CPU上执行工作是有意义的。当工作完成时,相关联的任务可以将其继续项安排回到UI线程(如果该任务是在UI线程上创建的)或者在适当的其他工作线程上运行。

  • 如果任务因需要与慢速硬件(例如磁盘或网络)通信而具有高延迟性,则像您指出的那样,没有线程。专用硬件以异步方式完成任务,最终由操作系统提供的中断处理负责在正确的线程上调度任务完成。

  • 第三个异步原因不是因为您正在管理高延迟操作,而是因为您正在将算法分解为小部分并将它们放在工作队列中。也许您正在制作自己的自定义调度程序、实施Actor模型系统或尝试进行无栈编程等。没有线程,没有I / O,但存在异步性。

所以再次强调,awaiting并不会使某些东西在工作线程上运行。调用启动工作线程的方法才会使某些东西在工作线程上运行。让您调用的方法决定是否创建工作线程。 异步方法已经是异步的。您不需要对它们做任何事情来使它们异步化。await并不会使任何内容异步化。

Await存在的唯一目的是为了使开发人员更容易检查异步操作是否已完成,并在未完成时将当前方法的剩余部分作为继续部分进行注册。这就是await的用途。再次强调,await不会创建异步性。await有助于构建异步工作流程。等待是工作流程中必须完成异步任务才能继续的点。
那是正确的。如果您拥有同步方法,并且知道它是CPU绑定的,并且希望将其异步化,并且知道该方法在另一个线程上运行是安全的,则Task.Run将找到一个工作线程,安排委托在工作线程上执行,并给您代表异步操作的任务。您只应该使用非常耗时的方法(1)超过30毫秒,比如CPU绑定的方法(2),(3)安全地调用另一个线程。否则可能会出现问题。如果雇用一个工人做不到30毫秒的工作,那么想想现实生活。如果您有一些计算要做,买广告、面试候选人、雇用人员、让他们加起三十个数字,然后解雇他们是有意义的吗?雇用一个工作线程是很昂贵的。如果雇佣成本比自己做这项工作更高,那么雇佣线程不会带来任何性能提升;反而会使情况变得更糟。如果您雇用一个工人来执行IO绑定任务,您就相当于雇用一个工人坐在邮箱旁边几年,并在邮件到达时大喊特喊。这并不能使邮件到达更快。这只是浪费了可以用于解决其他问题的工作资源。如果您雇用一个工人来执行非线程安全的任务,那么如果您雇用两个工人,告诉他们同时驾驶同一辆车到两个不同的地点,他们将在高速公路上争夺方向盘时撞车。

非常感谢您的出色回答。我只想确认一件事。 当等待任务时,是否总是没有线程被创建。比如说,我们等待一个执行大量计算的CPU绑定任务。 据我所知,I/O绑定代码将在低级CPU组件上执行(比线程低得多),并且仅短暂地使用线程来通知上下文有关已完成任务状态的信息。我还知道,我们使用Task.Run()来执行CPU绑定代码,以查找线程池中的可用线程。 这是真的吗? - Soufien Hajji
@SoufienHajji:不用谢。我在答案中回答了你的后续问题。 - Eric Lippert
3
使用TurboPascal在IBM/PC DOS上使用旧的行字符构建“多线程”UI的乐趣,在保存寄存器并通过从内存中恢复寄存器(包括指令指针)来跳转到下一个“线程”的同时,伪造一切。这让人回想起过去的记忆。如往常一样,Eric Lippert表现出色。 - Lasse V. Karlsen
@LasseVågsætherKarlsen:当我是一名合作学生时,我编写了一个基于字符模式NetWare服务器操作系统的纤程实现,作为向客户交付的生产代码。你必须做一些奇怪的事情来安全地移动堆栈寄存器,但我无论如何都记不起来了。回想起来,我既感到惊讶又感到恐惧,他们竟然将我的破解实现发送给真正依赖它的客户。Netware在零环中运行用户程序!任何错误都可能抹掉任何内存。 - Eric Lippert
@EricLippert 可能你的回答是我曾经得到过的最好的答案之一,音乐笑话太搞笑了。非常感谢 ^_^ 我再也不能要求更多了 :) - Soufien Hajji

3
如果这样的话,使用 await async 就不会获得时间收益,因为没有使用额外的线程。 这是正确的。 仅凭 asyncawait 本身并不直接使用线程。它们的目的是“释放调用线程”。 既 Task.WhenAllTask.WhenAny 都不直接使用任何线程。不。 GetStringAsync 在当前线程上同步调用,就像任何其他方法一样。 它再次同步地返回一个未完成的任务。 当等待 getStringTask 时,我们将查看另一个线程是否已完成其任务;如果没有,则控件将回到 AccessTheWebAsync() 方法的调用者,直到另一个线程完成其任务以恢复控制。
除了没有其他线程外,这个任务很接近。 await getStringTask 将检查 任务 是否已完成; 如果没有,则它将从 AccessTheWebAsync 返回一个未完成的任务。建议阅读我的 async intro 以获取更多详细信息。

谢谢。我刚刚读了你的文章,我了解到只有Task.Run会在线程池中寻找可用线程来执行CPU绑定代码。至于I/O绑定代码,它将在低级CPU组件上执行,而不是线程,并且仅短暂地使用一个线程来通知上下文有关已完成任务状态的信息。 - Soufien Hajji

0

一篇帮助我理解异步等待的文章是Eric Lippert的这次采访,在采访中他将异步等待与烹饪早餐进行了比较。在文章中间搜索异步等待相关内容。

如果一个厨师要做早餐,他只需把面包放进烤面包机里,就不必闲着等待面包烤好,而是开始四处寻找其他事情可以做,例如煮茶水。

当你使用异步等待时,类似的情况也会发生。如果你调用一个异步函数,你知道在函数内部会有一个等待。实际上,编译器会在你忘记在异步函数中等待时提醒你。

一旦线程看到等待,它并不会闲着等待可等待任务完成,而是四处寻找其他事情可以做。它可以沿着调用栈向上查找,看看是否有一个调用者还没有等待,并执行这些语句,直到看到等待。再沿着调用栈向上查找并执行语句,直到看到等待。

不能保证在您不等待异步调用后继续执行语句的线程与原始线程相同。但是,由于此线程具有相同的“上下文”,因此您可以将其视为相同的线程。无需关键部分等。

Console.Writeline(Thread.CurrentThread.ManagedThreadId);

// async call to the text reader to read a line; don't await
var taskReadLine = myTextReader.ReadLineAsync()

// because I did not await, the following will be executed as soon as a thread is free
Console.Writeline(Thread.CurrentThread.ManagedThreadId);
...

// we need the read line; await for it
string readLine = await taskReadLine;
Console.Writeline(Thread.CurrentThread.ManagedThreadId);
ProcessReadLine(readLine);

不能保证执行 DoSomething 的线程与调用 ReadLineAsync 的线程相同。如果在简单的测试程序中执行代码,则很有可能会得到多个线程 ID。

您的代码不应依赖于异步函数内的任何语句在等待结果之前被执行:

async Task<int> DoIt()
{
    this.X = 4;
    await DoSomethingElseAsync(this.X);
    return 5;
}
async Task CallDoItAsync()
{
    this.X = 0;
    var taskDoIt = DoIt();

    // you didn't await, it is not guaranteed that this.X already changed to 4
    ...
    int i = await taskDoIt();
    // now you can be certain that at some moment 4 had been assigned to this.X 

创建任务对象并不会创建线程。 创建线程是相当昂贵的。因此,您的进程拥有一个线程池,其中包含多个线程。空闲的线程被放入池中,并可根据请求执行其他任务。一旦您的进程需要一个线程,它就会从线程池中获取一个可用的线程并安排其运行。

如果线程池中没有可用的线程,我不确定会发生什么。我猜测您的函数只需等待可用线程即可。

您可以使用静态ThreadPool类访问线程池。

ThreadPool.GetMaxThreads (out int workerThreads, out int completionPortThreads);
++workerThreads;
++completionPortThreads;
bool success = ThreadPool.SetMaxThreads (workerThreads, completionPortThreads);

非常小心地更改线程池!

有些人说async-await只有在保持UI响应时才有用,但以下内容表明它也可以提高处理速度。

非异步:

void CopyFile(FileInfo infile, FileInfo outFile)
{
     using(var textReader = inFile.OpenText())
     {
        using (var textWriter = outFile.CreateText())
        {
            // Read a line. Wait until line read
            var line = textReader.ReadLine();
            while (line != null)
            {
                // Write the line. Wait until line written
                textWrite.WriteLine(line);

                // Read the next line. Wait until line read
                line = textReader.ReadLine();
            }
        }
    }
}

你看到了所有的等待。幸运的是,TextReader和TextWriter会缓冲数据,否则我们真的必须等到数据被写入后才能读取下一行。

async Task CopyFileAsync(FileInfo infile, FileInfo outFile)
{
     using(var textReader = inFile.OpenText())
     {
        using (var textWriter = outFile.CreateText())
        {
            // Read a line. Wait until line read
            var line = await textReader.ReadLineAsync();
            while (line != null)
            {
                // Write the line. Don't wait until line written
                var writeTask = textWrite.WriteLineAsync(line);

                // While the line is being written, I'm free to read the next line. 
                line = textReader.ReadLine();

                // await until the previous line has been written:
                await writeTask;
            }
        }
    }
}

在写入一行时,我们已经尝试读取下一行。这可以提高处理速度。


0

你的基本假设 —— Task 总是在一个线程上运行 —— 是错误的。一个简单的反例就是基于计时器的任务,它根本不运行: 它只是订阅计时器并在计时器触发时将任务状态设置为已完成。

更有用和更实际的任务不在任何地方运行的示例是网络请求: 它们发送请求,订阅传入的答案,然后停止运行,为另一项工作释放线程*。

因此,让我们考虑你实际的问题。


到目前为止,我所知道的是只有Task.Run()会创建一个新线程。这对于Task.WhenAll()或Task.WhenAny()也是正确的吗?
不,Task.WhenAll不会创建任何新线程。它将等待已经存在的任务完成,无论它们在哪里运行(而且无论它们是否在任何线程中运行!)。
由Task.WhenAll创建的任务本身并没有在任何特定的线程中运行!它只是检测底层任务何时完成,并在所有任务都准备好后自己也完成。Task.WhenAll不需要任何线程来执行此操作。
创建 getStringTask 任务时,另一个线程将复制当前上下文并开始执行 GetStringAsync 方法。
像之前看到的那样调用异步方法,比如 GetStringAsync,不会在任何特定的线程上执行。GetStringAsync 的代码设置了一些东西,以便在答案到来时它重新获得控制(可能在线程池线程上),并将控制权交还给您。准备工作可以在当前线程上完美完成,它不需要太多时间*。

*免责声明:这只是一个简化版,实际上网络异步请求执行的操作序列要复杂得多。


感谢您的回答。对于我的第一个问题,假设我们有Task.WhenAll(5 Tasks)。假设这5个任务不需要任何同步,那么这些任务将并行运行,这是否意味着在我们的线程池中同时使用了5个线程(如果我们直接找到5个可用线程)? - Soufien Hajji
1
@SoufienHajji:让我再强调一下,这些任务并不在线程上运行。因此,1000个正在运行的任务并不意味着占用了1000个线程。 - Vlad

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