我正在使用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()
本身在这里调用了另一个await
ed方法,当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!";
}
这是发生了什么:
CLR会加载您的程序集并找到Main
入口点。
CLR还使用从操作系统请求的线程来填充默认线程池,它会立即挂起这些线程(如果操作系统本身不挂起它们,我会忘记那些细节)。
然后,CLR选择一个线程用作主线程,并选择另一个线程作为GC线程(对此还有更多详细信息,我认为它甚至可能使用OS提供的主CLR入口点线程-我不确定这些细节)。我们称这个为Thread0
。
Thread0
然后Console.WriteLine(" Fun With Async ===>");
作为常规方法调用运行。
Thread0
然后将调用DoWorkAsync()
也作为常规方法调用。
Thread0
(inside DoWorkAsync
)然后调用Task.Run
,将委托(函数指针)传递给BlockingJob
。
Task.Run
是“在线程池中的线程上计划(不是立即运行)此委托作为概念上的“作业”,并立即返回aTask<T>
来代表该作业的状态”的简写形式。
Task.Run
调用时线程池已耗尽或繁忙,则BlockingJob
直到线程返回池时,或者如果您手动增加池的大小,线程池才会运行。Thread0
然后立即给出一个Task<String>
代表生命周期和完成时间的BlockingJob
。请注意,此时BlockingJob
方法可能尚未运行,因为这完全取决于调度程序。
Thread0
然后遇到第一await
对BlockingJob
的工作的Task<String>
。
DoWorkAsync
包含一个有效的return
语句,该语句使实际执行返回Main
,然后立即返回线程池,并让.NET异步调度程序开始担心调度。
因此,当Thread0
返回线程池时,BlockingJob
可能会调用或可能不会调用它,具体取决于您的计算机设置和环境(例如,如果您的计算机只有1个CPU内核,则情况会有所不同-但也有很多其他事情!)。
Task.Run
把BlockingJob
作业放到调度,然后直到不可不实际运行它Thread0
自己返回线程池,然后调度运行BlockingJob
上Thread0
,整个程序只使用一个线程。Task.Run
运行BlockingJob
(这在这个琐碎的程序中可能是这种情况)。现在,假设Thread0
已屈服于池并Task.Run
在线程池(Thread1
)中使用了其他线程作为BlockingJob
,Thread0
则将被挂起,因为没有其他预定的继续(来自await
或ContinueWith
),也没有预定的线程池作业(来自Task.Run
或手动使用)ThreadPool.QueueUserWorkItem
)。
Thread1
正在运行BlockingJob
和它睡觉(块)这5秒钟,因为Thread.Sleep
块这就是为什么你应该总是喜欢Task.Delay
在async
代码,因为它不会阻止!)。Thread1
之后,然后取消阻塞并"Done with work!"
从该BlockingJob
调用返回-它将值返回到Task.Run
内部调度程序的调用站点,调度程序将BlockingJob
作业标记为完成,并带有"Done with work!"
结果值(由Task<String>.Result
值表示)。Thread1
然后返回到线程池。await
存在一个,该资源先前已在步骤8中返回到池时由先前在步骤8中使用。Task<String>
DoWorkAsync
Thread0
Thread0
Task<String>
现在已完成,因此它将从线程池中选择另一个线程(可能是或可能不是Thread0
-它可能是Thread1
或另一个线程Thread2
-再次,这取决于您的程序,计算机等-但最重要的是,取决于同步上下文以及是否使用ConfigureAwait(true)
或ConfigureAwait(false)
。
Thread2
。(我需要在这里解释一下,尽管您的async Task<String> DoWorkAsync
方法是C#源代码中的单个方法,但是在内部,该DoWorkAsync
方法在每条await
语句中均分为“子方法” ,并且每个“子方法”都可以输入直)。
struct
捕获局部函数状态的隐藏状态机。请参见脚注2)。因此,现在调度程序告诉Thread2
调用DoWorkAsync
紧随其后的逻辑的“子方法” await
。在这种情况下,这就是String value = await threadTask;
线。
Task<String>.Result
is是"Done with work!"
,因此将其设置String value
为该字符串。所述DoWorkAsync
子方法,该方法Thread2
称为-成然后也返回String value
-但不是Main
,但右回调度-然后调度器通过该字符串值返回到Task<String>
用于await messageTask
在Main
,然后拾取另一个线程(或相同的线程)到enter-intoMain
的子方法表示之后的代码await messageTask
,然后该线程Console.WriteLine( message );
以正常方式调用和其余代码。
请记住,挂起的线程与阻塞的线程不是同一回事:这是一个过分的简化,但是出于此答案的目的,“挂起的线程”具有一个空的调用栈,并且可以由调度程序立即投入使用。做一些有用的事情,而“阻塞线程”具有填充的调用栈,并且调度程序无法触摸它或重新利用它,除非-并且直到它返回线程池为止-请注意,线程可以被“阻塞”,因为它很忙运行普通代码(例如while
循环或自旋锁),因为它被诸如的同步原语阻止Semaphore.WaitOne
,因为它被睡眠Thread.Sleep
,或者因为调试器指示操作系统冻结线程)。
在我的回答中,我说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)(即,如果编写时不带async
andawait
关键字)。
(此代码省略了许多细节,例如异常处理,的实际值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] 删除。
我来说两句