我正在用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上运行。
答案 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!";
}
发生了什么事
CLR会加载您的程序集并找到Main
入口点。
CLR还使用从操作系统请求的线程来填充默认线程池,它会立即挂起这些线程(如果操作系统本身不挂起它们,我会忘记那些细节)。
然后CLR选择一个线程用作主线程,另一个线程用作GC线程(对此有更多详细信息,我想它甚至可能使用OS提供的主CLR入口点线程-我是不确定这些细节)。我们将其称为Thread0
。
Thread0
然后作为常规方法调用运行Console.WriteLine(" Fun With Async ===>");
。
Thread0
然后调用DoWorkAsync()
,也作为常规方法调用。
Thread0
(在DoWorkAsync
内部)然后调用Task.Run
,将委托(函数指针)传递给BlockingJob
。
Task.Run
是“ 时间表(不是立即运行)”的简写(作为概念上的“工作”,不立即运行),并立即返回{ {1}}代表该工作的状态”。
Task<T>
时线程池已耗尽或繁忙,那么Task.Run
根本不会运行,直到线程返回池-或者您手动增大大小的泳池。 BlockingJob
一个Thread0
,它代表Task<String>
的生存期和完成时间。请注意,此时BlockingJob
方法可能已经运行尚未运行,因为这完全取决于调度程序。
BlockingJob
然后遇到Thread0
的工作await
的第一个BlockingJob
。
Task<String>
的 actual CIL(MSIL)包含有效的DoWorkAsync
语句,该语句使 real 执行返回到{{1 }},然后立即返回线程池,并让.NET异步调度程序开始担心调度问题。
因此,当return
返回线程池时,可能会或可能不会调用Main
,具体取决于您的计算机设置和环境(如果您的计算机只有1个CPU,情况会有所不同例如核心-还有很多其他东西!)。
Thread0
放入BlockingJob
作业到调度程序中,然后直到Task.Run
本身返回线程池时才真正运行它,然后调度程序在BlockingJob
上运行Thread0
,整个程序仅使用一个线程。BlockingJob
也有可能立即在另一个池线程上运行Thread0
(在这个琐碎的程序中可能就是这种情况)。现在,假设Task.Run
已屈服于池,并且BlockingJob
在线程池(Thread0
)中为Task.Run
使用了另一个线程,则Thread1
将被暂停,因为没有其他预定的继续(来自BlockingJob
或Thread0
)也没有预定的线程池作业(来自await
或手动使用{{1} }。
ContinueWith
正在运行Task.Run
,并且它睡眠(阻塞)了5秒钟,因为ThreadPool.QueueUserWorkItem
被阻塞了,这就是为什么您应该始终在{{1}中选择Thread1
的原因}代码,因为它不会被阻塞!)。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
。
ConfigureAwait(true)
。(我需要在这里解释一下,尽管您的ConfigureAwait(false)
方法在C#源代码中是单个方法,但是在内部Thread2
方法被拆分为“子方法”在每个async Task<String> DoWorkAsync
语句中,每个“子方法”都可以直接输入)。
DoWorkAsync
。请参见脚注2)。因此,现在调度程序告诉await
调用struct
之后的逻辑所对应的Thread2
“子方法”。在这种情况下,它是DoWorkAsync
行。
await
是String value = await threadTask;
,因此它将Task<String>.Result
设置为该字符串。 "Done with work!"
调用的String value
子方法还会返回该DoWorkAsync
-但不返回到Thread2
,而是直接返回到调度程序-然后调度程序将该字符串值传回String value
中Main
的{{1}},然后选择另一个线程(或相同线程)输入Task<String>
'子方法代表await messageTask
之后的代码,然后该线程以正常方式调用Main
和其余代码。
请记住,挂起的线程与阻塞的线程不是同一回事:这是一个过分的简化,但是出于此答案的目的,“挂起的线程”具有一个空的调用栈,可以通过以下方式立即使用:调度程序可以执行有用的操作,而“阻塞线程”具有填充的调用堆栈,并且调度程序无法触摸或重新利用它,除非-并且直到其返回线程池为止-请注意,线程可以被“阻塞”因为它忙于运行正常的代码(例如Main
循环或自旋锁),因为它被await messageTask
之类的同步原语阻止,因为Console.WriteLine( message );
处于睡眠状态或调试器指示操作系统冻结线程)。
在我的回答中,我说C#编译器实际上会将每个while
语句周围的代码编译为“子方法”(实际上是状态机),这就是允许线程( any 线程,而不管其调用栈状态如何)以“恢复”其线程返回线程池的方法。这是这样的:
假设您具有此Semaphore.WaitOne
方法:
Thread.Sleep
编译器将生成在概念上与此C#相对应的CIL(MSIL)(即,如果编写时没有使用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;
}
的实际值,内联async
,捕获await
等,但是这些细节详细信息现在不重要)
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;
}
}
}
。
答案 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
,这还有其他缺点:
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();
}