我正在学习 Andrew Troelsen 的书"Pro C# 7 With .NET and .NET Core"中的 C#。在第 19 章(异步编程)中,作者使用了以下示例代码:
static async Task Main(string[] args)
{
Console.WriteLine(" Fun With Async ===>");
string message = await DoWorkAsync();
Console.WriteLine(message);
Console.WriteLine("Completed");
Console.ReadLine();
}
static async Task<string> DoWorkAsync()
{
return await Task.Run(() =>
{
Thread.Sleep(5_000);
return "Done with work!";
});
}
作者接着说
"...此关键字 (await) 将始终修改返回 Task 对象的方法。当逻辑流到达 await 令牌时,调用线程在此方法中挂起,直到调用完成。如果要运行此版本的应用程序,您会发现"已完成"消息显示在"完成工作!消息。如果这是一个图形应用程序,用户可以在DoWorkAsync()方法执行时继续使用UI"。
但是当我在VS中运行此代码时,我没有获得此行为。主线程实际上被阻塞了 5 秒,"已完成"直到"完成工作!
浏览有关 async/await 如何工作的各种在线文档和文章,我认为"await"会起作用,例如当遇到第一个"await"时,程序会检查该方法是否已经完成,如果没有,它会立即"返回"调用方法,然后在可等待任务完成后返回。
但是,如果调用方法是 Main() 本身,它会返回给谁?它会只是等待等待完成吗?这就是代码按原样运行的原因(在打印"完成"之前等待 5 秒)?
但这引出了下一个问题:因为DoWorkAsync()本身在这里调用了另一个await方法,当遇到await Task.Run()行时,显然要到5秒后才能完成,DoWorkAsync()不应该立即返回到调用方法Main(),如果发生这种情况,Main()不应该像书作者建议的那样继续打印"已完成"吗?
顺便说一句,这本书适用于 C# 7,但我正在使用 C# 2019 运行 VS 8,如果这有什么不同的话。
我强烈建议阅读 2012 年引入await
关键字时的这篇博文,但它解释了异步代码在控制台程序中的工作原理: https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/
作者接着说
此关键字 (await) 将始终修改返回 Task 对象的方法。当逻辑流到达
await
令牌时,调用线程在此方法中挂起,直到调用完成。如果要运行此版本的应用程序,您会发现"已完成"消息显示在"完成工作!"消息之前。如果这是一个图形应用程序,用户可以在执行DoWorkAsync()
方法时继续使用 UI"。
作者不精确。
我会改变这个:
当逻辑流到达
await
令牌时,调用线程在此方法中挂起,直到调用完成
对此:
当逻辑流到达
await
令牌(在DoWorkAsync
返回Task
对象之后)时,函数的本地状态保存在内存中的某个地方,正在运行的线程执行一个return
返回到异步调度程序(即线程池)。
我的观点是,await
不会导致线程"挂起"(也不会导致线程阻塞)。
下一句也是个问题:
如果要运行此版本的应用程序,您会发现"已完成"消息显示在"完成工作!"消息之前
(我假设作者所说的"这个版本"指的是语法相同但省略了await
关键字的版本)。
所提出的主张是不正确的。被调用的方法仍然返回一个不能有意义地传递给Console.WriteLine
DoWorkAsync
Task<String>
:返回的Task<String>
必须首先awaited
。
浏览有关 async/await 如何工作的各种在线文档和文章,我认为"等待"会起作用,例如当遇到第一个"等待"时,程序会检查该方法是否已经完成,如果没有,它会立即"返回"调用方法,然后在可等待任务完成后返回。
你的想法通常是正确的。
但是,如果调用方法是 Main() 本身,它会返回给谁?它会只是等待等待完成吗?这就是代码按原样运行的原因(在打印"完成"之前等待 5 秒)?
它将返回到由 CLR 维护的默认线程池。每个CLR程序都有一个线程池,这就是为什么即使是最微不足道的.NET程序的进程也会出现在Windows任务管理器中,线程数在4到10之间。但是,这些线程中的大多数将被挂起(但它们挂起的事实与使用async
/await
无关。
但这引出了下一个问题:因为
DoWorkAsync()
本身在这里调用另一个await
ed 方法,所以当遇到该await Task.Run()
行时,显然要到 5 秒后才能完成,DoWorkAsync()
不应该立即返回到调用方法Main()
,如果发生这种情况,Main()
不应该继续打印"已完成", 正如书作者所建议的那样?
是和否:)
如果您查看编译程序的原始 CIL(MSIL)(await
是一个纯粹的语法功能,不依赖于对 .NET CLR 的任何实质性更改,这会有所帮助,这就是为什么async
/await
关键字随 .NET Framework 4.5 一起引入的原因,即使 .NET Framework 4.5 运行在相同的 .NET 4.0 CLR 上,比它早 3-4 年。
首先,我需要在语法上将您的程序重新排列为以下内容(此代码看起来不同,但它编译为与原始程序相同的 CIL (MSIL):
static async Task Main(string[] args)
{
Console.WriteLine(" Fun With Async ===>");
Task<String> messageTask = DoWorkAsync();
String message = await messageTask;
Console.WriteLine( message );
Console.WriteLine( "Completed" );
Console.ReadLine();
}
static async Task<string> DoWorkAsync()
{
Task<String> threadTask = Task.Run( BlockingJob );
String value = await threadTask;
return value;
}
static String BlockingJob()
{
Thread.Sleep( 5000 );
return "Done with work!";
}
以下是发生的情况:
CLR 加载程序集并查找
Main
入口点。CLR 还用它从操作系统请求的线程填充默认线程池,它会立即挂起这些线程(如果操作系统本身不挂起它们 - 我忘记了这些细节)。
然后,CLR 选择一个线程用作主线程,另一个线程用作 GC 线程(还有更多详细信息,我认为它甚至可能使用主操作系统提供的 CLR 入口点线程 - 我不确定这些细节)。我们称之为
Thread0
。然后
Thread0
Console.WriteLine(" Fun With Async ===>");
作为普通方法调用运行。然后
Thread0
调用DoWorkAsync()
也作为普通方法调用。Thread0
(DoWorkAsync
内部)然后调用Task.Run
,将委托(函数指针)传递给BlockingJob
。- 请记住,
Task.Run
是"将此委托安排(不立即运行)在线程池中的线程上作为概念"作业",并立即返回Task<T>
来表示该作业的状态"的简写。- 例如,如果在调用线程池时线程池已耗尽或繁忙
Task.Run
则在线程返回到池之前BlockingJob
根本不会运行 - 或者如果您手动增加池的大小。
- 例如,如果在调用线程池时线程池已耗尽或繁忙
- 请记住,
然后立即
Thread0
给出一个Task<String>
,代表BlockingJob
的生命周期和完成。请注意,此时BlockingJob
方法可能尚未运行,也可能尚未运行,因为这完全取决于您的计划程序。然后
Thread0
遇到了BlockingJob
约伯Task<String>
的第一个await
。- 此时,
DoWorkAsync
的实际 CIL (MSIL) 包含一个有效的return
语句,该语句导致实际执行返回到Main
,然后立即返回到线程池,并让 .NET 异步调度程序开始担心调度。- 这就是它变得复杂的地方:)
- 此时,
因此,当
Thread0
返回到线程池时,根据您的计算机设置和环境,可能会也可能不会调用BlockingJob
(例如,如果您的计算机只有 1 个 CPU 内核,情况会有所不同 - 但还有许多其他事情!- 完全有可能
Task.Run
将BlockingJob
作业放入调度程序,然后直到Thread0
本身返回到线程池,然后调度程序在Thread0
上运行BlockingJob
,整个程序仅使用单线程。 - 但也有可能
Task.Run
立即在另一个池线程上运行BlockingJob
(这是这个琐碎程序中可能出现的情况)。
- 完全有可能
现在,假设
Thread0
已经屈服于池,并且Task.Run
线程池(Thread1
)中使用了不同的线程进行BlockingJob
,那么Thread0
将被挂起,因为没有其他计划的延续(来自await
或ContinueWith
)也没有计划的线程池作业(来自Task.Run
或手动使用ThreadPool.QueueUserWorkItem
)。- (请记住,挂起的线程与阻塞的线程不是一回事!- 见脚注1)
- 所以
Thread1
BlockingJob
运行并且它在那 5 秒内休眠(块),因为Thread.Sleep
块,这就是为什么您应该始终更喜欢async
代码中的Task.Delay
,因为它不会阻塞! - 在这 5 秒之后
Thread1
然后取消阻止并从该BlockingJob
调用中返回"Done with work!"
- 它将该值返回到Task.Run
的内部调度程序的调用站点,调度程序将BlockingJob
作业标记为完成,结果值为"Done with work!"
(由Task<String>.Result
值表示)。 - 然后
Thread1
返回到线程池。 - 调度程序知道
DoWorkAsync
内部的Task<String>
上存在一个await
,Thread0
之前在步骤 8 中返回到池时Thread0
使用了该。 - 因此,由于该
Task<String>
现已完成,因此它从线程池中挑选出另一个线程(可能是也可能不是Thread0
- 它可能是Thread1
或另一个不同的线程Thread2
- 同样,这取决于您的程序,您的计算机等 - 但最重要的是,它取决于同步上下文以及您是否使用了ConfigureAwait(true)
或ConfigureAwait(false)
)。- 在没有同步上下文(即不是WinForms、WPF 或 ASP.NET(但 ASP.NET 不是 Core))的普通控制台程序中,调度程序将使用池中的任何线程(即没有线程相关性)。我们称之为
Thread2
.
- 在没有同步上下文(即不是WinForms、WPF 或 ASP.NET(但 ASP.NET 不是 Core))的普通控制台程序中,调度程序将使用池中的任何线程(即没有线程相关性)。我们称之为
(我需要在这里题外话来解释一下,虽然您的
async Task<String> DoWorkAsync
方法是 C# 源代码中的单个方法,但在内部,DoWorkAsync
方法在每个await
语句中被拆分为"子方法",并且每个"子方法"可以直接输入)。- (它们不是"子方法",但实际上整个方法被重写为捕获本地函数状态的隐藏状态机
struct
。见脚注2)。
- (它们不是"子方法",但实际上整个方法被重写为捕获本地函数状态的隐藏状态机
所以现在调度程序告诉
Thread2
调用DoWorkAsync
"子方法",该方法对应于紧随该await
之后的逻辑。在本例中,它是String value = await threadTask;
行。- 请记住,调度程序知道
Task<String>.Result
是"Done with work!"
的,所以它会String value
设置为该字符串。
- 请记住,调度程序知道
然后,
Thread2
调用的DoWorkAsync
子方法也会返回该String value
- 但不是Main
,而是直接返回调度程序 - 然后调度程序将该字符串值传递回Main
中await messageTask
的Task<String>
,然后选择另一个线程(或同一线程)进入Main
的子方法,该方法表示await messageTask
后的代码, 然后,该线程以正常方式调用Console.WriteLine( message );
和其余代码。
脚注
脚注1 请记住,挂起的线程与阻塞的线程不是一回事:这是一个过度简化,但出于此答案的目的,"挂起线程"有一个空的调用堆栈,可以由调度程序立即投入工作以做一些有用的事情,而"阻塞线程"有一个填充的调用堆栈,调度程序不能触摸它或重新利用它,除非它返回到线程池 - 请注意线程可能被"阻塞",因为它忙于运行正常代码(例如while
循环或旋转锁),因为它被同步原语(如Semaphore.WaitOne
)阻塞,因为它在Thread.Sleep
休眠,或者因为调试器指示操作系统冻结线程)。
脚注2在我的回答中,我说 C# 编译器实际上会将每个await
语句周围的代码编译为"子方法"(实际上是状态机),这就是允许线程(任何线程,无论其调用堆栈状态如何)"恢复"其线程返回到线程池的方法的原因。这是它的工作原理:
假设您有以下async
方法:
async Task<String> FoobarAsync()
{
Task<Int32> task1 = GetInt32Async();
Int32 value1 = await task1;
Task<Double> task2 = GetDoubleAsync();
Double value2 = await task2;
String result = String.Format( "{0} {1}", value1, value2 );
return result;
}
编译器将生成在概念上与此 C# 对应的 CIL(MSIL)(即,如果编写时没有async
和await
关键字)。
(这段代码省略了很多细节,比如异常处理、state
的实际值、内联AsyncTaskMethodBuilder
、this
的捕获等等——但这些细节现在并不重要)
Task<String> FoobarAsync()
{
FoobarAsyncState state = new FoobarAsyncState();
state.state = 1;
state.task = new Task<String>();
state.MoveNext();
return state.task;
}
struct FoobarAsyncState
{
// Async state:
public Int32 state;
public Task<String> task;
// Locals:
Task<Int32> task1;
Int32 value1
Task<Double> task2;
Double value2;
String result;
//
public void MoveNext()
{
switch( this.state )
{
case 1:
this.task1 = GetInt32Async();
this.state = 2;
// This call below is a method in the `AsyncTaskMethodBuilder` which essentially instructs the scheduler to call this `FoobarAsyncState.MoveNext()` when `this.task1` completes.
// When `FoobarAsyncState.MoveNext()` is next called, the `case 2:` block will be executed because `this.state = 2` was assigned above.
AwaitUnsafeOnCompleted( this.task1.GetAwaiter(), this );
// Then immediately return to the caller (which will always be `FoobarAsync`).
return;
case 2:
this.value1 = this.task1.Result; // This doesn't block because `this.task1` will be completed.
this.task2 = GetDoubleAsync();
this.state = 3;
AwaitUnsafeOnCompleted( this.task2.GetAwaiter(), this );
// Then immediately return to the caller, which is most likely the thread-pool scheduler.
return;
case 3:
this.value2 = this.task2.Result; // This doesn't block because `this.task2` will be completed.
this.result = String.Format( "{0} {1}", value1, value2 );
// Set the .Result of this async method's Task<String>:
this.task.TrySetResult( this.result );
// `Task.TrySetResult` is an `internal` method that's actually called by `AsyncTaskMethodBuilder.SetResult`
// ...and it also causes any continuations on `this.task` to be executed as well...
// ...so this `return` statement below might not be called until a very long time after `TrySetResult` is called, depending on the contination chain for `this.task`!
return;
}
}
}
请注意,出于性能原因,FoobarAsyncState
是一个struct
而不是class
,我不会深入探讨。
使用static async Task Main(string[] args)
签名时,C# 编译器会在后台生成一个MainAsync
方法,实际的Main
方法将按如下所示重写:
public static void Main()
{
MainAsync().GetAwaiter().GetResult();
}
private static async Task MainAsync()
{
// Main body here
}
这意味着控制台应用程序的主线程(ManagedThreadId
等于1
的线程)在未完成任务的第一await
命中后立即被阻塞,并且在应用程序的整个生存期内保持阻塞状态!在此之后,应用程序以独占方式在ThreadPool
线程上运行(除非代码显式启动线程)。
这是对线程的浪费,但另一种方法是安装控制台应用程序的SynchronizationContext
,这还有其他缺点:
- 应用程序容易受到困扰 UI 应用程序(Windows 窗体、WPF 等)的相同死锁方案的影响。
- 没有可用的内置内容,因此您必须搜索第三方解决方案。就像Stephen Cleary从Nito.AsyncEx.Context包中
AsyncContext
一样。
因此,当您考虑替代方案的复杂性时,浪费 1 MB RAM 的价格变得便宜!
不过,还有另一种选择,它可以更好地利用主线程。这是为了避免async Task Main
签名。只需在应用的每个主要异步方法之后使用.GetAwaiter().GetResult();
。这样,在方法完成后,您将回到主线程中!
static void Main(string[] args)
{
Console.WriteLine(" Fun With Async ===>");
string message = DoWorkAsync().GetAwaiter().GetResult();
Console.WriteLine(message);
Console.WriteLine($"Completed, Thread: {Thread.CurrentThread.ManagedThreadId}");
Console.ReadLine();
}