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

Ms.*_*lib 220 .net c# multithreading asynchronous async-await

一次又一次,我看到它说使用async- await不会创建任何额外的线程.这没有任何意义,因为计算机一次看起来只做一件事以上的唯一方法就是

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

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

ang*_*son 275

实际上,async/await不是那么神奇.完整的主题非常广泛,但我认为我们可以管理您的问题的快速而完整的答案.

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

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

我明确地 不会谈论GetSomethingAsync现在回来的事情.我们只是说这是在2秒之后完成的事情.

在传统的非异步世界中,您的按钮单击事件处理程序将如下所示:

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

当您单击表单中的按钮时,应用程序将显示冻结约2秒,同时我们等待此方法完成.会发生什么是"消息泵",基本上是一个循环,被阻止.

这个循环不断询问窗口"有没有人做过什么,比如移动鼠标,点击某些东西?我需要重新绘制一些东西吗?如果有,请告诉我!" 然后处理"某事".这个循环得到一条消息,用户点击了"button1"(或来自Windows的同等类型的消息),最后调用了button1_Click上面的方法.在此方法返回之前,此循环现在处于等待状态.这需要2秒,在此期间,不会处理任何消息.

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

因此,如果在第一个示例中,async/await不创建新线程,它是如何做到的?

那么,会发生什么,你的方法被分成两部分.这是一个广泛的主题类型的东西,所以我不会详细说明,但足以说这个方法分为这两件事:

  1. 所有代码导致await,包括调用GetSomethingAsync
  2. 以下所有代码 await

插图:

code... code... code... await X(); ... code... code... code...
Run Code Online (Sandbox Code Playgroud)

重新排列:

code... code... code... var x = X(); await X; code... code... code...
^                                  ^          ^                     ^
+---- portion 1 -------------------+          +---- portion 2 ------+
Run Code Online (Sandbox Code Playgroud)

基本上该方法执行如下:

  1. 它执行一切 await
  2. 它调用GetSomethingAsync方法,它完成它的事情,并返回将来完成2秒的东西

    到目前为止,我们仍然在对button1_Click的原始调用内部,发生在主线程上,从消息循环调用.如果代码导致await花费大量时间,UI仍将冻结.在我们的例子中,没有那么多

  3. 什么await关键字,一些聪明的编译器魔术一起,确实是它基本上像"好吧,你知道吗,我会简单地从按钮单击事件处理程序返回这里.当你(在,事情我们"等待完成,让我知道因为我还有一些代码要执行".

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

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

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

  5. 2秒后,我们正在等待的事情完成,现在发生的事情是它(好吧,同步上下文)将消息放入消息循环正在查看的队列中,说"嘿,我有更多的代码你执行",这个代码是等待之后的所有代码.
  6. 当消息循环到达该消息时,它将基本上"重新进入"它停止的方法,紧接着await并继续执行该方法的其余部分.请注意,此代码再次从消息循环中调用,因此如果此代码在没有async/await正确使用的情况下执行某些操作,则会再次阻止消息循环

有许多移动部件在引擎盖下这里,所以这里有一些详细信息的链接,我会说:"如果你需要它",但这个话题是非常广泛的,要知道这是相当重要的其中一些活动部件.你总是会明白async/await仍然是一个漏洞的概念.一些潜在的限制和问题仍然泄露到周围的代码中,如果他们不这样做,你通常最终必须调试一个随机破坏的应用程序,看似没有充分的理由.


好的,那么如果GetSomethingAsync旋转一个将在2秒内完成的线程呢?是的,那么显然有一个新线程在起作用.但是,这个线程不是因为这个方法的异步,而是因为这个方法的程序员选择了一个线程来实现异步代码.几乎所有异步I/O 都不使用线程,它们使用不同的东西.async/await 他们自己不会启动新线程,但显然"我们等待的东西"可以使用线程来实现.

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

  • Web请求(以及需要时间的许多其他网络相关事物)
  • 异步文件读写
  • 更多的人,一个好兆头是,如果有问题的类/接口有一个名为方法SomethingSomethingAsync或BeginSomething与EndSomething和有一个IAsyncResult参与其中.

通常这些东西不使用引擎盖下的线程.


好的,所以你想要一些"广泛的主题"?

那么,让我们问一下罗斯林关于我们的按钮点击:

试试Roslyn

我不会在这里完整生成的类中链接,但它是非常棒的东西.

  • 所以它基本上是OP所描述的"*通过调度任务并在它们之间切换来模拟并行执行*",不是吗? (9认同)
  • @Bergi不太好.执行是真正的并行 - 异步I/O任务正在进行,并且不需要线程继续(这是在Windows出现之前很久就已经使用过的东西 - MS DOS也使用异步I/O,即使它没有有多线程!).当然,`await`*也可以按你描述的方式使用,但通常不是.只调度回调(在线程池上) - 在回调和请求之间,不需要线程. (4认同)
  • @ LasseV.Karlsen - 我正在提供你的答案,但我还是挂了一个细节.据我所知,事件处理程序存在,如在步骤4,它允许消息泵继续抽,但*时*和*,其中*请问"东西需要两秒."如果继续不执行一个单独的线程?如果要在UI线程上执行,那么它会在执行时阻止消息泵,因为它必须在同一个线程上执行*某个时间*.. [续] ... (4认同)
  • 这就是为什么我想明确避免过多谈论该方法的作用,因为问题是关于async/await具体而言,它不会创建自己的线程.显然,它们可以用于等待*for*线程完成. (3认同)
  • 我喜欢你对消息泵的解释.当控制台应用程序或Web服务器中没有消息泵时,您的解释有何不同?如何实现方法的重新获得? (3认同)
  • 如果“需要 2 秒的事情”是 Stephen Cleary 在[他的回答](http://stackoverflow.com/a/37419836/2704659)(和[参考博客文章](http:// blog.stephencleary.com/2013/11/there-is-no-thread.html),即 IO 操作,那么我明白了。但这是您的答案所假设的吗? (2认同)
  • 恕我直言,您的答案中缺少的内容(除了它确实对我有帮助之外)是要清楚地说明,如果 GetSomethingAsync() 不执行真正异步的操作(例如启动线程、执行异步 I/O 或喜欢。一切都会按顺序执行,如果不按顺序执行,就不会获得响应。(如果我理解正确的话,就是这样。) (2认同)

Ste*_*ary 86

我在博客文章"没有线程"中完整地解释了它.

总之,现代I/O系统大量使用DMA(直接内存访问).在网卡,视频卡,HDD控制器,串行/并行端口等上有专用的专用处理器.这些处理器可以直接访问存储器总线,并完全独立于CPU进行读/写操作.CPU只需要通知设备包含数据的存储器中的位置,然后可以自己动手,直到设备发出中断通知CPU读/写完成为止.

一旦操作在飞行中,CPU就没有工作,因此没有线程.

  • 我读完你​​的文章,但仍然有一些基本的东西我不明白,因为我不太熟悉操作系统的底层实现。我得到了您所写的内容:“写操作现在正在“进行中”。有多少线程正在处理它?没有。” 。因此,如果没有线程,那么如果不在线程上,操作本身如何完成? (3认同)
  • 这是成千上万的解释中缺失的一块!!! 实际上有人在后台进行I/O操作.它不是一个线程,而是另一个专用硬件组件来完成它的工作! (3认同)
  • @PrabuWeerasinghe:编译器创建了一个保存状态和局部变量的结构。如果 await 需要让出(即返回到其调用者),则该结构被装箱并存在于堆中。 (2认同)
  • @KevinBui:异步工作取决于线程池线程(工作线程和 I/O 线程)的存在。特别是,I/O 完成端口需要专用的 I/O 线程来处理来自操作系统的完成请求。所有异步 I/O 都需要这样做,但异步的好处是您不需要*每个请求* 一个线程。 (2认同)
  • @noelicus:最初的问题是“async”/“await”是否启动新线程,但它们没有。如果您在同步方法上有“async”修饰符(没有“await”),那么编译器将警告您它将同步运行(直接在调用线程上)。对于 CPU 密集型工作,通常使用“await Task.Run”,在这种情况下,“Task.Run”使其在线程池线程上运行。 (2认同)

Eri*_*ert 83

计算机一次出现多于一件事的唯一方法是(1)实际上一次做多于一件事,(2)通过调度任务并在它们之间切换来模拟它.因此,如果async-await没有这些

这并不是说等待确实没有这些的.请记住,目的await不是使同步代码神奇地异步.这是为了在调用异步代码时使用我们用于编写同步代码的相同技术.等待是关于使用高延迟操作的代码看起来像使用低延迟操作的代码.这些高延迟操作可能在线程上,它们可能在特殊用途的硬件上,它们可能会将它们的工作分解成小块并将其放入消息队列中以供稍后由UI线程处理.他们正在做一些事情以实现异步,但他们正在做这件事.等待只是让你利用这种异步.

另外,我认为你缺少第三种选择.我们老人们 - 今天有说唱音乐的孩子应该从我的草坪上下来等 - 记住20世纪90年代早期的Windows世界.没有多CPU机器也没有线程调度程序.你想同时运行两个Windows应用程序,你必须屈服.多任务是合作的.操作系统告诉进程它会运行,如果它不正常,它会使所有其他进程无法服务.它一直运行直到它产生,并且不知何故它必须知道如何在下次操作系统控制回它时从它停止的地方开始.单线程异步代码非常类似,使用"await"而不是"yield".等待的意思是"我要记住我在这里离开的地方,让其他人跑了一会儿;当我等待的任务完成时,给我回电话,我会在我离开的地方接我." 我认为你可以看到它如何使应用程序更具响应性,就像在Windows 3天中一样.

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

有你失踪的钥匙. 方法可以在其工作完成之前返回.这就是异步的本质.一个方法返回,它返回一个任务,意味着"这项工作正在进行中;告诉我完成后该做什么".该方法的工作尚未完成,即使它已经返回.

在await运算符之前,你必须编写看起来像spaghetti穿过瑞士奶酪的代码,以处理我们在完成后有工作要做的事实,但返回和完成失去同步.Await允许您编写看起来像返回和完成同步的代码,而不会实际同步它们.

  • 我认为用于IO的"cpu中断"概念并不了解很多调制解调器"程序员",因此他们认为线程需要等待每一位IO. (3认同)
  • @JAB:绝对.生成器在C#中称为"迭代器块",并使用`yield`关键字.C#中的`async`方法和迭代器都是*coroutine*的一种形式,它是一个知道如何暂停当前操作以便稍后恢复的函数的通用术语.如今,许多语言都具有协同程序或协程式控制流程. (2认同)
  • 与产量的类比是一个很好的类比——它是*在一个进程内*的协作多任务处理。*(从而避免了系统范围内的协作多任务处理的系统稳定性问题) (2认同)

gar*_*ead 27

我很高兴有人问这个问题,因为在最长的时间里我也认为线程对于并发是必要的.当我第一次看到事件循环时,我以为它们是谎言.我心里想:"如果它在单个线程中运行,那么这段代码就无法并发".请记住,这是在我已经经历了理解并发性和并行性之间差异的斗争之后.

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

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

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

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


Mar*_*oom 23

await并async使用任务而不是线程.

该框架有一个线程池,可以以Task对象的形式执行一些工作; 向池中提交任务意味着选择一个空闲的,已存在的1,线程来调用任务操作方法.
创建任务是创建新对象的问题,远比创建新线程快得多.

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

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


虽然中断编程技术在每个现代操作系统中都得到了广泛应用,但我认为它们并不重要.
您可以在一个CPU中并行执行两个CPU绑定任务(实际交错) aysnc/await.
操作系统支持排队IORP,这无法简单解释.


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

作为一个概念示例,这里是一个伪代码示例.
为了清晰起见,事情正在简化,因为我不记得所有细节.

method:
   instr1                  
   instr2
   await task1
   instr3
   instr4
   await task2
   instr5
   return value
Run Code Online (Sandbox Code Playgroud)

它变成了这样的东西

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();
Run Code Online (Sandbox Code Playgroud)

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


And*_*ykh 16

我不打算与Eric Lippert或Lasse V. Karlsen以及其他人竞争,我只是想提请注意这个问题的另一个方面,我认为没有明确提及.

单独使用await它不会让你的应用程序神奇地响应.如果您正在等待来自UI线程块的方法中的任何操作,它仍将以与非等待版本相同的方式阻止您的UI.

你必须专门编写你的等待方法,因此它要么产生一个新线程,要么使用类似完成端口的东西(它将在当前线程中返回执行,并在完成端口发出信号时调用其他东西继续).但这部分在其他答案中得到了很好的解释.

  • 它首先不是竞争对手; 这是一次合作! (2认同)

Sim*_*ier 15

这是我如何看待这一切,它可能不是超级技术准确但它帮助我,至少:).

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

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

所以,当我们编写一段源代码时,在编译之后,取决于我们使用的对象(这是非常重要的),处理将是CPU绑定的,或IO绑定的,事实上,它可以绑定到一个组合都.

一些例子:

  • 如果我使用FileStream对象的Write方法(这是一个Stream),处理将是1%CPU绑定,99%IO绑定.
  • 如果我使用NetworkStream对象的Write方法(这是一个Stream),处理将是1%CPU绑定,99%IO绑定.
  • 如果我使用Memorystream对象的Write方法(这是一个Stream),处理将是100%CPU绑定.

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

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

一些例子:

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

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

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

async/await只是一个基于Task概念的通用编程模型.它比线程或线程池更容易用于CPU绑定任务,并且比旧的Begin/End模型更容易使用.然而,它只是一个超级复杂的功能 - 完全包装器.

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

这意味着它不是因为类有一个方法"DoSomethingAsync"返回一个Task对象,你可以假定它将受CPU限制(这意味着它可能没用,特别是如果它没有取消令牌参数),或IO Bound (这意味着它可能是必须的),或两者的结合(因为模型非常具有病毒性,结合和潜在的好处,最终可能是超级混合而不是那么明显).

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


Cal*_*ius 6

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

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

while (getMessage(out message)) // pseudo-code
{
   dispatchMessage(message); // pseudo-code
}
Run Code Online (Sandbox Code Playgroud)

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

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

void expensiveOperation()
{
    for (int i = 0; i < 1000; i++)
    {
        Thread.Sleep(10);
    }
}
Run Code Online (Sandbox Code Playgroud)

好吧,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. 
}
Run Code Online (Sandbox Code Playgroud)

在这种情况下,只有第一次迭代运行,然后它会向事件队列发布一条消息以运行下一次迭代并返回。我们的示例postFunctionCallMessage伪函数将“调用此函数”事件放入队列,因此事件调度程序将在到达它时调用它。这允许在连续运行长时间运行的工作的同时处理所有其他 GUI 事件。

只要这个长时间运行的任务在运行,它的延续事件就一直在事件队列中。所以你基本上发明了自己的任务调度程序。队列中的继续事件是正在运行的“进程”。实际上这就是操作系统所做的,除了继续事件的发送和返回调度程序循环是通过操作系统注册上下文切换代码的​​ CPU 的定时器中断完成的,所以你不需要关心它。但是在这里您正在编写自己的调度程序,因此您确实需要关心它 - 到目前为止。

因此,我们可以通过将它们分解成小块并发送延续事件,在与 GUI 并行的单个线程中运行长时间运行的任务。这是Task班级的总体思路。它代表一个工作,当你调用.ContinueWith它时,你定义在当前工作完成时调用哪个函数作为下一个工作(并且它的返回值被传递给延续)。但是手动将工作拆分成小块进行所有这些链接是一项繁琐的工作,并且完全弄乱了逻辑布局,因为整个后台任务代码基本上是.ContinueWith一团糟。所以这是编译器可以帮助您的地方。它在幕后为您完成所有这些链接和延续。当你说await您告诉编译器“停在这里,将函数的其余部分添加为延续任务”。编译器负责其余的工作,因此您不必这样做。

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