异步 Main() 中等待行为的困惑

tha*_*ssd 3 .net c# async-await

我正在通过 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!";
            });
        }
Run Code Online (Sandbox Code Playgroud)

作者接着说

"... this 关键字 (await) 将始终修改返回 Task 对象的方法。当逻辑流到达 await 标记时,调用线程将在此方法中挂起,直到调用完成。如果您要运行此版本在应用程序中,您会发现 Completed 消息显示在 Done with work! 消息之前。如果这是一个图形应用程序,用户可以在 DoWorkAsync() 方法执行时继续使用 UI”。

但是当我在 VS 中运行这段代码时,我没有得到这种行为。主线程实际上被阻塞了 5 秒,直到“完成工作!”之后才会显示“完成”。

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

但是如果调用方法是 Main() 本身,它返回给谁?它会简单地等待等待完成吗?这就是代码行为正常的原因(在打印“已完成”之前等待 5 秒)?

但这引出了下一个问题:因为这里的 DoWorkAsync() 本身调用了另一个 await 方法,当遇到 await Task.Run() 行时,显然要等到 5 秒后才能完成,所以 DoWorkAsync() 不应该立即返回到调用方法 Main(),如果发生这种情况,Main() 是否应该像本书作者建议的那样继续打印“已完成”?

顺便说一句,这本书是针对 C# 7 的,但我正在使用 C# 8 运行 VS 2019,如果这有什么不同的话。

Dai*_*Dai 5

我强烈建议在 2012await年引入关键字时阅读这篇博文,但它解释了异步代码在控制台程序中的工作原理:https : //devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/


作者接着说

this 关键字 (await) 将始终修改返回 Task 对象的方法。当逻辑流到达await令牌时,调用线程将在此方法中挂起,直到调用完成。如果您要运行此版本的应用程序,您会发现“完成”消息显示在“完成工作!”之前。信息。如果这是一个图形应用程序,则用户可以在DoWorkAsync()方法执行时继续使用 UI ”。

作者不严谨。

我会改变这个:

当逻辑流到达awaittoken时,调用线程在这个方法中被挂起,直到调用完成

对此:

当逻辑的流动到达await令牌(这是 DoWorkAsync返回一个Task对象)时,函数的局部状态被保存在内存中的某个地方和正在运行的线程执行return回异步调度程序(即线程池)。

我的观点是这await不会导致线程“挂起”(也不会导致线程阻塞)。


下一句也有问题:

如果您要运行此版本的应用程序,您会发现“完成”消息显示在“完成工作!”之前。信息

(我假设“这个版本”作者指的是一个在语法上相同但省略了await关键字的版本)。

提出的索赔是不正确的。被调用的方法DoWorkAsync仍然返回一个Task<String>无法有意义地传递给Console.WriteLine:返回的Task<String>必须是awaited第一个。


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

你的想法大体上是正确的。

但是如果调用方法是 Main() 本身,它返回给谁?它会简单地等待等待完成吗?这就是代码行为正常的原因(在打印“已完成”之前等待 5 秒)?

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


但这引出了下一个问题:因为DoWorkAsync()这里本身调用了另一个awaited 方法,当await Task.Run()遇到该行时,显然要等到 5 秒后才能完成,不应DoWorkAsync()立即返回调用方法Main(),如果发生这种情况,不应Main()按照书作者的建议继续打印“已完成”?

是与否:)

如果您查看已编译程序的原始 CIL (MSIL),它会有所帮助(这await是一种纯粹的语法特性,不依赖于对 .NET CLR 的任何实质性更改,这就是为什么在.NET Framework 4.5 中引入了async/await关键字,甚至尽管 .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!";
}
Run Code Online (Sandbox Code Playgroud)

这是发生的事情:

  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>代表该作业状态的a 。
      • 例如,如果线程池在Task.Run调用时耗尽或忙碌,则BlockingJob在线程返回池之前根本不会运行 - 或者如果您手动增加池的大小。
  7. Thread0然后立即给出一个Task<String>代表 的生命周期和完成的BlockingJob。请注意,此时该BlockingJob方法可能尚未运行,也可能尚未运行,因为这完全取决于您的调度程序。

  8. Thread0然后遇到第一个awaitforBlockingJob的 Job Task<String>

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

    • 这是完全有可能的是Task.RunBlockingJob作业放到调度,然后直到不可不实际运行它Thread0自己返回线程池,然后调度运行BlockingJobThread0,整个程序只使用一个线程。
    • 但是也有可能Task.RunBlockingJob立即在另一个池线程上运行(在这个简单的程序中很可能就是这种情况)。
  10. 现在,假设Thread0已经让步于池并Task.Run在线程池 ( Thread1) 中使用了不同的线程for BlockingJob,那么Thread0将被挂起,因为没有其他预定的延续(来自awaitContinueWith)也没有预定的线程池作业(来自Task.Run或手动使用ThreadPool.QueueUserWorkItem)。

    • (请记住,挂起的线程与阻塞的线程不同!- 见脚注 1)
    • 所以Thread1正在运行BlockingJob并且它会休眠(阻塞)那 5 秒,因为Thread.Sleep阻塞这就是为什么你应该总是喜欢Task.Delayasync代码中,因为它不会阻塞!)。
    • 在这 5 秒Thread1之后,然后解除阻塞并"Done with work!"从该BlockingJob调用返回- 并将该值返回到Task.Run内部调度程序的调用站点,调度程序将BlockingJob作业标记为完成,并"Done with work!"作为结果值(这由Task<String>.Result值表示)。
    • Thread1 然后返回线程池。
    • 当返回到池中时,调度程序知道await存在于先前在步骤 8 中使用的那个Task<String>内部。DoWorkAsyncThread0Thread0
    • 所以因为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>.Resultis "Done with work!",因此它设置String value为该字符串。
  13. 然后调用的DoWorkAsync子方法Thread2也返回那个String value- 但不是 to Main,而是直接返回给调度程序 - 然后调度程序将该字符串值传递回Task<String>for await messageTaskinMain然后选择另一个线程(或同一个线程)到enter-intoMain的子方法表示之后的代码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;
}
Run Code Online (Sandbox Code Playgroud)

编译器将生成在概念上与此 C# 对应的 CIL (MSIL)(即,如果它是在没有asyncandawait关键字的情况下编写的)。

(这段代码省略了很多细节,比如异常处理、 的真实值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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

请注意,出于性能原因,这FoobarAsyncState是 astruct而不是 aclass我不会讨论。

  • 哇。对于一个新来的人来说,这是很多需要消化的东西。非常感谢您写了这么详细的文章。因此,对于我的最后一个问题(您提供了最全面的答案),我可以将其理解为: 1.await DoWorkAsync() 调用确实导致 Main() 线程返回到 CRL 线程池,这就是为什么此时他们后面的代码没有立即执行。2. 当所有子方法完成循环并通过await DoWorkAsync() 调用获得具体字符串后,Main() 中的剩余代码最终开始执行(可能由新线程执行)。 (2认同)