我是通过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,如果这有什么区别的话。
2条答案
按热度按时间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)与您的原始程序相同):
字符串
以下是发生的情况:
Main
进入点。1.然后,CLR选择一个线程作为主线程,另一个线程作为GC线程(还有更多的细节,我想它甚至可能使用OS提供的CLR入口点线程--我不确定这些细节)。
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
。此时,
DoWorkAsync
的actualCIL(MSIL)包含一个有效的return
语句,该语句会导致 * 真实的 * 执行返回到Main
,然后立即返回到线程池,并让.NET Java调度程序开始担心调度问题。这就是它变得复杂的地方:)
因此,当
Thread0
返回到线程池时,BlockingJob
可能会被调用,也可能不会被调用,这取决于您的计算机设置和环境(例如,如果您的计算机只有一个CPU核心,情况就会有所不同-但也有许多其他情况!)。*完全有可能
Task.Run
将BlockingJob
作业放入调度程序,然后直到Thread0
本身返回到线程池才实际运行它,然后调度程序在Thread0
上运行BlockingJob
,整个程序只使用单线程。但是
Task.Run
也有可能立即在另一个池线程上运行BlockingJob
(在这个简单的程序中很可能就是这种情况)。现在,假设
Thread0
已经屈服于线程池,而Task.Run
使用了线程池中的另一个线程(Thread1
)对于BlockingJob
,则Thread0
将被挂起,因为没有其他计划的继续(来自await
或ContinueWith
)或计划的线程池作业(来自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
方法:型
编译器将生成CIL(MSIL),它在概念上对应于这个C#(即,如果它是在没有
async
和await
关键字的情况下编写的)。(This代码省略了很多细节,如异常处理、
state
的真实的值、内联AsyncTaskMethodBuilder
、捕获this
等-但这些细节现在并不重要)型
请注意,由于性能原因,
FoobarAsyncState
是struct
而不是class
,我不会讨论这个问题。xytpbqjk2#
当你使用
static async Task Main(string[] args)
signature时,C#编译器generates在幕后使用了一个MainAsync
方法,实际的Main
方法被重写如下:字符串
这意味着控制台应用程序的主线程,即
ManagedThreadId
等于1
的线程,在未完成任务的第一个await
被命中后立即被阻塞,并在应用程序的整个生命周期内保持阻塞状态!在此之后,应用程序仅在ThreadPool
线程上运行(除非代码显式启动线程)。这是一个线程的浪费,但替代方案是安装一个
SynchronizationContext
到控制台应用程序,这有其他缺点:1.应用程序变得容易受到困扰UI应用程序(Windows窗体,WPF等)的相同死锁场景的影响。
1.没有任何内置的解决方案,所以你必须寻找第三方的解决方案。就像Stephen Cleary的Nito.AsyncEx.Context软件包中的
AsyncContext
。请参阅这里的示例。因此,当你考虑到替代方案的复杂性时,浪费RAM的1 MB的价格变得很便宜!
还有另一种方法,它可以更好地利用主线程。这是为了避免
async Task Main
签名。只需在应用程序的每个主要异步方法之后使用.GetAwaiter().GetResult();
。这样,在方法完成后,您将返回主线程!型