一遍又一遍地听到说使用async
-await
不会创建任何额外的线程。这不合理,因为计算机看起来在同时处理多个任务的唯一方式是:
- 实际上同时处理多个任务(并行执行,利用多个处理器)
- 通过调度任务并在它们之间切换来模拟它(做一点A,一点B,一点A等等)。
因此,如果async
-await
既没有做到这两点,那么它如何使应用程序响应呢?如果只有一个线程,那么调用任何方法都意味着在执行任何其他操作之前必须等待该方法完成,而该方法内部的方法也必须等待结果才能继续进行,等等。
一遍又一遍地听到说使用async
-await
不会创建任何额外的线程。这不合理,因为计算机看起来在同时处理多个任务的唯一方式是:
因此,如果async
-await
既没有做到这两点,那么它如何使应用程序响应呢?如果只有一个线程,那么调用任何方法都意味着在执行任何其他操作之前必须等待该方法完成,而该方法内部的方法也必须等待结果才能继续进行,等等。
实际上,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_Click
方法。在此方法返回之前,此循环现在被卡住了等待。这需要2秒钟,在此期间,不会处理任何消息。async/await
没有创建新线程,则它是如何工作的呢?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 ------+
基本上,这种方法的执行方式如下:
执行await
之前的所有内容。
调用GetSomethingAsync
方法,该方法执行其任务,并返回一个在未来2秒内完成的东西。
到目前为止,我们仍然在原始的button1_Click调用中执行,这发生在主线程上,从消息循环中调用。如果在await
之前的代码需要很长时间,用户界面将仍然会冻结。在我们的示例中,不是很多。
await
关键字与一些聪明的编译器魔法一起使用时,实际上是像这样的:“好了,你知道吗,我要在这里从按钮单击事件处理程序中简单地返回。当你(也就是我们正在等待的东西)完成时,请告诉我,因为我仍然有一些代码要执行”。
实际上,它会通知SynchronizationContext类 完成了操作,具体取决于当前正在使用的同步上下文将如何排队执行。Windows窗体程序中使用的上下文类将使用消息循环正在处理的队列进行排队。
因此,它返回到消息循环中,现在可以继续抽取消息,例如移动窗口、调整大小或单击其他按钮。
对于用户来说,用户界面现在是响应的,可以处理其他按钮点击、调整大小和最重要的重新绘制,因此不会出现卡顿现象。
2秒钟后,我们等待的东西完成,现在它(也就是同步上下文)将一条消息放入消息循环正在查看的队列中,表示“嘿,我有更多代码需要执行”,而这些代码是await之后的所有代码。
当消息循环到达该消息时,它将基本上“重新进入”离开await
后的方法,并继续执行该方法的其余部分。请注意,该代码再次从消息循环调用,因此如果该代码使用async/await
不正确并且做某些冗长的操作,它将再次阻止消息循环。
这里有许多复杂的部分,因此以下是一些更多信息的链接。我原本想说“如果你需要的话”,但这个主题相当广泛,知道一些这些“移动部件”也相当重要。不可避免地,您将了解到async/await仍然是一个泄漏的概念。一些潜在的限制和问题仍然会泄漏到周围的代码中,如果它们没有,通常你最终会不得不调试一个看似毫无道理地断开的应用程序。
好的,那么如果 GetSomethingAsync
启动一个在 2 秒内完成的线程呢?是的,那么显然有一个新的线程在运行。但是,这个线程并不是由于这个方法是异步的而产生的,而是因为该方法的程序员选择了一个线程来实现异步代码。几乎所有异步 I/O 都不使用线程,它们使用不同的东西。async/await
本身不会启动新线程,但是我们等待的“东西”可能是使用线程实现的。
在 .NET 中有很多东西本质上没有自己的线程,但仍然是异步的:
SomethingSomethingAsync
或 BeginSomething
和 EndSomething
的方法,并且涉及到一个 IAsyncResult
。通常这些东西在幕后不会使用线程。
好的,你想要一些关于“广泛主题”的内容吗?
那么,让我们询问Try Roslyn关于我们的按钮点击事件:
我不会在这里链接完整的生成类,但它是相当血腥的东西。
await
也可以像您描述的那样使用,但通常不会。只有回调被调度到线程池中 - 在回调和请求之间,不需要线程。 - Luaanasync
/await
是否启动新线程,它们不会。如果在同步方法上使用 async
修饰符(没有 await
),那么编译器会警告您它将在调用线程上同步运行。对于 CPU 绑定的工作,通常使用 await Task.Run
,这种情况下,Task.Run
是使其在线程池线程上运行的原因。 - Stephen Clearyawait
的目的不是通过魔法将同步代码变成异步代码。它的作用是在调用异步代码时使用与编写同步代码相同的技术。 Await是关于使使用高延迟操作的代码看起来像使用低延迟操作的代码。这些高延迟操作可能位于线程上,也可能位于专用硬件上,或者它们会将工作分成小块,并将其放入消息队列中,以便稍后由UI线程处理。 它们正在做某些事情来实现异步性,但它们才是实现异步性的人。等待只是让您利用该异步特性的一种方式。此外,我认为你忽略了第三个选项。 我们老年人——今天的孩子听着那些饶舌音乐,他们应该离开我的草坪等等——还记得20世纪90年代初期的Windows世界。 那时没有多CPU机器和线程调度程序。 如果您想同时运行两个Windows应用程序,您必须主动放弃。 多任务处理是协作式的。 操作系统告诉一个进程它可以运行,如果它表现不好,它就会阻止其他所有进程得到服务。 它一直运行直到被放弃,而且不知何故,下一次操作系统控制权交回给它时,它必须知道从哪里开始继续执行。 单线程异步代码非常类似,只不过使用了“await”代替了“yield”。 等待意味着“我将在这里记住我停止的地方,让别人运行一段时间;当我正在等待的任务完成时,叫回我,然后我将从上次停下的地方继续。” 我认为你可以看出这样做如何使应用程序更加响应迅速,就像在Windows 3时期一样。
调用任何方法都意味着等待该方法完成
这就是你忽略的关键。 一个方法可以在其工作完成之前返回。 这就是异步操作的本质。 一个方法返回,返回一个任务,表示“此工作正在进行中;当它完成时告诉我该做什么”。 方法的工作尚未完成,尽管它已经返回。
在await运算符之前,您必须编写看起来像穿过瑞士奶酪的意大利面的代码,以处理我们需要在完成后执行的工作,但是返回和完成不同步的事实。Await允许您编写看起来同步了返回和完成的代码,而它们实际上并没有同步。
yield
关键字。在C#中,异步方法和迭代器都是协程的一种形式,它是一个通用术语,用于表示可以暂停当前操作以便以后恢复的函数。现今许多语言都具有协程或类似协程的控制流。 - Eric Lippert我很高兴有人问这个问题,因为很长一段时间以来,我也认为线程是并发所必需的。当我第一次看到事件循环时,我觉得这是一个谎言。我心想,“如果代码在单个线程中运行,那么它怎么可能是并发的呢?”请记住,这是在我已经努力理解并发和并行之间区别之后。
通过自己的研究,我终于找到了缺失的部分:select()
。具体而言,是由不同内核实现的IO多路复用,具有不同的名称:select()
、poll()
、epoll()
、kqueue()
。这些是系统调用,虽然实现细节不同,但可以将一组文件描述符传递给它们进行监视。然后,您可以进行另一个调用,该调用会阻塞,直到其中一个被监视的文件描述符发生变化。
await
函数时,您正在观察某个事件(该函数的完成),然后将控制权返回到主事件循环。当您正在观察的事件完成时,该函数(最终)从上次离开的地方继续执行。允许您挂起和恢复此类计算的函数称为协程。await
和async
使用任务(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 实际上,一个线程池可以有它自己的任务创建策略。
我并不想与Eric Lippert或Lasse V. Karlsen等人竞争,只是想引起对这个问题另一个方面的关注,我认为这个方面没有得到明确的提及。
仅仅使用await
并不能让您的应用程序自动变得响应。如果在UI线程上等待的方法中发生阻塞,它仍然会像非等待版本一样阻塞您的UI。
您必须特别编写可等待的方法,以便它要么生成一个新线程,要么使用类似完成端口之类的东西(它将在当前线程中返回执行,并在完成端口被信号时调用其他内容进行继续)。但这部分已经在其他答案中很好地解释了。
我尝试自下而上地解释一下。也许对某些人有帮助。
当我在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 核心,并且还允许开发人员运行手动编写的长任务(这将阻止工作线程而不是主线程)。
总结其他答案:
异步/等待通常用于IO绑定任务,因为使用它们时,调用线程不需要被阻塞。这在UI线程的情况下尤其有用,因为我们可以确保它们在执行后台操作(例如从远程服务器获取要显示的数据)时保持响应。
异步不会创建自己的线程。调用方法的线程用于执行异步方法,直到找到可等待的内容。然后,同一线程继续执行调用方法中异步方法调用之后的部分。请注意,在被调用的异步方法内,在返回可等待的内容后,方法的剩余部分可能使用来自线程池的线程执行 - 这是单独线程出现的唯一地方。
这并没有直接回答问题,但我认为它提供了一些有趣的额外信息:
异步和等待本身不会创建新线程。但是根据您在哪里使用异步等待,await
之前的同步部分可能运行在与await
之后的同步部分不同的线程上(例如ASP.NET和ASP.NET Core的行为不同)。
在基于UI-Thread的应用程序(如WinForms、WPF)中,在await
之前和之后都将在同一个线程上。但是当您在线程池线程上使用异步等待时,await
之前和之后的线程可能不相同。
await
/async
在不创建任何线程的情况下工作。 - user253751