异步/等待。等待部分的继续是在哪里执行的?

7

我非常好奇async/await如何使你的程序不会停顿。我真的很喜欢Stephen Cleary解释async/await的方式"我喜欢把“await”看作是“异步等待”。也就是说,异步方法会暂停,直到可等待对象完成(所以它在等待),但实际线程没有被阻塞(所以它是异步的)"

我读过async方法在编译器遇到await关键字之前是同步工作的。如果编译器无法找到awaitable对象,那么编译器就将awaitable对象排队,并将控制权交给调用AccessTheWebAsync方法的方法。在这个例子中,调用者(事件处理程序)会继续处理。调用方可能会做一些与AccessTheWebAsync方法结果无关的其他工作,然后再等待该结果,或者立即等待该结果。事件处理程序正在等待AccessTheWebAsync方法,而AccessTheWebAsync方法正在等待GetStringAsync方法。让我们看一个MSDN示例

async Task<int> AccessTheWebAsync()
{ 
    // You need to add a reference to System.Net.Http to declare client.
    HttpClient client = new HttpClient();

    // GetStringAsync returns a Task<string>. That means that when you await the 
    // task you'll get a string (urlContents).
    Task<string> getStringTask = client.GetStringAsync("http://msdn.microsoft.com");

    // You can do work here that doesn't rely on the string from GetStringAsync.
    DoIndependentWork();

    // The await operator suspends AccessTheWebAsync. 
    //  - AccessTheWebAsync can't continue until getStringTask is complete. 
    //  - Meanwhile, control returns to the caller of AccessTheWebAsync. 
    //  - Control resumes here when getStringTask is complete.  
    //  - The await operator then retrieves the string result from getStringTask. 
    string urlContents = await getStringTask;

    // The return statement specifies an integer result. 
    // Any methods that are awaiting AccessTheWebAsync retrieve the length value. 
    return urlContents.Length;
}

微软开发团队的另一篇博客文章指出async/await不会创建新的线程或使用线程池中的其他线程。好的。

我的问题:

  1. 在我们的例子中,async/await在哪里执行可等待的代码(例如下载网站),导致控制权让程序转而询问Task<string> getStringTask的结果?我们知道没有新线程,也没有使用线程池。

  2. 我这个傻瓜的假设是否正确,即CLR只是在一个线程范围内在当前可执行代码和可等待方法之间切换?但更改加数的顺序不会改变总和,UI可能会被阻塞一段时间。


不是编译器查看可等待对象,而是编译器编译您的代码。 - i3arnon
3个回答

15
如果操作真正是异步的,那么就没有需要“执行”的代码了。你可以将其视为通过回调来处理; HTTP请求被发送(同步),然后HttpClient注册完成Task<string>的回调函数。当下载完成时,回调被调用,从而完成任务。它比这更复杂,但这是一般思路。
我有一篇博文详细介绍了无需线程如何进行异步操作
那么CLR是否只在一个线程范围内切换当前可执行代码和可等待部分呢?这是部分正确的思路,但不完整。首先,当一个async方法恢复,它的(原先的)调用堆栈并没有随之恢复。因此,async/await纤程协程非常不同,尽管它们可以用于实现类似的功能。
不要将await视为“切换到其他代码”,而应该将其视为“返回一个不完整的任务”。如果调用方法调用await,则它也会返回一个不完整的任务,以此类推。最终,你要么将不完整的任务返回给框架(例如ASP.NET MVC/WebAPI/SignalR或单元测试运行器),要么拥有一个async void方法(例如UI事件处理程序)。在操作进行中,你最终会得到一堆任务对象的“堆栈”。不是真正的堆栈,只是一个依赖关系树。每个async方法都由一个任务实例表示,并且它们都在等待异步操作完成。

等待部分的继续在哪里执行?

当等待任务时,await默认情况下会在捕获的上下文中恢复其async方法。该上下文是SynchronizationContext.Current,除非为空,在这种情况下,它是TaskScheduler.Current。实际上,这意味着在UI线程上运行的async方法将在该UI线程上恢复;处理ASP.NET请求的async方法将恢复处理同一ASP.NET请求(可能在不同的线程上);在大多数其他情况下,async方法将在线程池线程上恢复。
在问题的示例代码中,GetStringAsync将返回一个未完成的任务。当下载完成时,该任务将完成。因此,当AccessTheWebAsync调用await来等待该下载任务时(假设下载尚未完成),它将捕获当前上下文,然后从AccessTheWebAsync返回一个未完成的任务。
当下载任务完成时,AccessTheWebAsync的继续将被安排到该上下文(UI线程、ASP.NET请求、线程池等),并且将在该上下文中执行时提取结果的Length。当AccessTheWebAsync方法返回时,它设置了先前从AccessTheWebAsync返回的任务结果。这反过来将恢复下一个方法,以此类推。

2
一般来说,续体(await之后的方法部分)可以在任何地方运行。实际上,它往往在UI线程(例如Windows应用程序)或线程池(例如ASP .NET服务器)上运行。在某些情况下,它也可以在调用线程上同步运行...这真的取决于您调用的API类型和使用的同步上下文。
您链接的博客文章并没有说续体不会在线程池线程上运行,它只是说将一个方法标记为async并不能神奇地使该方法的调用在单独的线程或线程池上运行。
也就是说,他们只是想告诉你,如果你有一个方法void Foo() { Console.WriteLine(); },将其更改为async Task Foo() { Console.WriteLine(); }并不会突然使Foo();的调用表现出任何不同 - 它仍然会同步执行。

因此,如果可等待的代码尝试在UI线程上运行,它可能会阻塞UI。但是,如果使用异步代码,则不会发生UI的阻塞。 - StepUp
@StepUp 异步调用不在 UI 线程上运行。如果需要的话,后续操作可以在 UI 线程上运行(例如使用 HTTP 调用结果更新文本框)。从你的问题标题来看,这就是你所问的 - 后续操作在哪里运行? - Joren
我真的很困惑,续体和异步代码有什么区别?因为如果无法立即返回可等待代码的结果,那么续体和异步代码将被分配任务。 - StepUp
@StepUp 继续操作在异步操作之后。它是 await 后面的代码。在您的情况下,它是 return urlContents.Length;。当然,它需要一个线程来运行,通常是 ThreadPool 线程。但实际的异步操作不需要一个。 - i3arnon

2
如果您所说的“可等待代码”是指实际的异步操作,那么您需要意识到它“在CPU之外执行,因此不需要线程和要运行的代码”。
例如,当您下载网页时,大部分操作发生在您的服务器与Web服务器之间发送和接收数据时。这时没有代码需要执行。这就是为什么您可以“接管”线程并在等待任务获取实际结果之前执行其他操作(其他CPU操作)的原因。
所以回答您的问题:
1.它“在CPU之外执行(因此实际上没有被执行)。这可能意味着网络驱动程序、远程服务器等(主要是I/O)。
2.不需要。真正的异步操作不需要由CLR执行。它们只是在未来启动和完成。
一个简单的例子是Task.Delay,它创建一个任务,在一段时间后完成:
var delay = Task.Delay(TimeSpan.FromSeconds(30));
// do stuff
await delay;

Task.Delay 内部创建并设置一个 System.Threading.Timer,在间隔后执行回调函数并完成任务。 System.Threading.Timer 不需要线程,它使用系统时钟。因此您有一个可等待的代码块,该代码块会在30秒内“执行”,但实际上在那段时间内没有任何事情发生。操作已启动并将在未来的30秒内完成。


2
“它在CPU之外执行,因此不需要线程和要运行的代码。” - 这通常适用于异步操作,但对于续体来说并非如此,我认为这就是问题所在。续体将在CPU上运行,并将在一个线程上运行。 - Joren
@Joren 我认为这不是被问到的内容。 - i3arnon
1
@StepUp 一个 CPU 绑定的操作并不是自然异步的。它可以被卸载到 ThreadPool 线程(例如使用 Task.Run)并被视为异步,但这并不是 async-await 的一般情况。 - i3arnon
1
@StepUp 在这种情况下,操作确实需要一个线程,这是一个“线程池”线程。 - i3arnon
非常感谢 @i3arnon。 - StepUp
显示剩余2条评论

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