async Main()中await行为的困惑

5

我正在学习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秒钟,“已完成”直到“工作已完成”后才显示。
在查阅有关异步/等待的各种在线文档和文章时,我认为“await”的工作方式是:当遇到第一个“await”时,程序会检查方法是否已经完成,如果没有完成,它会立即“返回”到调用方法,然后再在可等待任务完成后返回。
但是如果调用方法本身是Main(),那么它要返回给谁呢?它会简单地等待等待操作完成吗?这就是代码表现出这种行为(等待5秒钟才打印“已完成”)的原因吗?
但这引出了下一个问题:因为DoWorkAsync()本身调用另一个等待方法,当遇到那个等待Task.Run()行时,显然不会完成,这时DoWorkAsync()不应立即返回到调用方法Main()吗?如果发生这种情况,应该会像书中作者建议的那样,Main()继续打印“已完成”吧?
顺便说一下,这本书是针对C# 7的,但我正在运行带有C# 8的VS 2019,是否有任何区别?

1
嗨,由于任务正在等待中,以下指令将在等待的任务完成后执行。 - Noymul Islam Chowdhury
我能理解你的困惑,但这正是你所期望发生的。await(正如其名称所示)等待任务完成,然后将在同一线程上创建一个继续执行的可能的延续(取决于同步上下文),以继续你所在的块的顺序执行。 - TheGeneral
另外,main是一个特殊情况,因为它是应用程序的入口点。 - TheGeneral
1
你可能会问:“如果它阻塞了,那这一切有什么用?”首先,它实际上并不会阻塞(尽管当前代码块的执行会等待工作完成,但这是不同的)。其用途在于可扩展性,在设备芯片上可以将工作排队并回调,没有必要阻塞线程(IO工作)。此外,对于UI框架,它们有一个主线程(消息泵/调度程序),为什么要阻塞UI,当您可以异步地处理工作负载时呢?然后,当您完成时,它会回到主线程(或您所在的上下文)以继续执行。 - TheGeneral
2个回答

9
我强烈推荐阅读这篇2012年的博客文章,当时引入了await关键字,但它解释了控制台程序中异步代码的工作原理:https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/
作者随后表示,此关键字(await)将始终修改返回Task对象的方法。当逻辑流到达await标记时,调用线程在此方法中暂停,直到调用完成。如果运行此应用程序的版本,则会发现“已完成”消息显示在“完成工作!”消息之前。如果这是一个图形应用程序,则用户可以在DoWorkAsync()方法执行时继续使用UI。作者不够精确。我想要改变这个:当逻辑流到达await标记时,调用线程在此方法中暂停,直到调用完成。 改为这个:当逻辑流到达await标记(即在DoWorkAsync返回Task对象之后),函数的本地状态在内存中某处保存,并且运行线程执行return返回到异步调度程序(即线程池)。
我的观点是,await不会导致线程“挂起”(也不会导致线程阻塞)。
下一句话也有问题:
如果您运行此应用程序的版本,您会发现“已完成”消息显示在“完成工作!”消息之前(我认为作者指的是一个语法上相同但省略了await关键字的版本)。
提出的主张是不正确的。调用的方法DoWorkAsync仍然返回一个Task<String>,该字符串无法以有意义的方式传递给Console.WriteLine:必须首先await 返回的 Task<String>。
通过查阅有关async/await工作原理的各种在线文档和文章,我认为"await"会在遇到第一个"await"时起作用,程序会检查方法是否已经完成,如果没有,它将立即返回到调用方法,然后在可等待任务完成后返回。你的想法基本上是正确的。但是,如果调用方法本身是Main(),它会返回给谁呢?它会简单地等待await完成吗?这就是代码表现出等待5秒钟才打印“已完成”的原因吗?它返回到CLR维护的默认线程池。每个CLR程序都有一个线程池,这就是为什么即使是最简单的.NET程序的进程也会在Windows任务管理器中显示4到10个线程计数的原因。然而,大多数线程将被挂起(但它们被挂起的事实与使用async/await无关)。
但这引出了下一个问题:因为DoWorkAsync()本身在这里调用了另一个await的方法,当遇到那个await Task.Run()行时,显然要等待5秒钟才能完成,那么DoWorkAsync()不应该立即返回给调用方法Main()吗?如果发生这种情况,Main()不应该继续打印“已完成”,就像书中作者建议的那样吗?
是和不是 :)
如果您查看编译后程序的原始CIL(MSIL),会有所帮助(await是一种纯语法特性,不依赖于.NET CLR的任何实质性更改,这就是为什么async/await关键字是在.NET Framework 4.5中引入的,即使.NET Framework 4.5运行在比它早3-4年的.NET 4.0 CLR上也是如此)。
首先,我需要将您的程序语法重新排列成这样(这段代码看起来不同,但它编译为与原始程序相同的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. Thread0 然后运行 Console.WriteLine(" Fun With Async ===>"); 作为普通方法调用。

  5. Thread0 然后以普通方法调用的方式调用 DoWorkAsync()

  6. Thread0(在 DoWorkAsync 中)然后调用 Task.Run,传递委托(函数指针)到 BlockingJob

    • 请记住,Task.Run 是 "安排(而不是立即运行)此委托在线程池中的一个线程上作为概念性的“作业”,并立即返回表示该作业状态的 Task<T> 的缩写。
      • 例如,如果在调用 Task.Run 时线程池已经耗尽或繁忙,则根本不会运行 BlockingJob,直到线程返回到池中-或者如果您手动增加池的大小。
  7. Thread0 立即获得一个代表 BlockingJob 生命周期和完成情况的 Task<String>。请注意,在此时,BlockingJob 方法可能已经运行,也可能还没有运行,因为这完全取决于您的调度程序。

  8. Thread0 然后遇到 BlockingJob 的作业的 Task<String> 的第一个 await

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

    • 完全有可能Task.RunBlockingJob 作业放入调度程序,然后直到 Thread0 本身返回到线程池,调度程序才运行它,并且整个程序仅使用单线程。
    • 但是

      脚注

      请记住,挂起的线程与阻塞的线程不是同一件事情:这是一个过于简化的说法,但在本答案中,“挂起的线程”具有空的调用堆栈,并且可以立即由调度程序投入使用以执行某些有用的操作,而“阻塞的线程”具有填充的调用堆栈,调度程序不能接触它或重新分配它,除非它返回到线程池——请注意,线程可能因为正在运行正常代码(例如while循环或自旋锁),因为被同步原语(如Semaphore.WaitOne)阻塞,因为通过Thread.Sleep休眠,或者因为调试器指示操作系统冻结线程而“阻塞”。

      在我的回答中,我说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;
      }
      

      编译器将生成CIL(MSIL),这与C#的概念相对应(即,如果不使用“async”和“await”关键字编写)。
      (此代码省略了许多细节,如异常处理,状态的实际值,它内联了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,出于性能原因,我不会深入讨论。

2
哇,这对新手来说是很多的东西需要消化。非常感谢你提供了如此详细的说明。所以针对我的最后一个问题(你提供了最全面的答案),我能理解为:
  1. await DoWorkAsync() 的调用确实导致了 Main() 线程返回到 CRL 线程池,这就是为什么在这一点之后它们后面的代码没有立即执行的原因。
  2. 当所有子方法都经历完它们的周期并且通过 await DoWorkAsync() 调用获得了具体的字符串时,Main() 中剩下的代码最终得以执行(可能是由新线程执行)。
- thankyoussd
@user683202 差不多就是这样!我能推荐的就是在 Linqpad 中编写一些示例程序,查看生成的 CIL,然后使用 ILSpy(或 Red Gate Reflector)反汇编它,以了解它的真正工作原理。请注意,C#中的await/async实际上并不使用TaskCompletionSourceContinueWith - 但它们仍然是有用的工具。 - Dai

1
当您使用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 Forms、WPF 等)相同的死锁场景的影响。
  2. 没有内置可用的解决方案,因此必须搜索第三方解决方案。例如来自 Nito.AsyncEx.Context 包的 Stephen Cleary 的 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();
}

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