异步 Main() 中等待行为的混淆



我正在学习 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.WriteLineDoWorkAsyncTask<String>:返回的Task<String>必须首先awaited


浏览有关 async/await 如何工作的各种在线文档和文章,我认为"等待"会起作用,例如当遇到第一个"等待"时,程序会检查该方法是否已经完成,如果没有,它会立即"返回"调用方法,然后在可等待任务完成后返回。

你的想法通常是正确的。

但是,如果调用方法是 Main() 本身,它会返回给谁?它会只是等待等待完成吗?这就是代码按原样运行的原因(在打印"完成"之前等待 5 秒)?

它将返回到由 CLR 维护的默认线程池。每个CLR程序都有一个线程池,这就是为什么即使是最微不足道的.NET程序的进程也会出现在Windows任务管理器中,线程数在4到10之间。但是,这些线程中的大多数将被挂起(但它们挂起的事实与使用async/await无关。


但这引出了下一个问题:因为DoWorkAsync()本身在这里调用另一个awaited 方法,所以当遇到该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!";
}

以下是发生的情况:

  1. CLR 加载程序集并查找Main入口点。

  2. CLR 还用它从操作系统请求的线程填充默认线程池,它会立即挂起这些线程(如果操作系统本身不挂起它们 - 我忘记了这些细节)。

  3. 然后,CLR 选择一个线程用作主线程,另一个线程用作 GC 线程(还有更多详细信息,我认为它甚至可能使用主操作系统提供的 CLR 入口点线程 - 我不确定这些细节)。我们称之为Thread0

  4. 然后Thread0Console.WriteLine(" Fun With Async ===>");作为普通方法调用运行。

  5. 然后Thread0调用DoWorkAsync()也作为普通方法调用。

  6. Thread0(DoWorkAsync内部)然后调用Task.Run,将委托(函数指针)传递给BlockingJob

    • 请记住,Task.Run是"将此委托安排(不立即运行)在线程池中的线程上作为概念"作业",并立即返回Task<T>来表示该作业的状态"的简写。
      • 例如,如果在调用线程池时线程池已耗尽或繁忙Task.Run则在线程返回到池之前BlockingJob根本不会运行 - 或者如果您手动增加池的大小。
  7. 然后立即Thread0给出一个Task<String>,代表BlockingJob的生命周期和完成。请注意,此时BlockingJob方法可能尚未运行,也可能尚未运行,因为这完全取决于您的计划程序。

  8. 然后Thread0遇到了BlockingJob约伯Task<String>的第一个await

    • 此时DoWorkAsync的实际 CIL (MSIL) 包含一个有效的return语句,该语句导致实际执行返回到Main,然后立即返回到线程池,并让 .NET 异步调度程序开始担心调度。
      • 这就是它变得复杂的地方:)
  9. 因此,当Thread0返回到线程池时,根据您的计算机设置和环境,可能会也可能不会调用BlockingJob(例如,如果您的计算机只有 1 个 CPU 内核,情况会有所不同 - 但还有许多其他事情!

    • 完全有可能Task.RunBlockingJob作业放入调度程序,然后直到Thread0本身返回到线程池,然后调度程序在Thread0上运行BlockingJob,整个程序仅使用单线程。
    • 但也有可能Task.Run立即在另一个池线程上运行BlockingJob(这是这个琐碎程序中可能出现的情况)。
  10. 现在,假设Thread0已经屈服于池,并且Task.Run线程池(Thread1)中使用了不同的线程进行BlockingJob,那么Thread0将被挂起,因为没有其他计划的延续(来自awaitContinueWith)也没有计划的线程池作业(来自Task.Run或手动使用ThreadPool.QueueUserWorkItem)。

    • (请记住,挂起的线程与阻塞的线程不是一回事!- 见脚注1)
    • 所以Thread1BlockingJob运行并且它在那 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>上存在一个awaitThread0之前在步骤 8 中返回到池时Thread0使用了该。
    • 因此,由于该Task<String>现已完成,因此它从线程池中挑选出另一个线程(可能是也可能不是Thread0- 它可能是Thread1或另一个不同的线程Thread2- 同样,这取决于您的程序,您的计算机等 - 但最重要的是,它取决于同步上下文以及您是否使用了ConfigureAwait(true)ConfigureAwait(false))。
      • 在没有同步上下文(即不是WinForms、WPF 或 ASP.NET(但 ASP.NET 不是 Core))的普通控制台程序中,调度程序将使用池中的任何线程(即没有线程相关性)。我们称之为Thread2.
  11. (我需要在这里题外话来解释一下,虽然您的async Task<String> DoWorkAsync方法是 C# 源代码中的单个方法,但在内部,DoWorkAsync方法在每个await语句中被拆分为"子方法",并且每个"子方法"可以直接输入)。

    • (它们不是"子方法",但实际上整个方法被重写为捕获本地函数状态的隐藏状态机struct。见脚注2)。
  12. 所以现在调度程序告诉Thread2调用DoWorkAsync"子方法",该方法对应于紧随该await之后的逻辑。在本例中,它是String value = await threadTask;行。

    • 请记住,调度程序知道Task<String>.Result"Done with work!"的,所以它会String value设置为该字符串。
  13. 然后,Thread2调用的DoWorkAsync子方法也会返回该String value- 但不是Main,而是直接返回调度程序 - 然后调度程序将该字符串值传递回Mainawait messageTaskTask<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)(即,如果编写时没有asyncawait关键字)。

(这段代码省略了很多细节,比如异常处理、state的实际值、内联AsyncTaskMethodBuilderthis的捕获等等——但这些细节现在并不重要)

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,这还有其他缺点:

  1. 应用程序容易受到困扰 UI 应用程序(Windows 窗体、WPF 等)的相同死锁方案的影响。
  2. 没有可用的内置内容,因此您必须搜索第三方解决方案。就像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();
}

最新更新