如果async-await不创建任何额外的线程,那么它如何使应用程序具有响应性?

一次又一次,我看到它说使用async-await不会创建任何额外的线程。这是没有道理的,因为计算机一次可以做多件事的唯一方法是

  • 实际上同时做多件事(并行执行,使用多个处理器)
  • 通过调度任务并在它们之间切换来模拟它(做一点a,一点B,一点a,等等)。

因此,如果async-await两者都没有,那么它如何使应用程序具有响应性呢?如果只有1个线程,那么调用任何方法都意味着在执行其他操作之前等待该方法完成,并且该方法中的方法必须在继续执行之前等待结果,以此类推。

91829 次浏览

我在我的博客文章没有线中详细解释了这一点。

总之,现代I/O系统大量使用DMA(直接内存访问)。在网卡、显卡、硬盘控制器、串行/并行端口等上都有特殊的专用处理器。这些处理器可以直接访问内存总线,并且完全独立于CPU处理读写。CPU只需要通知设备内存中包含数据的位置,然后就可以做自己的事情,直到设备引发中断通知CPU读/写完成。

一旦操作在运行中,CPU就没有工作要做,因此没有线程。

实际上,async/await并没有那么神奇。整个话题相当广泛,但对于你的问题,我认为我们可以快速而完整地回答。

让我们在Windows窗体应用程序中处理一个简单的按钮点击事件:

public async void button1_Click(object sender, EventArgs e)
{
Console.WriteLine("before awaiting");
await GetSomethingAsync();
Console.WriteLine("after awaiting");
}

我将显式地 谈论GetSomethingAsync现在返回的是什么。假设这是一个在2秒后就能完成的任务。

在传统的非异步环境中,你的按钮点击事件处理程序是这样的:

public void button1_Click(object sender, EventArgs e)
{
Console.WriteLine("before waiting");
DoSomethingThatTakes2Seconds();
Console.WriteLine("after waiting");
}

当您单击表单中的按钮时,应用程序将冻结大约2秒,而我们等待此方法完成。所发生的是“消息泵”,基本上是一个循环,被阻塞了。

这个循环不断地问窗口“有人做了什么,比如移动了鼠标,点击了什么?”我需要重新粉刷吗?如果有,请告诉我!”然后处理“某事”。这个循环得到一条消息,用户点击了“button1”。(或来自Windows的等效类型的消息),并最终调用上面的button1_Click方法。在此方法返回之前,这个循环现在一直在等待。这需要2秒,在此期间,没有消息被处理。

处理窗口的大多数事情都是使用消息完成的,这意味着如果消息循环停止泵送消息,即使只有一秒钟,用户也会很快注意到。例如,如果您将记事本或任何其他程序移到您自己的程序之上,然后又移开,则会向您的程序发送一系列绘制消息,指示窗口的哪个区域现在突然再次可见。如果处理这些消息的消息循环正在等待某个东西,被阻塞,那么就没有绘制完成。

那么,如果在第一个例子中,async/await没有创建新线程,它是如何做到的呢?

你的方法被分成了两个。这是一个宽泛的主题类型的东西,所以我不会讲得太详细,但足以说明该方法分为以下两部分:

  1. await之前的所有代码,包括对GetSomethingAsync的调用
  2. await后面的所有代码

说明:

code... code... code... await X(); ... code... code... code...

重新安排:

code... code... code... var x = X(); await X; code... code... code...
^                                  ^          ^                     ^
+---- portion 1 -------------------+          +---- portion 2 ------+

基本上这个方法是这样执行的:

  1. 它执行await之前的所有内容

  2. 它调用GetSomethingAsync方法,它做它的事情,并返回在未来2秒内完成的事情

    到目前为止,我们仍然处于对button1_Click的原始调用中,该调用发生在主线程上,从消息循环调用。如果指向await的代码花费了很多时间,UI仍然会冻结。在我们的例子中,没有那么多

  3. await关键字,加上一些聪明的编译器魔法,所做的是它基本上类似于&;好吧,你知道吗,我将简单地从这里的按钮单击事件处理程序返回。当你(例如,我们正在等待的东西)抽出时间完成时,请告诉我,因为我还有一些代码要执行”。

    实际上,它将让SynchronizationContext类知道它已经完成,这取决于当前正在执行的实际同步上下文,将排队等待执行。Windows窗体程序中使用的上下文类将使用消息循环抽取的队列对其进行排队。

  4. 因此它返回到消息循环,该循环现在可以自由地继续抽取消息,例如移动窗口、调整窗口大小或单击其他按钮。

    对于用户来说,UI现在再次响应,处理其他按钮点击,调整大小,最重要的是,重绘,因此它不会出现冻结。

  5. 2秒后,我们等待的事情完成了,现在发生的事情是它(好吧,同步上下文)将一条消息放入消息循环正在查看的队列中,说“嘿,我有一些更多的代码让你执行”,而这段代码就是所有的代码 await。

  6. 当消息循环到达该消息时,它基本上会“重新进入”;在await之后继续执行该方法的其余部分。注意,这段代码再次从消息循环中调用,所以如果这段代码碰巧在没有正确使用async/await的情况下做了很长的事情,它将再次阻塞消息循环

这里有很多活动部件,所以这里有一些链接到更多信息,我想说“如果你需要它”,但这个话题相当广泛,了解一些移动的部分是相当重要的。你总是会明白async/await仍然是一个有漏洞的概念。一些潜在的限制和问题仍然会泄漏到周围的代码中,如果它们不这样做,您通常最终不得不调试一个似乎没有什么好理由的随机崩溃的应用程序。


好吧,那么如果GetSomethingAsync旋转一个将在2秒内完成的线程呢?是的,很明显有新的线索。这个线程,然而,不是因为的异步性的这个方法,这是因为这个方法的程序员选择了一个线程来实现异步代码。几乎所有异步I/O 都使用线程,它们使用不同的东西。async/await 本身不会旋转新的线程,但显然“我们等待的东西”;可以使用线程实现。

.NET中有许多东西不一定会自己启动线程,但仍然是异步的:

  • Web请求(以及许多其他需要时间的与网络相关的事情)
  • 异步文件读写
  • 还有更多,如果有问题的类/接口有名为SomethingSomethingAsyncBeginSomethingEndSomething的方法,并且涉及到IAsyncResult,则是一个好迹象。

通常这些东西在引擎盖下不使用线程。


好吧,所以你想要一些“宽泛的话题”?

好吧,让我们问尝试Roslyn关于按钮点击:

试试Roslyn

我不打算在这里链接完整的生成类,但这是相当血腥的东西。

计算机能同时做多件事的唯一方法是(1)实际上同时做多件事,(2)通过调度任务和在任务之间切换来模拟它。如果async-await两者都不做

这并不是说await做了既不。记住,await的目的不是使同步代码神奇地异步化。它是用来启用在调用异步代码时,使用与编写同步代码相同的技术的。Await大约是使使用高延迟操作的代码看起来像使用低延迟操作的代码。这些高延迟操作可能在线程上,也可能在特殊用途的硬件上,它们可能将工作分解成小块,并将其放入消息队列中,以便稍后由UI线程处理。它们使用某物来实现异步,但是使用他们来实现异步。Await可以让您利用这种异步性。

而且,我认为你缺少了第三个选择。我们这些老年人——今天的孩子们带着饶舌音乐应该离开我的草坪,等等——记得20世纪90年代早期的Windows世界。没有多cpu机器,也没有线程调度程序。如果你想同时运行两个Windows应用程序,你必须收益率。多任务处理是合作。操作系统告诉一个进程它可以运行,如果它表现不佳,它就会阻止所有其他进程被服务。它一直运行,直到它屈服,并且以某种方式它必须知道如何在下一次操作系统将控制权交还给它时,重新拾起它离开的地方。单线程异步代码与此非常相似,使用“await”而不是“yield”。等待意味着“我要记住我离开这里的地方,让别人跑一会儿;等我等的任务完成了再打给我,我就会继续刚才的工作。”我想你可以看到,这让应用程序的响应速度更快,就像Windows 3时代一样。

调用任何方法都意味着等待该方法完成

这就是你所缺少的关键。方法可以在其工作完成之前返回。这就是异步的本质。方法返回,它返回一个任务,表示“这项工作正在进行中;完工后告诉我该做什么。”方法的工作还没有完成,即使它已经回来了

在await操作符之前,你必须编写看起来像意大利面穿过瑞士奶酪的代码,以处理我们有工作要做在完成之后,但与返回和完成不同步的事实。Await允许你编写代码,使看起来像return和completion一样是同步的,而它们实际上是同步的。

我真的很高兴有人问这个问题,因为很长一段时间以来,我也认为线程对于并发性是必要的。当我第一次看到事件循环时,我认为他们是一个谎言。我对自己说:“如果这段代码在一个线程中运行,它就不可能是并发的”。请记住这是,我已经经历了理解并发和并行之间的区别的斗争。

经过我自己的研究,我终于找到了缺失的部分:select()。具体来说,IO多路复用,由不同名称的内核实现:select()poll()epoll()kqueue()。它们是系统调用,虽然实现细节不同,但允许你传入一组文件描述符来观察。然后,您可以进行另一个调用,该调用将阻塞,直到被监视的文件描述符之一发生变化。

因此,可以等待一组IO事件(主事件循环),处理第一个完成的事件,然后将控制权交还给事件循环。清洗并重复。

这是如何工作的呢?简而言之,这是内核和硬件级的魔力。计算机中除了CPU之外还有许多组件,这些组件可以并行工作。内核可以控制这些设备,并直接与它们通信以接收特定的信号。

这些IO多路复用系统调用是单线程事件循环(如node.js或Tornado)的基本构建块。当你await一个函数时,你正在观察一个特定的事件(该函数的完成),然后将控制权交还给主事件循环。当您正在观看的事件完成时,函数(最终)从它停止的地方开始。允许像这样暂停和恢复计算的函数被称为协同程序

awaitasync使用任务而不是线程。

框架有一个线程池,准备以任务对象的形式执行一些工作; 向池中提交任务意味着选择一个空闲的已经存在的1线程来调用该任务 动作方法。< br > 创建任务是创建一个新对象的问题,比创建一个新线程快得多 给定一个任务可以附加一个延续给它,它是一个新的任务对象要执行 当线程结束时,

因为async/await使用了__abc1,所以它们没有创建一个新的线程。


虽然中断编程技术在每个现代操作系统中都被广泛使用,但我不认为它们是 有关。< br > 你可以让两个CPU绑定任务在一个CPU上并行执行(实际上是交错执行) aysnc/await。< br > 这不能简单地用操作系统支持队列IORP.

来解释
上次我检查了编译器将async方法转换为DFA,工作分为步骤, 每一个都以await指令结束 await开始它的任务并给它附加一个continuation来执行下一个 的一步。< / p > 作为一个概念示例,这里有一个伪代码示例 为了清晰起见,事情被简化了,因为我不记得所有的细节
method:
instr1
instr2
await task1
instr3
instr4
await task2
instr5
return value

它会变成这样

int state = 0;


Task nextStep()
{
switch (state)
{
case 0:
instr1;
instr2;
state = 1;


task1.addContinuation(nextStep());
task1.start();


return task1;


case 1:
instr3;
instr4;
state = 2;


task2.addContinuation(nextStep());
task2.start();


return task2;


case 2:
instr5;
state = 0;


task3 = new Task();
task3.setResult(value);
task3.setCompleted();


return task3;
}
}


method:
nextStep();

实际上,一个池可以有它的任务创建策略。

我不打算和Eric Lippert或者Lasse V. Karlsen等人竞争,我只是想让大家注意这个问题的另一个方面,我想这个问题没有明确提到。

单独使用await并不能让你的应用神奇地做出响应。如果你在方法中所做的任何事情都来自UI线程块,它仍然会像不可等待的版本一样阻塞你的UI. 它仍然会像不可等待的版本一样阻塞你的UI. 它仍然会像不可等待的版本一样阻塞你的UI. 它仍然会像不可等待的版本一样阻塞你的UI

你必须编写你的awaitable方法,以便它产生一个新线程或使用一个完成端口之类的东西(它将在当前线程中返回执行,并在完成端口收到信号时调用其他东西来继续)。但这部分在其他答案中有很好的解释。

实际上,async await链是由CLR编译器生成的状态机。

然而,async await确实使用了TPL使用线程池来执行任务的线程。

应用程序没有被阻塞的原因是状态机可以决定执行哪个协同例程、重复、检查并再次决定。

进一步阅读:

async &等待生成?< / >

Async Await and the Generated StateMachine

以下是我对这一切的看法,它在技术上可能不是超级准确,但至少对我有帮助:)。

机器上基本上有两种类型的处理(计算):

  • 发生在CPU上的处理
  • 在其他处理器(GPU,网卡等)上发生的处理,让我们称之为IO。

因此,当我们编写一段源代码时,在编译后,根据我们使用的对象(这是非常重要的),处理将是CPU绑定IO绑定,事实上,它可以绑定到两者的组合。

一些例子:

  • 如果我使用FileStream对象(它是一个流)的Write方法,处理将是1%的CPU限制和99%的IO限制。
  • 如果我使用NetworkStream对象(它是一个流)的Write方法,处理将是1%的CPU限制和99%的IO限制。
  • 如果我使用Memorystream对象(它是一个流)的Write方法,处理将是100% CPU限制。

因此,正如你所看到的,从面向对象程序员的角度来看,虽然我总是访问Stream对象,但下面发生的事情可能在很大程度上取决于对象的最终类型。

现在,为了优化事情,如果可能和/或必要的话,有时能够运行代码并行(注意我不使用异步这个词)是有用的。

一些例子:

  • 在桌面应用程序中,我想打印一个文档,但我不想等待它。
  • 我的web服务器同时为许多客户端提供服务,每个客户端并行获取他的页面(不是序列化的)。

在async / await之前,我们基本上有两个解决方案:

  • 线程。它相对容易使用,有Thread和ThreadPool类。线程只受CPU限制
  • “旧的”开始/结束/ AsyncCallback异步编程模型。这只是一个模型,它并没有告诉你你将会受到CPU或IO的限制。如果你看一下Socket或FileStream类,它是IO绑定的,这很酷,但我们很少使用它。
async / await只是一个基于任务概念的通用编程模型。对于CPU绑定的任务,它比线程或线程池更容易使用,而且比旧的Begin/End模型更容易使用。 然而,它“只是”一个超级复杂的功能齐全的包装

因此,真正的胜利主要是在IO绑定任务上,任务不使用CPU,但async/await仍然只是一个编程模型,它不能帮助你确定处理最终将如何/在哪里发生。

这意味着它不是因为一个类有一个方法“DoSomethingAsync”返回一个任务对象,你可以假设它将是CPU绑定(这意味着它可能是无用的,特别是如果它没有取消令牌参数),或IO绑定(这意味着它可能是必须),或两者的组合(因为模型是相当病毒式的,绑定和潜在的好处可以,最终,超级混合,不那么明显)。

所以,回到我的例子,在MemoryStream上使用async/await进行写操作将保持CPU限制(我可能不会从中受益),尽管我肯定会从文件和网络流中受益。

总结其他答案:

Async/await通常是为IO绑定任务创建的,因为通过使用它们,调用线程不需要被阻塞。这在UI线程的情况下特别有用,因为我们可以确保它们在执行后台操作时保持响应(比如从远程服务器获取数据)。

Async不创建自己的线程。调用方法的线程用于执行异步方法,直到它找到一个可等待对象。然后,同一线程继续执行异步方法调用之外的调用方法的其余部分。注意,在被调用的async方法中,从awaitable返回后,该方法的提醒可以使用线程池中的线程执行——这是唯一出现单独线程的地方。

我试图自下而上地解释它。也许有人会觉得有用。 我在那里,做了那件事,重新发明了它,用Pascal在DOS中制作简单的游戏(好旧时光…)

所以…每个事件驱动的应用程序内部都有一个类似这样的事件循环:

while (getMessage(out message)) // pseudo-code
{
dispatchMessage(message); // pseudo-code
}
框架通常对你隐藏这个细节,但它就在那里。 getMessage函数从事件队列中读取下一个事件或等待事件发生:鼠标移动、按下键、按上键、单击等等。然后dispatchMessage将事件分派给适当的事件处理程序。 然后等待下一个事件,依此类推,直到出现退出事件,退出循环并完成应用程序 事件处理程序应该快速运行,以便事件循环可以轮询更多事件,并且UI保持响应性。 如果单击按钮触发了这样昂贵的操作,会发生什么?< / p >
void expensiveOperation()
{
for (int i = 0; i < 1000; i++)
{
Thread.Sleep(10);
}
}
好吧,UI变得无响应,直到10秒操作结束,因为控件停留在函数中。 要解决这个问题,您需要将任务分解为可以快速执行的小部分。 这意味着您不能在一个事件中处理整个事件。 你必须做一小部分工作,然后发布另一个事件到事件队列请求继续

所以你可以把它改成:

void expensiveOperation()
{
doIteration(0);
}


void doIteration(int i)
{
if (i >= 1000) return;
Thread.Sleep(10); // Do a piece of work.
postFunctionCallMessage(() => {doIteration(i + 1);}); // Pseudo code.
}

在这种情况下,只运行第一次迭代,然后它将消息发送到事件队列以运行下一次迭代并返回。 我们的例子postFunctionCallMessage伪函数放了一个“调用这个函数”;事件发送到队列,以便事件调度程序在到达队列时调用它。 这允许所有其他GUI事件在连续运行长时间运行工作的片段时被处理 只要这个长时间运行的任务在运行,它的延续事件就一直在事件队列中。基本上你发明了自己的任务调度程序。 队列中的延续事件为“进程”;它们在奔跑。 实际上这就是操作系统所做的,除了发送延续事件并返回调度程序循环是通过CPU的计时器中断完成的,操作系统在那里注册了上下文切换代码,所以你不需要关心它。 但在这里,您正在编写自己的调度程序,因此您确实需要关心它-到目前为止 因此,我们可以通过将长时间运行的任务分解成小块并发送延续事件,在与GUI并行的单个线程中运行它们。 这是Task类的一般思想。它代表了一件工作,当你对它调用.ContinueWith时,你定义了当前工作完成时要调用的下一件工作(并且它的返回值被传递给continuation)。 但是手动执行所有这些将工作分割成小块的链接是一项繁琐的工作,并且完全混乱了逻辑的布局,因为整个后台任务代码基本上是.ContinueWith混乱。 这就是编译器帮助你的地方。它在引擎盖下为您完成所有这些链接和延续。当你说await时,你告诉编译器“停止在这里,将函数的其余部分作为延续任务添加”。 其余的由编译器负责,所以你不必这样做

虽然这个任务块链接不涉及创建线程,并且当任务块很小时,它们可以在主线程的事件循环上调度,但实际上有一个工作线程池来运行任务。这允许更好地利用CPU内核,还允许开发人员运行手动编写的长任务(这会阻塞工作线程而不是主线程)。

这并没有直接回答问题,但我认为它提供了一些有趣的额外信息:

Async和await本身不会创建新线程。但是根据你在哪里使用async-await,在await之前的同步部分可以运行在不同的线程上,而在await之后的同步部分则可以运行在不同的线程上(例如ASP。NET和ASP。NET核心表现不同)。

在基于ui线程的应用程序(WinForms, WPF)中,您将在之前和之后处于同一个线程上。但是当你在线程池线程上使用async-await时,await之前和之后的线程可能不相同。

关于这个主题的一个很棒的视频