如果async-await不创建任何其他线程,那么它如何使应用程序响应?


241

一次又一次,我看到它说使用async- await不会创建任何其他线程。这是没有道理的,因为一台计算机似乎一次只能完成一件事情的唯一方法是

  • 实际上一次执行一件以上的事情(并行执行,使用多个处理器)
  • 通过计划任务并在它们之间进行切换来模拟它(执行一点A,一点B,一点A等)

因此,如果async- await这些都不起作用,那么如何使应用程序响应呢?如果只有1个线程,则调用任何方法都意味着先等待该方法完成,然后再执行其他操作,并且该方法内的方法必须等待结果之后再进行操作,依此类推。


17
IO任务不受CPU限制,因此不需要线程。异步的要点是在IO绑定任务期间不阻塞线程。
juharr

24
@jdweng:不,一点也不。即使它创建了新线程,也与创建新进程有很大不同。
乔恩·斯基特

8
如果您了解基于回调的异步编程,那么您将了解await/的async工作原理而无需创建任何线程。
user253751 '16

6
它并不能完全使应用程序具有更高的响应速度,但确实会阻止您阻塞线程,这是导致应用程序无响应的常见原因。
欧文

6
@RubberDuck:是的,它可以使用线程池中的线程进行继续。但是,这并不是按照OP在这里想象的那样启动线程-并不是说“采用这种普通方法,现在在单独的线程中运行-那里是异步的”。比这要好得多。
乔恩·斯基特

Answers:


299

实际上,异步/等待并不是那么神奇。整个主题非常广泛,但是对于您的问题,我想我们可以解决,但需要快速而完整的答案。

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

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秒钟,而我们等待此方法完成。发生的事情是“消息泵”(基本上是一个循环)被阻塞了。

该循环不断询问Windows:“是否有人做过某些事情,例如移动鼠标,单击某些东西?我需要重新粉刷一些东西吗?如果是,请告诉我!” 然后处理那个“东西”。此循环收到一条消息,提示用户单击“ 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 Forms程序中使用的上下文类将使用消息循环正在泵送的队列对其进行排队。

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

    对于用户而言,UI现在可以再次响应,可以处理其他按钮单击,调整大小,最重要的是可以重新绘制,因此它似乎不会冻结。

  5. 2秒后,我们等待的事情完成了,现在发生的事情是,它(同步上下文)将一条消息放入了消息循环正在查看的队列中,并说:“嘿,我有更多的代码可以您执行”,而这段代码就是等待的所有代码。
  6. 当消息循环到达该消息时,它基本上会在中断之后的地方重新“进入”该方法,await然后继续执行该方法的其余部分。请注意,此代码再次从消息循环中调用,因此,如果此代码恰好长时间执行而未async/await正确使用,它将再次阻塞消息循环

这里有很多活动部件,所以这里有一些指向更多信息的链接,我想说的是“您是否需要它”,但是这个主题涉及很广,了解其中一些活动部件非常重要。您将始终了解异步/等待仍然是一个漏水的概念。一些潜在的局限性和问题仍然会泄漏到周围的代码中,如果没有,您通常最终不得不调试似乎无缘无故地随机中断的应用程序。


好的,如果GetSomethingAsync启动一个将在2秒内完成的线程该怎么办?是的,那么显然有一个新的线索在起作用。但是,此线程不是由于此方法的异步性,而是因为此方法的程序员选择了一个线程来实现异步代码。几乎所有异步I / O 都不使用线程,它们使用不同的东西。async/await 本身不会增加新线程,但是显然“等待的事情”可以使用线程来实现。

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

  • Web请求(以及许多其他与网络相关的事情,这需要时间)
  • 异步文件读写
  • 还有一个很好的信号是,所讨论的类/接口是否具有名为SomethingSomethingAsyncor的方法BeginSomethingEndSomething并且IAsyncResult涉及其中。

通常,这些东西不使用引擎盖下的螺纹。


好吧,所以您想要一些“广泛的主题”?

好吧,让我们向Roslyn咨询一下我们的按钮单击:

尝试罗斯林

我不会在这里链接完整的生成类,但这是很糟糕的东西。


10
因此,基本上这就是OP所说的“ 通过调度任务并在它们之间进行切换来模拟并行执行 ”,不是吗?
贝尔吉

4
@Bergi不完全是。执行过程实际上是并行的-异步I / O任务正在进行中,并且不需要线程继续进行(这是Windows出现之前很久就已经使用的东西-MS DOS也使用了异步I / O,即使它没有有多线程!)。当然,await 可以用您描述它的方式来使用,但通常不是。仅调度了回调(在线程池上)-在回调和请求之间,不需要线程。
a安

3
这就是为什么我要明确避免过多谈论该方法的原因,因为问题特别是关于异步/等待,它不会创建自己的线程。显然,他们可以用来等待线程来完成。
Lasse V. Karlsen

5
@ LasseV.Karlsen-我想知道您的好答案,但我仍然想保留一个细节。据我所知,事件处理程序存在,如在步骤4,它允许消息泵继续抽,但何时以及何处不“一事接受两个秒。”如果继续不执行一个单独的线程?如果要在UI线程上执行,那么它将在执行过程中
始终

3
我喜欢您对消息泵的解释。当没有消息泵如控制台应用程序或Web服务器中时,您的解释有何不同?如何重新获得方法?
Puchacz

94

我将在我的博客文章《没有线程》中对此进行完整的解释。

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

一旦执行了该操作,就无需CPU执行任何工作,因此就没有线程。


为了清楚起见,我了解使用async-await时会发生什么情况。关于无线程的创建-仅在对像您说有自己的处理器的设备的I / O请求中没有线程,这些处理器自己处理请求?我们是否可以假定所有I / O请求都在这样的独立处理器上处理,这意味着仅对CPU绑定的操作使用Task.Run?
Yonatan Nir

@YonatanNir:这不只是关于独立的处理器;任何类型的事件驱动响应自然都是异步的。Task.Run最适合于CPU限制的操作,但它还有其他一些用途。
史蒂芬·克雷里

1
我读完您的文章后,由于我对操作系统的底层实现并不十分了解,因此还有一些我不了解的基本知识。我得到了您写的内容:“现在写操作正在“进行中”。正在处理多少线程?没有。。因此,如果没有线程,那么如果不在线程上,该操作本身将如何完成?
Yonatan Nir

5
这是成千上万的解释中缺少的部分!!!实际上,有人在后台执行I / O操作。它不是一个线程,而是另一个专用的硬件组件在起作用!
the_dark_destructor

2
@PrabuWeerasinghe:编译器创建一个保存状态和局部变量的结构。如果一个等待需要屈服(即返回其调用者),则该结构被装箱并保存在堆中。
Stephen Cleary

87

计算机一次似乎可以完成一件事情的唯一方法是(1)实际上一次完成一件事情,(2)通过安排任务并在它们之间切换来模拟它。因此,如果async-await都不做

并不是说等待都不是。请记住,的目的await不是使同步代码神奇地异步化。启用与调用异步代码时用于编写同步代码的技术相同的技术。Await是关于使使用高延迟操作的代码看起来像使用低延迟操作的代码。这些高延迟操作可能在线程上,可能在专用硬件上,他们可能将工作分解成小块,然后将其放入消息队列中,以便稍后由UI线程进行处理。他们正在做一些事情以实现异步,但是他们是这样做的。Await只是让您利用这种异步性。

另外,我认为您缺少第三种选择。我们的老人-今天有说唱音乐的孩子应该离开我的草坪等等-记住1990年代初期Windows的世界。没有多CPU机器,也没有线程调度程序。您想同时运行两个Windows应用程序,但必须屈服。多任务协作。OS告诉进程它将开始运行,如果它的行为异常,则会使所有其他进程都无法运行。它一直运行到产生为止,并且某种程度上它必须知道如何在下次操作系统将控制权交还给它时从中断的地方开始。。单线程异步代码很像这样,用“ await”代替“ yield”。等待的意思是“我要记住我在这里离开的地方,让其他人跑一会儿;等到我等待的任务完成后再给我回电,然后我会从我离开的地方接起。” 我认为您可以看到它如何使应用程序具有更快的响应速度,就像Windows 3天一样。

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

您缺少钥匙。 方法可以在其工作完成之前返回。那就是那里异步的本质。一个方法返回,它返回一个任务,该任务的意思是“这项工作正在进行中;告诉我完成后应该做什么”。该方法的工作即使返回了也不会完成。

在await运算符之前,您必须编写看起来像通心粉穿过瑞士奶酪的代码,以处理以下事实:完成我们有工作要做,但是返回和完成不同步。通过Await,您可以编写看起来像返回和完成已同步的代码,而无需实际对其进行同步。


其他现代高级语言也支持类似的显式协作行为(即,函数会做一些事情,产生[可能会向调用方发送一些值/对象],在交还控制权时会继续停下来[可能会提供额外的输入] )。一方面,生成器在Python中足够强大。
JAB

2
@JAB:绝对。生成器在C#中称为“迭代器块”,并使用yield关键字。双方async在C#中的方法和迭代器的形式协同程序,这是一个知道如何后暂停其当前操作恢复功能的总称。如今,许多语言都具有协程或类似协程的控制流。
埃里克·利珀特

1
与yield的比喻是一个很好的例子-它是一个过程中的协作式多任务处理。(从而避免了系统范围内的协作多任务处理的系统稳定性问题)
user253751,2016年

3
我认为用于CPU的“ cpu中断”的概念并不了解许多调制解调器的“程序员”,因此他们认为线程需要等待IO的每一位。
伊恩·林格罗斯

WebClient的@EricLippert Async方法实际上会创建其他线程,请参见此处stackoverflow.com/questions/48366871/…–
KevinBui

27

我真的很高兴有人问这个问题,因为在最长的时间内,我还认为线程是并发所必需的。当我第一次看到事件循环时,我以为它们是谎言。我对自己说:“如果代码在单个线程中运行,则不可能并发”。请记住,这是我已经努力理解并发和并行性之间的区别之后。

经过我自己的研究,我终于找到了缺失的部分:select()。具体而言,复用IO,通过各种内核以不同的名字来实现:select()poll()epoll()kqueue()。这些是系统调用,尽管实现细节有所不同,但允许您传入一组文件描述符以进行观察。然后,您可以进行另一个阻塞的调用,直到被监视的文件描述符之一更改为止。

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

这是如何运作的?好吧,简短的答案是它是内核和硬件级别的魔术。除了CPU外,计算机中还有许多组件,这些组件可以并行工作。内核可以控制这些设备并直接与它们通信以接收某些信号。

这些IO多路复用系统调用是单线程事件循环(如node.js或Tornado)的基本构建块。当您使用await某个函数时,您正在监视某个事件(该函数的完成),然后将控制权交还给主事件循环。当您正在观看的事件结束时,该功能(最终)将从中断的地方开始。允许您像这样暂停和恢复计算的函数称为协程


25

awaitasync使用“ 任务而不是线程”。

该框架有一个线程池,准备好以Task对象的形式执行某些工作。向池提交Task意味着选择一个空闲的,已经存在的1线程来调用task action方法。
创建任务与创建新对象有关,比创建新线程快得多。

给定一个Task可以附加一个Continuation到它,它是线程结束后要执行的新Task对象。

由于async/await使用Task,因此它们不会创建新线程。


尽管中断编程技术已广泛用于每个现代OS中,但我认为它们在这里并不重要。
您可以使用在一个CPU中并行执行两个CPU绑定任务(实际上是交错执行) aysnc/await
操作系统支持IORP排队这一事实无法简单解释。


上一次我检查将编译器转换asyncDFA的方法时,工作分为多个步骤,每个步骤以一条await指令终止。
await开始它的任务和其附加的延续执行下一步。

作为一个概念示例,这是一个伪代码示例。
为了清楚起见,事情已经简化了,因为我不记得所有的细节。

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();

1实际上,池可以具有其任务创建策略。


16

我不会与埃里克·利珀特(Eric Lippert)或拉瑟·卡尔森(Lasse V. Karlsen)等人竞争,我只是想提请注意这个问题的另一个方面,我认为并未明确提及。

单独使用await不会使您的应用程序产生神奇的响应。如果您在UI线程块中等待的方法中执行了任何操作,它将仍然以与非等待版本相同的方式阻塞UI

您必须专门编写您的等待方法,以便它产生一个新线程或使用诸如完成端口之类的东西(它将在当前线程中返回执行,并在发出完成端口信号时调用其他内容以继续执行)。但是这部分在其他答案中也有很好的解释。


3
首先不是竞争。这是一次合作!
埃里克·利珀特

16

这就是我对所有这些的看法,至少在技术上可能不够准确,但至少对我有帮助。

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

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

因此,当我们编写一段源代码时,在编译之后,根据我们使用的对象(这非常重要),处理将受CPU约束IO约束,实际上,它可以绑定为以下各项的组合都。

一些例子:

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

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

现在,为了进行优化,可能和/或必要的情况下,能够并行运行代码有时会很有用(请注意,我不使用异步一词)。

一些例子:

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

在异步/等待之前,我们基本上有两种解决方案:

  • 线程。使用Thread和ThreadPool类相对容易使用。线程仅受CPU约束
  • “旧的” Begin / End / AsyncCallback异步编程模型。这只是一个模型,它不会告诉您是否要绑定CPU或IO。如果您看一下Socket或FileStream类,它是IO绑定的,这很酷,但是我们很少使用它。

基于Task概念,异步/等待只是一个通用的编程模型。它比用于CPU绑定任务的线程或线程池要容易使用,并且比旧的Begin / End模型要容易得多。但是,卧底在这两者上都是“仅”一个超级复杂的功能齐全的包装器。

因此,真正的胜利主要是在IO绑定任务(不使用CPU的任务)上,但是async / await仍然只是一种编程模型,它无法帮助您确定最终的处理方式/位置。

这意味着不是因为类有一个方法“ DoSomethingAsync”,它返回一个Task对象,您可以假定它受CPU限制(这意味着它可能非常无用,尤其是在没有取消标记参数的情况下)或IO绑定(这意味着这可能是必须的),或者两者的结合(由于该模型非常具有病毒性,因此,结合和潜在收益最终可能是超级混合的,而不是那么明显)。

因此,回到我的示例,尽管我肯定会从文件和网络流中受益,但是在MemoryStream上使用async / await进行写入操作将保持CPU绑定(我可能不会从中受益)。


1
使用theadpool进行cpu绑定工作是一个很好的答案,因为应该使用TP线程来减轻IO操作的负担。当然,受CPU限制的工作imo应该受到警告的限制,没有什么可以阻止使用多个线程。
davidcarr

3

总结其他答案:

异步/等待主要是为IO绑定任务创建的,因为通过使用它们,可以避免阻塞调用线程。它们的主要用途是与UI线程一起使用,在这些情况下,不需要在IO绑定操作上阻塞该线程。

异步不会创建它自己的线程。调用方法的线程用于执行async方法,直到找到可等待的方法为止。然后,同一线程将继续执行除异步方法调用之外的其余调用方法。在调用的异步方法中,从等待状态返回后,可以在线程池中的线程上执行继续操作-唯一的单独线程进入图片的地方。


很好的总结,但我认为它应该回答另外两个问题才能给出完整的图片:1.等待代码在哪个线程上执行?2.谁控制/配置上述线程池-开发人员或运行时环境?
stojke

1.在这种情况下,大多数等待的代码是IO绑定操作,它不使用CPU线程。如果希望使用await进行CPU绑定操作,则可以生成单独的Task。2.线程池中的线程由TPL框架中的Task Scheduler管理。
vaibhav kumar

2

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

所以...在每个事件驱动的应用程序中,都有一个如下所示的事件循环:

while (getMessage(out message)) // pseudo-code
{
   dispatchMessage(message); // pseudo-code
}

框架通常会向您隐藏此细节,但是它就在那里。getMessage函数从事件队列中读取下一个事件,或者等待事件发生:鼠标移动,按下键,按下键,单击等。然后dispatchMessage将事件分派到适当的事件处理程序。然后等待下一个事件,依此类推,直到退出事件退出,退出循环并完成应用程序。

事件处理程序应快速运行,以便事件循环可以轮询更多事件,并且UI保持响应。如果单击按钮触发这样的昂贵操作会怎样?

void expensiveOperation()
{
    for (int i = 0; i < 1000; i++)
    {
        Thread.Sleep(10);
    }
}

那么,由于控件停留在函数中,因此在10秒操作完成之前,UI一直没有响应。要解决此问题,您需要将任务分解为可以快速执行的小部分。这意味着您无法在单个事件中处理全部事情。您必须做一小部分工作,然后将另一个事件发布到事件队列中以要求继续。

因此,您可以将其更改为:

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它时,您可以定义当前作品完成时(下一个作品的返回值传递给下一个延续作品)下一个要调用的函数。本Task类使用一个线程池,那里是每个线程等待做的工作件类似想我发现在开始一个事件循环。这样,您可以并行运行数百万个任务,但是只有几个线程可以运行它们。但是,只要使用一个线程,它就可以正常工作-只要将您的任务正确地分成小块,每个任务看起来都是并行运行的。

但是,完成所有这些链接后,手动将工作分解成小块是一件繁琐的工作,并且完全弄乱了逻辑布局,因为整个后台任务代码基本上都是.ContinueWith一团糟。这就是编译器为您提供帮助的地方。它会在后台为您完成所有这些链接和延续。当您说出await要告诉编译器“在此处停止时,将函数的其余部分添加为继续任务”。编译器会处理其余的工作,因此您不必这样做。


0

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

async await 但是确实使用TPL使用线程池执行任务的线程。

应用程序未被阻止的原因是状态机可以决定执行,重复,检查并再次决定哪个协同程序。

进一步阅读:

异步等待产生什么?

异步等待和生成的状态机

异步C#和F#(III。):它是如何工作的?-托马斯·佩特里切克(Tomas Petricek)

编辑

好的。看来我的阐述不正确。但是,我确实必须指出,状态机对于async awaits 是重要资产。即使您采用异步I / O,您仍然需要一个助手来检查操作是否完成,因此,我们仍然需要一个状态机,并确定可以一起异步执行的例程。


0

这不是直接回答问题,但我认为这是一个有趣的附加信息:

异步并等待不会自行创建新线程。但是取决于使用异步等待的位置,等待之前的同步部分可能与等待之后的同步部分在不同的线程上运行(例如,ASP.NET和ASP.NET核心的行为不同)。

在基于UI线程的应用程序(WinForms,WPF)中,您将在同一线程之前和之后。但是,当您在线程池线程上使用async away时,等待之前和之后的线程可能会不同。

关于这个主题的精彩视频

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.