等待异步方法在后台做什么



我已经红了关于异步await的各种文章,我正在尝试深入了解await async。我的问题是我发现等待异步方法不会创建一个新线程,而只是使 UI 响应。如果是这样,则使用 await 异步时不会增加时间,因为没有使用额外的线程。

到目前为止,我所知道的是只有 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 Task 时,另一个线程将复制当前上下文并开始执行 GetStringAsync 方法。

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

所以我真的不明白在等待任务时如何不创建额外的线程。有人可以解释一下等待任务时到底发生了什么吗?

我已经红了关于异步 await 的各种文章,我正在尝试深入了解 await async。

崇高的追求。

我的问题是,我发现等待异步方法不会创建新线程,而只是使 UI 响应。

正确。认识到await意味着异步等待非常重要。 这并不意味着">使此操作异步"。 这意味着:

  • 此操作已是异步的
  • 如果操作完成,则获取其结果
  • 如果操作未完成,
  • 请返回给调用方,并将此工作流的其余部分分配为未完成操作的延续。
  • 当未完成的操作完成时,它将计划继续执行。

如果是这样,则使用 await 异步时不会增加时间,因为没有使用额外的线程。

这是不正确的。你没有正确考虑时间胜利。

想象一下这个场景。

  • 想象一个没有自动取款机的世界。 我在那个世界长大。那是一个奇怪的时代。因此,银行通常会有一排人等待存款或取款。
  • 想象一下,这家银行只有一个出纳员。
  • 现在想象一下,银行只接受和发放一美元的钞票。

假设有三个人在排队,他们每个人都想要十美元。你加入队伍的末端,你只需要一美元。 以下是两种算法:

  • 给排队的第一个人一美元。
  • [ 这样做十次 ]
  • 给排队的第二个人一美元。
  • [ 这样做十次 ]
  • 给排队的第三个人一美元。
  • [ 这样做十次 ]
  • 给你你的美元。

每个人都要等多久才能拿到所有的钱?

  • 人一等待 10 个时间单位
  • 二人等待 20
  • 三人等待 30
  • 你等31。

这是一个同步算法。异步算法为:

  • 给排队的第一个人一美元。
  • 给排队的第二个人一美元。
  • 给排队的第三个人一美元。
  • 给你你的美元。
  • 给排队的第一个人一美元。

这是一个异步解决方案。现在大家等多久?

  • 每个得到十美元的人等待大约 30 美元。
  • 您等待 4 个单位。

大型作业的平均吞吐量较低,但小型作业的平均吞吐量要高得多这就是胜利。此外,在异步工作流中,每个人获得第一美元的时间都较短,即使大型作业的最后一美元时间更长。此外,异步系统是公平的;每个作业大约等待(作业大小)x(作业数)。在同步系统中,有些作业几乎不等待时间,有些作业等待很长时间。

另一个好处是:柜员很贵;这个系统雇用一个柜员,为小工作获得良好的吞吐量。如您所注意的,为了在同步系统中获得良好的吞吐量,您需要雇用更多的出纳员,这很昂贵。

对于 Task.WhenAll() 或 Task.WhenAny() 也是如此吗?

它们不创建线程。他们只是接受一堆任务并在所有/任何任务完成后完成。

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

绝对不行。 该任务已经是异步的,由于它是 IO 任务,因此不需要线程。IO 硬件已是异步的。没有雇用新工人。

在等待getStringTask时,我们将看到另一个线程是否完成了他的任务

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

当你把一块面包放在烤面包机里,然后去查看你的电子邮件时,烤面包机里没有人在运行烤面包机。事实上,您可以启动异步作业,然后在它工作时执行其他操作,这是因为您具有本质上是异步的特殊用途硬件。网络硬件也是如此,就像烤面包机一样。没有线程。没有一个小人运行你的烤面包机。它自行运行。

否则,该控件将返回到 AccessTheWebAsync() 方法的调用方,直到另一个线程完成其恢复控件的任务。

同样,没有其他线程。

但控制流是正确的。如果任务完成,则获取任务的值。如果未完成,则在将当前工作流的其余部分分配为任务的延续后,控制权将返回给调用方。任务完成后,将计划运行延续。

我真的不明白在等待任务时如何没有创建额外的线程。

再一次,想想你生命中的每一次,当你因为被阻止而停止做一项任务时,做了一段时间的其他事情,然后在你被解锁时再次开始做第一个任务。你必须雇用工人吗?当然不是。然而,不知何故,当吐司在烤面包机里时,你设法做了鸡蛋。 基于任务的异步只是将现实世界的工作流程放入软件中。

它永远不会停止让我惊讶,今天你们的孩子如何用奇怪的音乐表现得好像线程一直存在,没有其他方法可以进行多任务处理。我学会了如何在没有线程的操作系统中编程。如果你想让两件事同时发生,你必须建立自己的异步;它没有内置于语言或操作系统中。然而,我们做到了。

协作单线程异步是回归到我们错误地引入线程作为控制流结构之前的世界;一个更优雅、更简单的世界。等待是协作式多任务系统中的暂停点。在预线程 Windows 中,您需要为此调用Yield(),我们没有用于创建延续和闭包的语言支持;您希望状态在收益中持续存在,因此编写了代码来执行此操作。你们都很容易!

有人可以解释一下等待任务时到底发生了什么吗?

正是你说的,只是没有线程。 检查任务是否完成;如果完成了,你就完成了。如果没有,请将工作流的其余部分安排为任务的延续,然后返回。这就是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 的方法的调用不是一种特殊的调用。"等待"不会启动线程或类似的东西。它是对返回的值进行操作的运算符。

因此,等待任务不会启动线程。 等待任务 (1) 检查任务是否完成,(2) 如果未完成,则将方法的其余部分分配为任务的继续,然后返回。就这样。Await不执行任何操作来创建线程。现在,也许被调用的方法启动了一个线程;这就是生意。这与等待无关,因为等待直到调用返回后才会发生。被调用的函数不知道正在等待其返回值

假设我们等待一个执行繁重计算的 CPU 绑定任务。到目前为止,我所知道的是一个 I/O 绑定代码,它将在低级 CPU 组件(远低于线程)上执行,并且只短暂地使用线程来通知上下文有关已完成的任务状态。

我们对上面对 FooAsync 的调用所知道的是,它是异步的,它返回一个任务。我们不知道它是如何异步的。这就是FooAsync业务的作者!但是FooAsync的作者可以使用三种主要技术来实现异步。如您所知,两种主要技术是:

  • 如果任务是高延迟的,因为它需要在另一个 CPU 上的当前计算机上完成长时间的计算,那么获取工作线程并启动在另一个 CPU 上执行工作的线程是有意义的。 工作完成后,如果任务是在 UI 线程上创建的,则关联的任务可以根据需要安排其继续在 UI 线程上重新运行

  • 如果任务是高延迟的,因为它需要与慢速硬件(如磁盘或网络)进行通信,那么正如您所注意到的,没有线程。专用硬件异步执行任务,操作系统提供的中断处理最终负责在正确的线程上安排任务完成。

  • 异步的第三个原因不是因为您正在管理高延迟操作,而是因为您将算法分解为小部分并将它们放在工作队列中。也许你正在制作自己的自定义调度程序,或者实现一个Actor模型系统,或者尝试做无堆栈编程,或者其他什么。没有线程,没有 IO,但有异步。

因此,同样,等待不会使某些内容在工作线程上运行。调用启动工作线程的方法会使某些内容在工作线程上运行。让要调用的方法决定是否创建工作线程。异步方法已经是异步的。 您无需对它们执行任何操作即可使它们异步。 等待不会使任何内容异步。

Await 的存在只是为了让开发人员更容易检查异步操作是否已完成,并在当前方法的其余部分尚未完成时将当前方法的其余部分注册为延续。这就是它的用途。 同样,等待不会创建异步。 Await可帮助您构建异步工作流等待是工作流中的一个点,在该点上,必须先完成异步任务,然后工作流才能继续

我也知道我们使用 Task.Run() 来执行 CPU 绑定代码以在线程池中查找可用线程。这是真的吗?

没错。如果您有一个同步方法,并且您知道它是 CPU 密集型的,并且您希望它是异步的,并且您知道该方法可以在另一个线程上运行是安全的,则 Task.Run 将找到一个工作线程,计划要在工作线程上执行的委托,并为您提供一个表示异步操作的任务。您应该只使用以下方法执行此操作:(1) 运行时间非常长,例如超过 30 毫秒,(2) CPU 受限,(3) 可以安全地调用另一个线程。

如果你违反了其中任何一个,坏事就会发生。如果你雇佣一个工人做不到30毫秒的工作,那么,想想现实生活。如果你有一些计算要做,买一个广告,面试候选人,雇用某人,让他们把三十几个数字加在一起,然后解雇他们,这有意义吗?雇用工作线程是昂贵的。如果雇用线程比自己做工作更昂贵,那么雇用线程根本无法获得任何性能胜利;你会让它变得更糟。

如果你雇佣一个工人来做IO绑定任务,你所做的就是雇佣一个工人坐在邮箱旁边多年,当邮件到达时大喊大叫。这不会使邮件更快到达。它只是浪费了本可以花在其他问题上的工人资源。

如果你雇佣一个工人来做一个不安全的任务,好吧,如果你雇佣两个工人,告诉他们同时把同一辆车开到两个不同的地点,当他们在高速公路上争夺方向盘时,他们会撞车。

如果是这样,则使用 await 异步时没有时间增益,因为没有使用额外的线程。

这是正确的。asyncawait本身不直接使用线程。它们的目的是释放调用线程

到目前为止,我所知道的是只有 Task.Run() 创建一个新线程。对于 Task.WhenAll() 或 Task.WhenAny() 也是如此吗?

不,Task.WhenAllTask.WhenAny都不直接使用任何线程。

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

不。GetStringAsync在当前线程上同步调用,就像任何其他方法一样。它再次同步返回未完成的任务。

当等待getStringTask时,我们将看到另一个线程是否完成了他的任务,如果没有,控件将返回AccessTheWebAsync()方法的调用者,直到另一个线程完成它的任务以恢复控件。

关闭,除了没有其他线程。await getStringTask将检查任务是否完成;如果不是,那么它将从AccessTheWebAsync返回一个未完成的任务。

有人可以解释等待任务时到底发生了什么吗?

我建议阅读我的async介绍以获取更多详细信息。

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

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

因此,让我们考虑一下您的实际问题。


到目前为止,我所知道的是只有 Task.Run() 创建一个新线程。对于 Task.WhenAll() 或 Task.WhenAny() 也是如此吗?

不,Task.WhenAll不会创建任何新线程。它将等待现有任务完成,无论它们在哪里运行(也无论它们是否在任何线程中运行!

Task.WhenAll创建的任务本身未在任何特定线程中运行!它只是检测基础任务何时完成,并在所有任务准备就绪后,也会自行完成。Task.WhenAll不需要任何线程来执行此操作。


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

调用像GetStringAsync这样的异步方法,正如我们之前看到的,不会在任何特定的线程上执行。GetStringAsync代码设置了这些东西,以便在答案出现时它重新获得控制权(可能在线程池线程上),并将控制权交还给您。准备工作可以在当前线程上完美完成,不需要太多时间*。


*免责声明:这是一种简化,实际上网络异步请求完成的操作顺序要复杂得多。

一篇对我理解 async-await 有很大帮助的文章是对 Eric Lippert 的采访,他将 async-await 与做早餐的厨师进行了比较。在中间的某个地方搜索异步等待。

如果一个厨师必须做早餐,而他只是在烤面包机里放了一些面包,他不会闲着等待面包被烤熟,而是开始环顾四周,看看他是否可以做其他事情,例如煮沸水泡茶。

当您看到 async-await 时,也会发生类似的事情。如果你调用一个异步函数,你就知道里面的某个地方是一个等待。事实上,如果你忘记在异步函数中等待,编译器会警告你。

一旦您的线程看到 await,它就不会闲置地等待可等待任务完成,而是环顾四周以查看它是否可以执行其他操作。它可以向上调用堆栈以查看其中一个调用方是否尚未等待,并执行这些语句,直到看到等待。再次向上调用堆栈并执行语句,直到看到 await。

无法保证在未等待的异步调用后继续语句的线程与原始线程相同。但是由于此线程具有相同的"上下文",因此您可以像是同一线程一样运行。不需要关键部分等。

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 

创建 Task 对象不会创建线程。创建线程的成本相当高。因此,您的进程有一个线程池,其中包含多个线程。空闲的线程放在池中,并可根据请求执行其他操作。一旦您的进程需要线程,它就会从线程池中获取一个可用线程并计划它运行。

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

您可以使用静态 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;
}
}
}
}

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

最新更新