一次又一次,我看到它说使用 async
-await
不会创建任何额外的线程。这没有任何意义,因为计算机似乎一次可以做不止一件事情的唯一方法是
实际上一次做不止一件事(并行执行,利用多个处理器)
通过调度任务并在它们之间切换来模拟它(做一点点A,一点点B,一点点A等)
因此,如果 async
-await
两者都没有,那么它如何使应用程序响应?如果只有 1 个线程,则调用任何方法意味着等待该方法完成后再执行任何其他操作,并且该方法中的方法必须等待结果才能继续,等等。
await
/async
如何在不创建任何线程的情况下工作。
实际上,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
没有创建新线程,它是如何做到的呢?
好吧,发生的事情是您的方法分为两部分。这是那些广泛的主题类型之一,所以我不会详细介绍,但足以说明该方法分为以下两部分:
导致 await 的所有代码,包括对 GetSomethingAsync 的调用 await 之后的所有代码
插图:
code... code... code... await X(); ... code... code... code...
重新排列:
code... code... code... var x = X(); await X; code... code... code...
^ ^ ^ ^
+---- portion 1 -------------------+ +---- portion 2 ------+
基本上该方法执行如下:
它执行一切等待等待它调用 GetSomethingAsync 方法,该方法执行它的操作,并返回将在未来 2 秒内完成的东西到目前为止,我们仍然在对 button1_Click 的原始调用中,发生在主线程上,从消息循环。如果导致等待的代码花费大量时间,UI 仍将冻结。在我们的示例中,await 关键字和一些聪明的编译器魔法所做的并不是什么,它基本上类似于“好吧,你知道吗,我将在这里简单地从按钮单击事件处理程序返回。当你(例如,我们正在等待的事情)快来完成,让我知道,因为我还有一些代码要执行”。实际上,它会让 SynchronizationContext 类知道它已经完成,这取决于当前正在播放的实际同步上下文,它将排队等待执行。 Windows 窗体程序中使用的上下文类将使用消息循环正在抽取的队列对其进行排队。所以它返回到消息循环,现在可以自由地继续发送消息,例如移动窗口、调整窗口大小或单击其他按钮。对于用户而言,UI 现在再次响应,处理其他按钮单击、调整大小以及最重要的是重绘,因此它看起来不会冻结。 2 秒后,我们等待的事情完成了,现在发生的事情是它(嗯,同步上下文)将一条消息放入消息循环正在查看的队列中,说“嘿,我有更多代码你去执行”,这段代码就是await之后的所有代码。当消息循环到达该消息时,它将基本上“重新进入”它停止的方法,就在 await 之后并继续执行该方法的其余部分。请注意,此代码再次从消息循环中调用,因此如果此代码碰巧在没有正确使用 async/await 的情况下做了一些冗长的事情,它将再次阻塞消息循环
这里有很多活动部件,所以这里有一些指向更多信息的链接,我想说“如果你需要它”,但这个主题相当广泛,了解其中一些活动部件是相当重要的。你总是会明白 async/await 仍然是一个有漏洞的概念。一些潜在的限制和问题仍然会泄漏到周围的代码中,如果没有,您通常最终不得不调试一个看似没有充分理由随机中断的应用程序。
使用 Async 和 Await 进行异步编程(C# 和 Visual Basic)
SynchronizationContext 类
Stephen Cleary - 没有值得一读的主题!
第 9 频道 - Mads Torgersen:C# Async 内部非常值得一看!
好的,那么如果 GetSomethingAsync
启动一个将在 2 秒内完成的线程呢?是的,那么显然有一个新线程在起作用。然而,这个线程并不是因为这个方法的异步性,而是因为这个方法的程序员选择了一个线程来实现异步代码。几乎所有异步 I/O不都使用线程,它们使用不同的东西。 async/await
他们自己不会启动新线程,但显然“我们等待的事情”可以使用线程来实现。
.NET 中有很多东西不一定会自行启动线程,但仍然是异步的:
Web 请求(以及许多其他需要时间的网络相关事情)
异步文件读写
还有更多,一个好兆头是,如果所讨论的类/接口具有名为 SomethingSomethingAsync 或 BeginSomething 和 EndSomething 的方法,并且涉及到 IAsyncResult。
通常这些东西不使用引擎盖下的线程。
好的,所以你想要一些“广泛主题的东西”?
好吧,让我们问一下 Try Roslyn 关于我们的按钮点击:
我不会在这里链接完整生成的类,但它是非常血腥的东西。
我在我的博文 There Is No Thread 中对其进行了全面解释。
总之,现代 I/O 系统大量使用 DMA(直接内存访问)。网卡、视频卡、硬盘控制器、串行/并行端口等上都有特殊的专用处理器。这些处理器可以直接访问内存总线,并完全独立于 CPU 处理读/写。 CPU只需要通知设备内存中包含数据的位置,然后可以做自己的事情,直到设备引发中断通知CPU读/写完成。
一旦操作运行,CPU 就没有工作要做,因此没有线程。
async
/await
是否启动新线程,而他们没有。如果您在同步方法上有 async
修饰符(没有 await
),那么编译器会警告您它将同步运行(直接在调用线程上)。对于 CPU 密集型工作,通常使用 await Task.Run
,在这种情况下,Task.Run
是使其在线程池线程上运行的原因。
一台计算机看起来一次做多于一件事的唯一方法是(1)实际上一次做多于一件事,(2)通过调度任务并在它们之间切换来模拟它。因此,如果 async-await 两者都没有
并不是那些都没有等待。请记住,await
的目的不是让同步代码神奇地异步。这是为了在调用异步代码时使用我们用于编写同步代码的相同技术。 Await 是关于使使用高延迟操作的代码看起来像使用低延迟操作的代码。那些高延迟操作可能在线程上,它们可能在专用硬件上,它们可能将它们的工作分解成小块并将其放入消息队列中,以便稍后由 UI 线程处理。他们正在做一些事情来实现异步,但他们是在做这件事的人。 Await 只是让您利用这种异步性。
另外,我认为您缺少第三种选择。我们这些老人——今天带着说唱音乐的孩子们应该离开我的草坪,等等——记得 1990 年代初期的 Windows 世界。没有多 CPU 机器,也没有线程调度程序。你想同时运行两个 Windows 应用程序,你必须让步。多任务处理是合作的。操作系统告诉一个进程它可以运行,如果它行为不端,它将使所有其他进程无法获得服务。它一直运行直到它屈服,并且不知何故,它必须知道如何在下次操作系统将控制权交还给它时从中断的地方恢复。单线程异步代码很像这样,用“await”而不是“yield”。等待的意思是“我要记住我在这里停下来的地方,让别人跑一会儿;等我等待的任务完成后给我回电话,我会从停下来的地方继续。”我认为您可以看到这如何使应用程序响应更快,就像它在 Windows 3 天中所做的那样。
调用任何方法意味着等待方法完成
有你缺少的钥匙。方法可以在其工作完成之前返回。这就是异步的本质。一个方法返回,它返回一个任务,意思是“这项工作正在进行中;告诉我完成后要做什么”。该方法的工作没有完成,即使它已经返回。
在 await 运算符之前,您必须编写看起来像意大利面条穿过瑞士奶酪的代码,以处理完成后我们还有工作要做的事实,但返回和完成是不同步的。 Await 允许您编写看起来像返回和完成是同步的代码,但实际上它们并不同步。
yield
关键字。 C# 中的 async
方法和迭代器都是 coroutine 的一种形式,它是一个函数的总称,它知道如何暂停其当前操作以便稍后恢复。如今,许多语言都有协程或类似协程的控制流。
yield return
为我们提供了在完成之前返回的工作流。 async
工作流程相同;他们在完成之前返回。
我真的很高兴有人问了这个问题,因为在很长一段时间内我也相信线程对于并发是必要的。当我第一次看到事件循环时,我认为它们是谎言。我心想“如果这段代码在单线程中运行,就不可能并发”。请记住,这是在我已经经历了理解并发和并行性之间差异的斗争之后。
经过我自己的研究,我终于找到了缺失的部分:select()
。具体来说,IO 多路复用,由不同名称的各种内核实现:select()
、poll()
、epoll()
、kqueue()
。这些是 system calls,虽然实现细节不同,但允许您传入一组 file descriptors 来观看。然后,您可以进行另一个调用,该调用会阻塞,直到其中一个被监视的文件描述符发生更改。
因此,可以等待一组 IO 事件(主事件循环),处理第一个完成的事件,然后将控制权交还给事件循环。冲洗并重复。
这是如何运作的?嗯,简短的回答是它是内核和硬件级别的魔法。计算机中除了CPU之外还有很多组件,这些组件可以并行工作。内核可以控制这些设备并直接与它们通信以接收某些信号。
这些 IO 多路复用系统调用是单线程事件循环(如 node.js 或 Tornado)的基本构建块。当您await
一个函数时,您正在监视某个事件(该函数的完成),然后将控制权交还给主事件循环。当您正在观看的事件完成时,该功能(最终)从它停止的地方开始。允许您像这样暂停和恢复计算的函数称为 coroutines。
await
和 async
使用 任务 而不是线程。
框架有一个线程池准备以Task对象的形式执行一些工作;将任务提交到池意味着选择一个空闲的、已经存在的线程来调用任务操作方法。创建一个任务就是创建一个新对象,比创建一个新线程快得多。
给定一个 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
它变成了这样的东西
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();
实际上一个池可以有它的任务创建策略。
async
函数的调用者有一个 SynchronizationContext(就像 GUI 线程一样),则延续被包装在一个调用中,该调用将在原始上下文中调度它。请参阅this。您的处理程序必须是 async
才能工作。
以下是我对这一切的看法,它在技术上可能不是超级准确,但至少对我有帮助:)。
机器上基本上有两种类型的处理(计算):
CPU 上发生的处理
发生在其他处理器(GPU、网卡等)上的处理,我们称它们为 IO。
所以,当我们写一段源码的时候,编译之后,根据我们使用的对象(这点很重要),处理会是CPU bound,或者IO bound,其实可以绑定一个组合两个都。
一些例子:
如果我使用 FileStream 对象(它是一个 Stream)的 Write 方法,处理将是 1% CPU 限制和 99% IO 限制。
如果我使用 NetworkStream 对象(它是一个 Stream)的 Write 方法,处理将是 1% CPU 限制和 99% IO 限制。
如果我使用 Memorystream 对象(它是一个 Stream)的 Write 方法,处理将是 100% CPU 绑定。
因此,正如您所看到的,从面向对象的程序员的角度来看,虽然我总是访问 Stream
对象,但下面发生的事情可能在很大程度上取决于对象的最终类型。
现在,为了优化事情,如果可能和/或必要的话,能够并行运行代码有时很有用(注意我不使用异步这个词)。
一些例子:
在桌面应用程序中,我想打印一个文档,但我不想等待它。
我的网络服务器同时为许多客户端提供服务器,每个客户端都并行获取他的页面(未序列化)。
在 async / await 之前,我们基本上有两种解决方案:
线程。它相对容易使用,带有 Thread 和 ThreadPool 类。线程仅受 CPU 限制。
“旧”的 Begin/End/AsyncCallback 异步编程模型。它只是一个模型,它不会告诉您是否会受 CPU 或 IO 限制。如果你看一下 Socket 或 FileStream 类,它是 IO 绑定的,这很酷,但我们很少使用它。
async / await 只是一种常见的编程模型,基于 Task 概念。对于 CPU 绑定任务,它比线程或线程池更易于使用,并且比旧的 Begin/End 模型更易于使用。然而,卧底,它“只是”一个超级复杂的功能完整的包装器。
因此,真正的胜利主要是在 IO Bound 任务上,即不使用 CPU 的任务,但 async/await 仍然只是一种编程模型,它不能帮助您确定最终将如何/在何处进行处理。
这意味着不是因为一个类有一个方法“DoSomethingAsync”返回一个 Task 对象,你可以假定它是 CPU 绑定的(这意味着它可能非常无用,特别是如果它没有取消令牌参数),或者 IO 绑定(这意味着它可能是必须的),或者两者兼而有之(由于该模型非常具有病毒性,因此最终的结合和潜在好处可能是超级混合的,而且不那么明显)。
因此,回到我的示例,在 MemoryStream 上使用 async/await 执行写入操作将保持 CPU 受限(我可能不会从中受益),尽管我肯定会从文件和网络流中受益。
我不打算与 Eric Lippert 或 Lasse V. Karlsen 以及其他人竞争,我只是想提请注意这个问题的另一个方面,我认为没有明确提及。
单独使用 await
不会使您的应用程序具有神奇的响应能力。如果您在等待 UI 线程阻塞的方法中执行任何操作,它仍会像不可等待版本一样阻塞您的 UI。
您必须专门编写您的可等待方法,以便它生成一个新线程或使用类似完成端口的东西(它将在当前线程中返回执行,并在完成端口收到信号时调用其他东西以继续)。但这部分在其他答案中得到了很好的解释。
我试图自下而上地解释它。也许有人觉得它有帮助。当我在 DOS 中用 Pascal 制作简单的游戏时,我在那里,完成了,重新发明了它(美好的旧时光......)
所以...每个事件驱动的应用程序内部都有一个事件循环,如下所示:
while (getMessage(out message)) // pseudo-code
{
dispatchMessage(message); // pseudo-code
}
框架通常会向您隐藏这些细节,但它就在那里。 getMessage 函数从事件队列中读取下一个事件或等待事件发生:鼠标移动、keydown、keyup、单击等。然后 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
时,您定义在当前工作完成时要调用的函数作为下一个工作(并将其返回值传递给延续)。但是手动将所有这些链接分解成小块是一项繁琐的工作,并且完全打乱了逻辑的布局,因为整个后台任务代码基本上是 .ContinueWith
混乱。所以这就是编译器可以帮助你的地方。它在引擎盖下为您完成所有这些链接和延续。当您说 await
时,您告诉编译器“停在这里,将函数的其余部分添加为延续任务”。编译器负责其余的工作,因此您不必这样做。
虽然这个任务块链接不涉及创建线程,并且当块很小时,它们可以在主线程的事件循环中调度,但实际上有一个运行任务的工作线程池。这可以更好地利用 CPU 内核,还允许开发人员运行手动编写的长任务(这将阻塞工作线程而不是主线程)。
总结其他答案:
Async/await 通常是为 IO 绑定任务创建的,因为通过使用它们,调用线程不需要被阻塞。这在 UI 线程的情况下特别有用,因为我们可以确保它们在执行后台操作时保持响应(例如从远程服务器获取要显示的数据)
异步不会创建它自己的线程。调用方法的线程用于执行异步方法,直到找到可等待对象。然后,同一个线程继续执行异步方法调用之外的其余调用方法。请注意,在被调用的异步方法中,在从等待返回后,可以使用线程池中的线程执行该方法的提醒——这是唯一出现单独线程的地方。
这不是直接回答问题,但我认为这是一个有趣的附加信息:
Async 和 await 本身不会创建新线程。但是根据您使用异步等待的位置,等待之前的同步部分可能在与等待之后的同步部分不同的线程上运行(例如,ASP.NET 和 ASP.NET 核心的行为不同)。
在基于 UI 线程的应用程序(WinForms、WPF)中,您将在之前和之后处于同一个线程上。但是当你在线程池线程上使用 async away 时,等待之前和之后的线程可能不一样。
实际上,async await
链是 CLR 编译器生成的状态机。
async await
但是确实使用 TPL 正在使用线程池执行任务的线程。
应用程序没有被阻塞的原因是状态机可以决定执行哪个协程,重复,检查,再决定。
进一步阅读:
What does async & await generate?
Async Await and the Generated StateMachine
Asynchronous C# and F# (III.): How does it work? - Tomas Petricek
编辑:
好的。看来我的阐述是不正确的。但是我必须指出,状态机是 async await
的重要资产。即使您采用异步 I/O,您仍然需要一个助手来检查操作是否完成,因此我们仍然需要一个状态机并确定哪个例程可以一起异步执行。
不定期副业成功案例分享
await
也可以按照您描述的方式使用,但通常不是这样。只有回调被调度(在线程池上)——在回调和请求之间,不需要线程。