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

367

一遍又一遍地听到说使用async-await不会创建任何额外的线程。这不合理,因为计算机看起来在同时处理多个任务的唯一方式是:

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

因此,如果async-await既没有做到这两点,那么它如何使应用程序响应呢?如果只有一个线程,那么调用任何方法都意味着在执行任何其他操作之前必须等待该方法完成,而该方法内部的方法也必须等待结果才能继续进行,等等。


27
IO任务并不会占用CPU资源,因此不需要使用线程。异步的主要目的是在IO密集型任务期间不阻塞线程。 - juharr
28
不,完全不是。即使它创建了新的“线程”,那也与创建新进程非常不同。 - Jon Skeet
10
如果您理解基于回调的异步编程,那么您就可以理解如何使用 await/async 在不创建任何线程的情况下工作。 - user253751
7
这并不会直接让应用程序变得更加响应,但它可以防止您阻塞线程,而线程阻塞是导致应用程序不响应的常见原因。 - Owen
12
@RubberDuck:是的,它可能会从线程池中获取一个线程来继续执行。但是它并不像楼主在这里想象的那样启动一个线程 - 它并不像说“使用一个独立的线程来运行这个普通的方法 - 这就是异步”的方式。它比那更微妙。 - Jon Skeet
显示剩余5条评论
11个回答

413

实际上,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秒钟的冻结,因为我们要等待该方法完成。发生的情况是,“消息泵”,基本上是一个循环,被阻塞了。
这个循环不断地询问Windows:“有人做了什么事情,比如移动鼠标,点击什么?我需要重绘什么吗?如果需要,请告诉我!”然后处理那个“something”。这个循环收到了用户点击“button1”的消息(或来自Windows的等效类型的消息),最终调用了我们上面的button1_Click方法。在此方法返回之前,此循环现在被卡住了等待。这需要2秒钟,在此期间,不会处理任何消息。
与Windows相关的大多数事情都使用消息来处理,这意味着如果消息循环停止传递消息,即使只有一秒钟,用户也会立即注意到。例如,如果您将记事本或任何其他程序移到自己的程序的顶部,然后再次移开,就会向您的程序发送一连串的绘图消息,指示现在突然再次可见的窗口的哪个区域。如果处理这些消息的消息循环正在等待某些内容,则不进行任何绘图。
因此,在第一个示例中,如果async/await没有创建新线程,则它是如何工作的呢?
嗯,发生的情况是,您的方法被分成两个部分。这是一种广泛的主题类型,所以我不会详细介绍,但可以说该方法被分成以下两个部分:
1. 包括调用GetSomethingAsync的所有代码在内的所有代码,直到await。 2. 所有以下代码。
说明:
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之前的代码需要很长时间,用户界面将仍然会冻结。在我们的示例中,不是很多。

  3. await关键字与一些聪明的编译器魔法一起使用时,实际上是像这样的:“好了,你知道吗,我要在这里从按钮单击事件处理程序中简单地返回。当你(也就是我们正在等待的东西)完成时,请告诉我,因为我仍然有一些代码要执行”。

    实际上,它会通知SynchronizationContext类 完成了操作,具体取决于当前正在使用的同步上下文将如何排队执行。Windows窗体程序中使用的上下文类将使用消息循环正在处理的队列进行排队。

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

    对于用户来说,用户界面现在是响应的,可以处理其他按钮点击、调整大小和最重要的重新绘制,因此不会出现卡顿现象。

  5. 2秒钟后,我们等待的东西完成,现在它(也就是同步上下文)将一条消息放入消息循环正在查看的队列中,表示“嘿,我有更多代码需要执行”,而这些代码是await之后的所有代码

  6. 当消息循环到达该消息时,它将基本上“重新进入”离开await后的方法,并继续执行该方法的其余部分。请注意,该代码再次从消息循环调用,因此如果该代码使用async/await不正确并且做某些冗长的操作,它将再次阻止消息循环。

这里有许多复杂的部分,因此以下是一些更多信息的链接。我原本想说“如果你需要的话”,但这个主题相当广泛,知道一些这些“移动部件”也相当重要。不可避免地,您将了解到async/await仍然是一个泄漏的概念。一些潜在的限制和问题仍然会泄漏到周围的代码中,如果它们没有,通常你最终会不得不调试一个看似毫无道理地断开的应用程序。


好的,那么如果 GetSomethingAsync 启动一个在 2 秒内完成的线程呢?是的,那么显然有一个新的线程在运行。但是,这个线程并不是由于这个方法是异步的而产生的,而是因为该方法的程序员选择了一个线程来实现异步代码。几乎所有异步 I/O 都不使用线程,它们使用不同的东西。async/await本身不会启动新线程,但是我们等待的“东西”可能是使用线程实现的。

在 .NET 中有很多东西本质上没有自己的线程,但仍然是异步的:

  • Web 请求(以及许多其他需要时间的网络相关内容)
  • 异步文件读取和写入
  • 还有许多其他情况,一个好的标志是所涉及的类/接口具有名为 SomethingSomethingAsyncBeginSomethingEndSomething 的方法,并且涉及到一个 IAsyncResult

通常这些东西在幕后不会使用线程。


好的,你想要一些关于“广泛主题”的内容吗?

那么,让我们询问Try Roslyn关于我们的按钮点击事件:

尝试 Roslyn

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


17
那么,它基本上就是OP所描述的“通过安排任务并在它们之间切换来模拟并行执行”,对吗? - Bergi
8
@Bergi 不完全正确。执行是真正的并行 - 异步I/O任务正在进行,不需要线程来进行(这是在Windows出现之前就被使用了 - 即使没有多线程,MS DOS也使用了异步I/O!)。当然,await也可以像您描述的那样使用,但通常不会。只有回调被调度到线程池中 - 在回调和请求之间,不需要线程。 - Luaan
4
因此,我希望明确避免过多地谈论该方法的作用,因为问题特别涉及async/await,它不会创建自己的线程。显然,它们可以用于等待线程完成。 - Lasse V. Karlsen
27
@LasseV.Karlsen -- 我正在消化你出色的答案,但我还卡在一个细节上。我理解事件处理程序存在于步骤4中,允许消息泵继续抽送,但是如果没有在单独的线程上执行,“需要两秒钟的事情”何时和在哪里继续执行呢?如果它在UI线程上执行,那么它在某个时间点上必须在同一线程上执行,因此会阻塞消息泵...[继续]... - rory.ap
8
我很喜欢你对消息泵的解释。当没有消息泵时,比如在控制台程序或Web服务器中,你的解释有什么不同?方法的重新进入是如何实现的? - Puchacz
显示剩余22条评论

160
我在我的博客文章中详细解释了这个问题,链接为There Is No Thread
简而言之,现代I/O系统大量使用DMA(直接内存访问)。网络卡、显卡、硬盘控制器、串行/并行端口等设备上有特殊的专用处理器。这些处理器可以直接访问内存总线,并独立地处理读写操作,与CPU无关。CPU只需通知设备数据存储在内存的位置,然后就可以进行自己的工作,直到设备发出中断信号通知CPU读写完成为止。
一旦操作开始,CPU就没有什么工作要做了,因此也就没有线程了。

3
我已经阅读了你的文章,但由于我不太熟悉操作系统的底层实现,仍有一些基础知识我不明白。 我理解了你的文章直到你写到:“写操作现在已经“在飞行”中。 有多少线程正在处理它?没有。”。那么,如果没有线程,操作本身是如何完成的?它不是在一个线程上执行的吗? - CodeMonkey
45
这是成千上万个解释中缺失的一环!!! 实际上有人在后台处理I/O操作。 它不是线程,而是另一个专用的硬件组件在执行它的工作! - the_dark_destructor
2
编译器创建一个结构体来保存状态和局部变量。如果await需要yield(即返回给调用者),那么该结构体将被装箱并存储在堆上。 - Stephen Cleary
3
@KevinBui:异步工作取决于线程池线程(包括工作者线程和 I/O 线程)的存在。特别地,I/O 完成端口需要专用的 I/O 线程来处理来自操作系统的完成请求。所有异步 I/O 都需要这个,但异步的好处在于你不需要为每个请求创建一个线程。 - Stephen Cleary
5
原问题是关于 async/await 是否启动新线程,它们不会。如果在同步方法上使用 async 修饰符(没有 await),那么编译器会警告您它将在调用线程上同步运行。对于 CPU 绑定的工作,通常使用 await Task.Run,这种情况下,Task.Run 是使其在线程池线程上运行的原因。 - Stephen Cleary
显示剩余14条评论

100
计算机同时完成多项任务的唯一方法是:(1)实际同时完成多项任务,(2)通过任务调度和切换来模拟并行。因此,如果异步等待不做这两件事情,它就不能让计算机看起来像是在同时执行多项任务。
并不是说异步等待都没有做到这两点。请记住,await的目的不是通过魔法将同步代码变成异步代码。它的作用是在调用异步代码时使用与编写同步代码相同的技术。 Await是关于使使用高延迟操作的代码看起来像使用低延迟操作的代码。这些高延迟操作可能位于线程上,也可能位于专用硬件上,或者它们会将工作分成小块,并将其放入消息队列中,以便稍后由UI线程处理。 它们正在做某些事情来实现异步性,但它们才是实现异步性的人。等待只是让您利用该异步特性的一种方式。此外,我认为你忽略了第三个选项。 我们老年人——今天的孩子听着那些饶舌音乐,他们应该离开我的草坪等等——还记得20世纪90年代初期的Windows世界。 那时没有多CPU机器和线程调度程序。 如果您想同时运行两个Windows应用程序,您必须主动放弃。 多任务处理是协作式的。 操作系统告诉一个进程它可以运行,如果它表现不好,它就会阻止其他所有进程得到服务。 它一直运行直到被放弃,而且不知何故,下一次操作系统控制权交回给它时,它必须知道从哪里开始继续执行。 单线程异步代码非常类似,只不过使用了“await”代替了“yield”。 等待意味着“我将在这里记住我停止的地方,让别人运行一段时间;当我正在等待的任务完成时,叫回我,然后我将从上次停下的地方继续。” 我认为你可以看出这样做如何使应用程序更加响应迅速,就像在Windows 3时期一样。

  

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

这就是你忽略的关键。 一个方法可以在其工作完成之前返回。 这就是异步操作的本质。 一个方法返回,返回一个任务,表示“此工作正在进行中;当它完成时告诉我该做什么”。 方法的工作尚未完成,尽管它已经返回

在await运算符之前,您必须编写看起来像穿过瑞士奶酪的意大利面的代码,以处理我们需要在完成后执行的工作,但是返回和完成不同步的事实。Await允许您编写看起来同步了返回和完成的代码,而它们实际上并没有同步


其他现代高级语言也支持类似的显式协作行为(即函数执行某些操作,产生一些值/对象返回给调用者,然后在控制权交回时从离开的地方继续执行,可能会提供额外的输入)。例如,在Python中生成器非常常见。 - JAB
2
@JAB: 当然。在C#中,生成器被称为“迭代块”,并使用yield关键字。在C#中,异步方法和迭代器都是协程的一种形式,它是一个通用术语,用于表示可以暂停当前操作以便以后恢复的函数。现今许多语言都具有协程或类似协程的控制流。 - Eric Lippert
2
“yield”的类比是一个很好的例子 - 它是在一个进程内进行协作式多任务处理的方式。(从而避免了系统范围内协作式多任务处理所带来的系统稳定性问题) - user253751
3
我认为“CPU中断”这一概念被用于IO的方式,很多现代的“程序员”并不了解,因此他们认为一个线程需要等待每个IO位。 - Ian Ringrose
1
@user469104: 我答案最后一段的主要目的是对“工作流程”的“完成”进行对比,这是工作流程状态的事实,而“返回”则是关于控制流的一个事实。正如您所指出的,在一般情况下,并没有要求工作流在返回之前必须完成; 在C# 2中,“yield return”为我们提供了在工作流程完成之前返回的工作流程。异步工作流程也是一样;它们会在完成之前返回。 - Eric Lippert
显示剩余5条评论

34

我很高兴有人问这个问题,因为很长一段时间以来,我也认为线程是并发所必需的。当我第一次看到事件循环时,我觉得这是一个谎言。我心想,“如果代码在单个线程中运行,那么它怎么可能是并发的呢?”请记住,这是在我已经努力理解并发和并行之间区别之后。

通过自己的研究,我终于找到了缺失的部分:select()。具体而言,是由不同内核实现的IO多路复用,具有不同的名称:select()poll()epoll()kqueue()。这些是系统调用,虽然实现细节不同,但可以将一组文件描述符传递给它们进行监视。然后,您可以进行另一个调用,该调用会阻塞,直到其中一个被监视的文件描述符发生变化。

因此,可以等待一组IO事件(主事件循环),处理完成的第一个事件,然后将控制权返回给事件循环。反复进行此操作。
这是如何工作的呢?简短的答案是,这是内核和硬件级别的魔术。除了CPU之外,计算机中有许多组件,这些组件可以并行工作。内核可以控制这些设备并直接与它们通信以接收某些信号。
这些IO多路复用系统调用是单线程事件循环(例如node.js或Tornado)的基本构建块。当您使用await函数时,您正在观察某个事件(该函数的完成),然后将控制权返回到主事件循环。当您正在观察的事件完成时,该函数(最终)从上次离开的地方继续执行。允许您挂起和恢复此类计算的函数称为协程

33

awaitasync使用任务(Tasks)而不是线程(Threads)。

框架有一个线程池,准备执行一些工作,这些工作以任务(Task)对象的形式存在;将一个任务(Task)提交到池中意味着选择一个空闲的、已经存在的1线程来调用任务操作方法。
创建一个任务(Task)只是创建一个新对象,比创建一个新线程快得多。

给定一个任务(Task),可以附加一个续集(Continuation),它是一个新的任务(Task)对象,将在线程结束时执行。

由于async/await使用任务(Task),它们不会创建新的线程。


虽然中断编程技术在每个现代操作系统中被广泛使用,但我认为它们在这里并不相关。
你可以有两个CPU绑定的任务在单个CPU上并行执行(实际上是交替执行),使用aysnc/await
这不能仅仅用操作系统支持排队IORP来简单解释。


最后一次我检查时,编译器将async方法转换为DFA,工作被分成步骤,每个步骤都以await指令结尾。
await启动它的任务(Task)并附加一个续集以执行下一个步骤。

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

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 实际上,一个线程池可以有它自己的任务创建策略。



遇到await时,控制权会返回给调用者。我明白这一点。但是调用异步函数的线程是否会释放到线程池中?例如在Windows应用程序中。 - variable
@variable 我需要重新熟悉一下在.NET上它是如何工作的,但是没错。刚才调用的异步函数已经返回,这意味着编译器创建了一个awaiter并将其附加到一个继续项上(当等待的事件完成时,该继续项将由任务的awaiter调用,该事件是真正的异步)。因此,线程没有其他事情要做,可以返回到池中,这意味着它可以处理其他工作。 - Margaret Bloom
我在想UI是否由于同步上下文而始终被分配相同的线程,你知道吗?如果是这样,线程将不会返回到池中,并且将被UI线程用于运行异步方法调用后的代码。我在这个领域是个新手。 - variable
@variable 看起来你必须手动调用应用程序调度程序以确保代码在UI线程中运行。虽然这段代码对我来说有点不好,但这个是一个更好的例子。显然,这个问题还涉及到GUI线程的同步上下文。... - Margaret Bloom
所以线程没有更多的任务可以执行,可以返回到线程池中。你能帮我理解一下吗?你说线程没有更多的任务要做,我知道你是对的。但是你能帮我理解一下吗?因为我认为线程确实还有更多的工作要做 - 例如,一旦控制权返回给调用者,它必须继续运行调用者的代码。我的想法错了吗? - variable
显示剩余2条评论

22
这是我对这个问题的看法,可能不是非常准确,但至少对我有帮助:)
机器上基本上有两种处理(计算)方式:
1.在CPU上进行的处理;
2.在其他处理器上进行的处理(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服务器同时为许多客户端提供服务,每个客户端并行获取自己的页面(不是串行)。
在async / await之前,我们基本上有两种解决方案:
1.线程。使用Thread和ThreadPool类相对容易。线程仅绑定于CPU。
2.旧的Begin / End / AsyncCallback异步编程模型。它只是一个模型,它不告诉您是否会绑定于CPU或IO。如果您查看Socket或FileStream类,则为IO绑定,这很酷,但我们很少使用它。
async / await只是基于Task概念的通用编程模型。对于CPU绑定任务,它比线程或线程池稍微容易使用一些,并且比旧的Begin / End模型要容易得多。但实际上,它只是两者的超级复杂的功能丰富包装器。
因此,真正的胜利大多在于IO受限任务上,这些任务不使用CPU,但是async/await仍然只是一种编程模型,它不能帮助您确定处理将在何处发生以及如何发生。
这意味着,并不是因为一个类有一个返回Task对象的"DoSomethingAsync"方法,您就可以假定它将是CPU受限的(这意味着它可能相当无用,特别是如果它没有取消标记参数),或者是IO受限的(这意味着它可能是必须的),或两者的组合(由于该模型具有相当强的传染性,因此绑定和潜在的好处最终可能会非常混合且不那么明显)。
因此,回到我的例子,使用async/await在MemoryStream上执行写操作将保持CPU受限状态(我可能不会从中受益),尽管我肯定会从文件和网络流中受益。

1
这是一个相当不错的答案,使用theadpool来处理CPU密集型工作是不好的,因为TP线程应该用于卸载IO操作。在我看来,CPU密集型工作应该是阻塞的,当然有一些注意事项,并且没有什么可以阻止使用多个线程。 - davidcarr

20

我并不想与Eric Lippert或Lasse V. Karlsen等人竞争,只是想引起对这个问题另一个方面的关注,我认为这个方面没有得到明确的提及。

仅仅使用await并不能让您的应用程序自动变得响应。如果在UI线程上等待的方法中发生阻塞,它仍然会像非等待版本一样阻塞您的UI。

您必须特别编写可等待的方法,以便它要么生成一个新线程,要么使用类似完成端口之类的东西(它将在当前线程中返回执行,并在完成端口被信号时调用其他内容进行继续)。但这部分已经在其他答案中很好地解释了。


6
首先,这不是一场竞争,而是一次合作! - Eric Lippert

11

我尝试自下而上地解释一下。也许对某些人有帮助。

当我在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);
    }
}

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 的计时器中断完成的,在其中注册了 OS 的上下文切换代码,因此你不需要关心它。 但是在这里,你正在编写自己的调度程序,因此你需要关注它-到目前为止。

因此,我们可以通过将长时间运行的任务分解成小块并发送继续事件来在单线程中与 GUI 并行运行。 这是 Task 类的一般想法。它表示一件工作,当你在它上面调用 .ContinueWith 时,你定义了在当前块完成时调用的下一块函数(它的返回值传递给继续事件)。 但是,手动执行所有这些链接并将工作分解成小块是一项繁琐的工作,并且完全搞乱了逻辑的布局,因为整个后台任务代码基本上是一个 .ContinueWith 混乱。 因此,编译器就派上用场了。在幕后,它为你处理所有这些链接和继续事件。当你说 await 时,你告诉编译器“在这里停止,在剩余的函数中添加一个继续任务”。 编译器会处理剩下的事情,所以你不必担心。

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


我非常欣赏你的解释,它是一个完美的例证。所有老一辈的人都应该像你在这里所做的那样用类似的方式来解释概念,因为作为Z世代的人,我不知道过去发生了什么以及它是如何发生的。 - Soner from The Ottoman Empire
我终于明白了。每个人都说“没有线程”,但是没有人以某种方式说有一个,即来自线程池的至少一个线程。那些也是线程,还是我理解错了什么? - deralbert
1
@deralbert 线程池的存在是因为任务不仅用于实现异步等待。您可以手动创建一个执行昂贵操作而不分块的任务对象。当您运行它时,它会阻塞工作线程池中的一个线程,而不是主线程。但是,小块的异步等待任务片段仍然可以快速执行,它们不会阻塞,因此甚至可以在主线程上运行而无需额外的线程。(更新答案以减少误导。) - Calmarius

4

总结其他答案:

异步/等待通常用于IO绑定任务,因为使用它们时,调用线程不需要被阻塞。这在UI线程的情况下尤其有用,因为我们可以确保它们在执行后台操作(例如从远程服务器获取要显示的数据)时保持响应。

异步不会创建自己的线程。调用方法的线程用于执行异步方法,直到找到可等待的内容。然后,同一线程继续执行调用方法中异步方法调用之后的部分。请注意,在被调用的异步方法内,在返回可等待的内容后,方法的剩余部分可能使用来自线程池的线程执行 - 这是单独线程出现的唯一地方。


1
很好的总结,但我认为它应该回答两个问题才能给出完整的图片:1. 等待代码在哪个线程上执行?2. 谁控制/配置所提到的线程池 - 开发人员还是运行时环境? - stojke
  1. 在这种情况下,大多数等待的代码是IO绑定操作,不会使用CPU线程。如果希望对CPU绑定操作使用await,则可以生成单独的任务。
  2. 线程池中的线程由TPL框架的任务调度程序管理。
- vaibhav kumar

3

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

异步和等待本身不会创建新线程。但是根据您在哪里使用异步等待,await之前的同步部分可能运行在与await之后的同步部分不同的线程上(例如ASP.NET和ASP.NET Core的行为不同)。

在基于UI-Thread的应用程序(如WinForms、WPF)中,在await之前和之后都将在同一个线程上。但是当您在线程池线程上使用异步等待时,await之前和之后的线程可能不相同。

这个主题的一段很棒的视频


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