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

用户名

我正在使用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的,但是如果有任何区别,我将使用C#8运行VS 2019。

我强烈建议您在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仍然返回a Task<String>,该值不能有意义地传递给Console.WriteLine:返回的Task<String>必须是awaited第一个。


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

您的想法通常是正确的。

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

它返回到CLR维护的默认线程池。每个CLR程序都有一个线程池,这就是为什么即使是最琐碎的.NET程序进程也会在Windows Task Manager中出现,其线程数在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线程(对此还有更多详细信息,我认为它甚至可能使用OS提供的主CLR入口点线程-我不确定这些细节)。我们称这个为Thread0

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

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

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

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

  8. Thread0然后遇到第一awaitBlockingJob的工作的Task<String>

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

    • 这是完全有可能的是Task.RunBlockingJob作业放到调度,然后直到不可不实际运行它Thread0自己返回线程池,然后调度运行BlockingJobThread0,整个程序只使用一个线程。
    • 但是也有可能立即在另一个池线程上Task.Run运行BlockingJob(这在这个琐碎的程序中可能是这种情况)。
  10. 现在,假设Thread0已屈服于池并Task.Run在线程池(Thread1)中使用了其他线程作为BlockingJobThread0则将被挂起,因为没有其他预定的继续(来自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中返回到池先前在步骤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-但不是Main,但右回调度-然后调度器通过该字符串值返回到Task<String>用于await messageTaskMain,然后拾取另一个线程(或相同的线程)到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;
}

编译器将生成从概念上讲与此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;
        }
    }
}

请注意,出于性能方面的考虑,FoobarAsyncState是一个struct而不是一个class,我将不予讨论。

本文收集自互联网,转载请注明来源。

如有侵权,请联系[email protected] 删除。

编辑于
0

我来说两句

0条评论
登录后参与评论

相关文章

来自分类Dev

nodejs异步在createReadStream中等待

来自分类Dev

异步并在javascript中等待

来自分类Dev

在任务中等待异步/等待

来自分类Dev

异步并等待返回类型混淆

来自分类Dev

与异步/等待功能的流程混淆

来自分类Dev

等待/异步异常行为

来自分类Dev

异步等待行为

来自分类Dev

在Android的IntentService中等待异步回调

来自分类Dev

如何在ExecutePostProcessingAsync中等待异步方法

来自分类Dev

在RxJS Observable的onNext中等待异步操作

来自分类Dev

在快速脚本中等待异步调用

来自分类Dev

异步并在扑朔迷离中等待

来自分类Dev

身份验证在异步setContext中等待

来自分类Dev

使用异步重构并在React中等待?

来自分类Dev

在面额框架中等待而无需异步

来自分类Dev

在for循环中等待异步功能

来自分类Dev

异步任务并在C#中等待

来自分类Dev

异步,在 Visual Studio 2010 中等待

来自分类Dev

通过堆栈的异步/等待行为

来自分类Dev

异步/等待:ConfigureAwait的异常行为

来自分类Dev

新线程的异步等待行为

来自分类Dev

异步等待的不同行为

来自分类Dev

异步等待的不同行为

来自分类Dev

意外的等待/异步行为

来自分类Dev

异步/等待循环行为

来自分类Dev

如何在C#中等待异步工作

来自分类Dev

在异步请求中等待Webapi 2客户端

来自分类Dev

在Vue组件中等待异步结果的正确方法

来自分类Dev

C#异步在未调用的函数中等待