.net 在USCMain()中的await行为的混淆

jmp7cifd  于 2023-11-20  发布在  .NET
关注(0)|答案(2)|浏览(150)

我是通过Andrew Troelsen的书“Pro C# 7 With .NET and .NET Core”来学习C#的。在第19章(Java编程)中,作者使用了以下示例代码:

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秒,直到“完成工作!"之后才显示“完成”。
浏览了各种在线文档和文章,了解了Java/await是如何工作的,我认为“await”可以工作,比如当遇到第一个“await”时,程序会检查方法是否已经完成,如果没有,它会立即“返回”到调用方法,然后在等待任务完成后再回来。
但是**如果调用方法是Main()本身,它会返回给谁?它会简单地等待await完成吗?这就是为什么代码会这样做(在打印“Completed”之前等待5秒)?
但是这就引出了下一个问题:因为DoWorkAsync()本身在这里调用了另一个await方法,当遇到await Task.Run()行时,这显然要在5秒后才能完成,DoWorkAsync()是否应该立即返回到调用方法Main(),如果发生这种情况,Main()是否应该继续打印“Completed”,就像书中作者建议的那样?
顺便说一句,这本书适用于C# 7,但我用C# 8运行VS 2019,如果这有什么区别的话。

1mrurvl1

1mrurvl11#

我强烈建议阅读2012年引入await关键字时的这篇博客文章,但它解释了异步代码如何在控制台程序中工作:https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/
提交人接着指出
这个保留字(await)会永远修改传回Task对象的方法。当逻辑流程到达await词语基元时,呼叫执行绪会在这个方法中暂停,直到呼叫完成为止。如果您要执行这个版本的应用程序,您会发现在[完成工作!“消息。如果这是图形应用程序,则在DoWorkAsync()方法执行时,用户可以继续使用UI”。
作者是不精确的。
我会改变这一点:
当逻辑流到达await标记时,调用线程将在此方法中挂起,直到调用完成
对此:
当逻辑流到达await标记时(即DoWorkAsync返回Task对象之后),函数的本地状态保存在内存中的某个位置,并且正在运行的线程执行return返回到异步调度程序(即线程池)。
我的观点是,await不会导致线程“挂起”(也不会导致线程阻塞)。
下一句话也是个问题:
如果要运行此版本的应用程序,您会发现“已完成”消息显示在“完成工作!”消息之前
(我假设作者所说的“this version”指的是一个在语法上相同但省略了await关键字的版本)。
所做的声明不正确。被调用的方法DoWorkAsync仍然返回一个Task<String>,该Task<String>不能 * 有意义地 * 传递给Console.WriteLine:返回的Task<String>必须首先是awaited
在浏览了各种关于async/await如何工作的在线文档和文章后,我认为“await”会起作用,比如当遇到第一个“await”时,程序会检查该方法是否已经完成,如果没有,它会立即“返回”到调用方法,然后在等待任务完成后返回。
你的想法大体上是正确的。
但是,如果调用方法是Main()本身,那么它返回给谁?它会简单地等待wait完成吗?这就是为什么代码表现得像现在这样(在打印“Completed”之前等待5秒)吗?
它会返回到由CLR. Every CLR program has a Thread Pool维护的默认线程池,这就是为什么即使是最普通的.NET程序进程也会在Windows任务管理器中显示为线程数在4到10之间的原因。然而,这些线程中的大多数将被挂起(但它们被挂起的事实与async/await的使用无关。
但这就引出了下一个问题:因为DoWorkAsync()本身在这里调用了另一个await艾德方法,当遇到await Task.Run()行时(这显然要到5秒后才能完成),DoWorkAsync()不应该立即返回到调用方法Main(),如果发生这种情况,Main()不应该继续打印“Completed,”就像书的作者建议的那样
是和否:)
如果您查看编译后的程序的原始CIL(MSIL)(await是一个纯粹的语法特性,这就是为什么在.NET Framework 4.5中引入了async/await关键字,尽管.NET Framework 4.5运行在比它早3年的相同.NET 4.0 CLR上-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还使用它从操作系统请求的线程填充默认线程池,并立即挂起这些线程(如果操作系统不自己挂起它们-我忘记了这些细节)。
    1.然后,CLR选择一个线程作为主线程,另一个线程作为GC线程(还有更多的细节,我想它甚至可能使用OS提供的CLR入口点线程--我不确定这些细节)。
  3. Thread0然后将Console.WriteLine(" Fun With Async ===>");作为正常方法调用运行。
    1.然后Thread0调用DoWorkAsync(),同样作为正常的方法调用
  • Thread0(在DoWorkAsync内部)然后调用Task.Run,将一个委托(函数指针)传递给BlockingJob

  • 记住Task.Run是“schedule(not immediately-run)this delegate在线程池中的一个线程上作为一个概念性的“job”,并立即返回一个Task<T>来表示该job的状态”的简写。

  • 例如,如果在调用Task.Run时线程池耗尽或忙碌,则BlockingJob将根本不会运行,直到线程返回池中,或者如果您手动增加池的大小。

  • 然后,Thread0会立即获得一个Task<String>,表示BlockingJob的生存期和完成时间。请注意,此时BlockingJob方法可能已经运行,也可能尚未运行,因为这完全取决于您的调度程序。

  • Thread0然后遇到BlockingJob的Job的Task<String>的第一个await

  • 此时,DoWorkAsyncactualCIL(MSIL)包含一个有效的return语句,该语句会导致 * 真实的 * 执行返回到Main,然后立即返回到线程池,并让.NET Java调度程序开始担心调度问题。

  • 这就是它变得复杂的地方:)

  • 因此,当Thread0返回到线程池时,BlockingJob可能会被调用,也可能不会被调用,这取决于您的计算机设置和环境(例如,如果您的计算机只有一个CPU核心,情况就会有所不同-但也有许多其他情况!)。
    *完全有可能Task.RunBlockingJob作业放入调度程序,然后直到Thread0本身返回到线程池才实际运行它,然后调度程序在Thread0上运行BlockingJob,整个程序只使用单线程。

  • 但是Task.Run也有可能立即在另一个池线程上运行BlockingJob(在这个简单的程序中很可能就是这种情况)。

  • 现在,假设Thread0已经屈服于线程池,而Task.Run使用了线程池中的另一个线程(Thread1)对于BlockingJob,则Thread0将被挂起,因为没有其他计划的继续(来自awaitContinueWith)或计划的线程池作业(来自Task.Run或手动使用ThreadPool.QueueUserWorkItem)。

  • (记住,挂起的线程和阻塞的线程不是一回事!-参见脚注1)

  • 所以Thread1正在运行BlockingJob,它在这5秒内休眠(阻塞),因为Thread.Sleep阻塞,这就是为什么你应该总是在async代码中首选Task.Delay,因为它不会阻塞!)。

  • 在这5秒之后,Thread1然后解除阻塞并从BlockingJob调用返回"Done with work!"-它将该值返回到Task.Run的内部调度程序的调用站点,调度程序将BlockingJob作业标记为完成,并将"Done with work!"作为结果值(这由Task<String>.Result值表示)。

  • Thread1然后返回到线程池。

  • Thread0返回到池中时,调度程序知道DoWorkAsync中的Task<String>上存在await,该await在之前的步骤8中被Thread0使用。

  • 因为Task<String>现在已经完成,所以它从线程池中挑选另一个线程(可能是也可能不是Thread0-可能是Thread1或另一个不同的线程Thread2-同样,这取决于您的程序,您的计算机,等-但最重要的是,它取决于同步上下文,如果你使用ConfigureAwait(true)ConfigureAwait(false))。

  • 在没有同步上下文的普通控制台程序中(即notWinForms,WPF或ASP.NET(但不是ASP.NET Core)),调度程序将使用池中的任何线程(即没有 * 线程关联 *)。让我们称之为Thread2

  • (这里我需要离题解释一下,虽然async Task<String> DoWorkAsync方法在C#源代码中是一个单一的方法,但在内部,DoWorkAsync方法在每个await语句中被拆分为“子方法”,每个“子方法”可以直接输入)。

  • (它们不是“子方法”,但实际上整个方法被重写为一个隐藏的状态机struct,它捕获本地函数状态。参见脚注2)。

  • 因此,现在调度程序告诉Thread2调用DoWorkAsync“子方法”,该方法对应于紧接着await之后的逻辑。

  • 请记住,调度程序知道Task<String>.Result"Done with work!",因此它将String value设置为该字符串。

  • Thread2调用的DoWorkAsync子方法也返回String value,但不返回Main,然后调度程序将该字符串值传递回Main中的await messageTask,然后选择另一个线程(或同一个线程)进入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;
}


编译器将生成CIL(MSIL),它在概念上对应于这个C#(即,如果它是在没有asyncawait关键字的情况下编写的)。
(This代码省略了很多细节,如异常处理、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;
        }
    }
}


请注意,由于性能原因,FoobarAsyncStatestruct而不是class,我不会讨论这个问题。

xytpbqjk

xytpbqjk2#

当你使用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等)的相同死锁场景的影响。
1.没有任何内置的解决方案,所以你必须寻找第三方的解决方案。就像Stephen Cleary的Nito.AsyncEx.Context软件包中的AsyncContext。请参阅这里的示例。
因此,当你考虑到替代方案的复杂性时,浪费RAM的1 MB的价格变得很便宜!
还有另一种方法,它可以更好地利用主线程。这是为了避免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();
}

相关问题