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

时间:2020-07-03 23:52:07

标签: c# .net async-await

我正在用Andrew Troelsen的书“带有.NET和.NET Core的Pro C#7”来学习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令牌时,调用线程将在此方法中挂起,直到调用完成。您将要运行此版本的应用程序,则将发现Completed消息显示在Done with work!消息之前。如果是图形应用程序,则用户可以在DoWorkAsync()时继续使用UI方法执行”。

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

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

但是如果调用方法是Main()本身,它将返回给谁?它会只是等待等待完成吗?这就是为什么代码保持原样(在打印“完成”之前等待5秒钟)吗?

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

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

2 个答案:

答案 0 :(得分:4)

我强烈建议您在2012年引入await关键字后阅读此博客文章,但它说明了异步代码如何在控制台程序中工作:https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/


然后作者声明

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

作者不精确。

我会改变这一点:

当逻辑流到达await令牌时,调用线程将在此方法中暂停,直到调用完成

对此:

当逻辑流到达await令牌时(在之后 DoWorkAsync返回Task对象之后),将保存函数的本地状态在内存中的某个位置,并且正在运行的线程执行return返回异步调度程序(即线程池)。

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


接下来的句子也是一个问题:

如果要运行此版本的应用程序,则会发现“完成”消息之前显示“已完成”消息。消息

(我假设作者使用的是“此版本”,指的是语法上相同但省略了await关键字的版本。)

提出的主张不正确。调用的方法DoWorkAsync仍然返回Task<String>,而该Console.WriteLine不能有意义地传递给Task<String>:返回的awaited必须首先为async


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

您的想法通常是正确的。

但是,如果调用方法是Main()本身,它将返回给谁?它会只是等待等待完成吗?这就是为什么代码保持原样(在打印“完成”之前等待5秒钟)吗?

它返回到CLR维护的默认线程池。 Every CLR program has a Thread Pool,这就是为什么即使是最琐碎的.NET程序进程也会出现在Windows Task Manager中,其线程数在4到10之间。但是,这些线程中的大多数将被挂起(但是事实上被暂停与使用await / DoWorkAsync()无关。


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

是,不是:)

如果您查看已编译程序的原始CIL(MSIL),将很有帮助(async是一种纯粹的语法功能,它不依赖于.NET CLR的任何实质性更改,这就是{{即使.NET Framework 4.5在同一NET 4.0 CLR上运行(早于3-4年),但.NET Framework 4.5还是引入了1}} / await关键字。

首先,我需要在语法上重新排列您的程序(此代码看起来有所不同,但是编译为与原始程序相同的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线程(对此有更多详细信息,我想它甚至可能使用OS提供的主CLR入口点线程-我是不确定这些细节)。我们将其称为Thread0

  4. Thread0然后作为常规方法调用运行Console.WriteLine(" Fun With Async ===>");

  5. Thread0然后调用DoWorkAsync() ,也作为常规方法调用

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

    • 请记住,Task.Run是“ 时间表(不是立即运行)”的简写(作为概念上的“工作”,不立即运行),并立即返回{ {1}}代表该工作的状态”。
      • 例如,如果在调用Task<T>时线程池已耗尽或繁忙,那么Task.Run根本不会运行,直到线程返回池-或者您手动增大大小的泳池。
  7. 然后立即给
  8. BlockingJob一个Thread0,它代表Task<String>的生存期和完成时间。请注意,此时BlockingJob方法可能已经运行尚未运行,因为这完全取决于调度程序。

  9. BlockingJob然后遇到Thread0的工作await的第一个BlockingJob

    • 在这一点上,Task<String> actual CIL(MSIL)包含有效的DoWorkAsync语句,该语句使 real 执行返回到{{1 }},然后立即返回线程池,并让.NET异步调度程序开始担心调度问题。
      • 这是复杂的地方:)
  10. 因此,当return返回线程池时,可能会或可能不会调用Main,具体取决于您的计算机设置和环境(如果您的计算机只有1个CPU,情况会有所不同例如核心-还有很多其他东西!)。

    • 完全可能Thread0放入BlockingJob作业到调度程序中,然后直到Task.Run本身返回线程池时才真正运行它,然后调度程序在BlockingJob上运行Thread0,整个程序仅使用一个线程。
    • 但是BlockingJob也有可能立即在另一个池线程上运行Thread0(在这个琐碎的程序中可能就是这种情况)。
  11. 现在,假设Task.Run已屈服于池,并且BlockingJob在线程池(Thread0)中为Task.Run使用了另一个线程,则Thread1将被暂停,因为没有其他预定的继续(来自BlockingJobThread0)也没有预定的线程池作业(来自await或手动使用{{1} }。

    • (请记住,挂起的线程与被阻塞的线程不一样!-请参见脚注1)
    • 因此ContinueWith正在运行Task.Run,并且它睡眠(阻塞)了5秒钟,因为ThreadPool.QueueUserWorkItem被阻塞了,这就是为什么您应该始终在{{1}中选择Thread1的原因}代码,因为它不会被阻塞!)。
    • 这5秒钟之后,BlockingJob会解除阻塞并从该Thread.Sleep调用中返回Task.Delay-并将该值返回到async的内部调度程序的呼叫站点和调度程序将Thread1作业标记为完成,并以"Done with work!"作为结果值(由BlockingJob值表示)。
    • Task.Run然后返回线程池。
    • 调度程序知道BlockingJob内的"Done with work!"上的Task<String>.Result上存在一个Thread1,先前await在步骤{8}中返回了Task<String>到游泳池。
    • 因此,由于DoWorkAsync现在已完成,因此它从线程池中选择了另一个线程(可能为Thread0,也可能不是Thread0 –可能为Task<String>或另一个不同的线程Thread0-同样,它取决于您的程序,计算机等-但最重要的是,它取决于同步上下文以及您使用的是Thread1还是Thread2
      • 在没有同步上下文的琐碎控制台程序中(例如,不是 WinForms,WPF或ASP.NET(但不是ASP.NET Core)),调度程序将使用 any >池中的线程(即没有线程亲和力)。我们称之为ConfigureAwait(true)
  12. (我需要在这里解释一下,尽管您的ConfigureAwait(false)方法在C#源代码中是单个方法,但是在内部Thread2方法被拆分为“子方法”在每个async Task<String> DoWorkAsync语句中,每个“子方法”都可以直接输入)。

    • (它们不是“子方法”,但实际上整个方法都被重写为捕获局部函数状态的隐藏状态机DoWorkAsync。请参见脚注2)。
  13. 因此,现在调度程序告诉await调用struct之后的逻辑所对应的Thread2“子方法”。在这种情况下,它是DoWorkAsync行。

    • 请记住,调度程序知道awaitString value = await threadTask;,因此它将Task<String>.Result设置为该字符串。
  14. "Done with work!"调用的String value子方法还会返回该DoWorkAsync-但不返回到Thread2,而是直接返回到调度程序-然后调度程序将该字符串值传回String valueMain的{​​{1}},然后选择另一个线程(或相同线程)输入Task<String>'子方法代表await messageTask之后的代码,然后该线程以正常方式调用Main和其余代码。


脚语

脚注1

请记住,挂起的线程与阻塞的线程不是同一回事:这是一个过分的简化,但是出于此答案的目的,“挂起的线程”具有一个空的调用栈,可以通过以下方式立即使用:调度程序可以执行有用的操作,而“阻塞线程”具有填充的调用堆栈,并且调度程序无法触摸或重新利用它,除非-并且直到其返回线程池为止-请注意,线程可以被“阻塞”因为它忙于运行正常的代码(例如Main循环或自旋锁),因为它被await messageTask之类的同步原语阻止,因为Console.WriteLine( message );处于睡眠状态或调试器指示操作系统冻结线程)。

脚注2

在我的回答中,我说C#编译器实际上会将每个while语句周围的代码编译为“子方法”(实际上是状态机),这就是允许线程( any 线程,而不管其调用栈状态如何)以“恢复”其线程返回线程池的方法。这是这样的:

假设您具有此Semaphore.WaitOne方法:

Thread.Sleep

编译器将生成在概念上与此C#相对应的CIL(MSIL)(即,如果编写时没有使用awaitasync关键字)。

(此代码省略了很多细节,例如异常处理,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; } 的实际值,内联async,捕获await等,但是这些细节详细信息现在不重要)

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; } } }

答案 1 :(得分:0)

当您使用static async Task Main(string[] args) signature时,C#编译器generates在幕后使用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. 没有内置的可用内容,因此您必须搜索第三方解决方案。就像来自AsyncContext包中的Stephen Cleary的Nito.AsyncEx.Context

因此,当您考虑替代方案的复杂性时,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();
}