我强烈建议您阅读 2012 年的这篇博文,当时await
引入了关键字,但它解释了异步代码如何在控制台程序中工作:https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/
然后作者指出
this 关键字 (await) 将始终修改返回 Task 对象的方法。当逻辑流达到await
token,调用线程在此方法中被挂起,直到调用完成。如果您要运行此版本的应用程序,您会发现“已完成”消息显示在“完成工作!”之前。信息。如果这是一个图形应用程序,用户可以继续使用 UI,同时DoWorkAsync()
方法执行”。
作者说得不严谨。
我会改变这个:
当逻辑流达到await
token,调用线程在此方法中被挂起,直到调用完成
To this:
当逻辑流达到await
令牌(即after DoWorkAsync
返回一个Task
对象),函数的本地状态保存在内存中的某个地方,并且正在运行的线程执行return
回到异步调度程序(即线程池)。
我的观点是await
不会导致线程“挂起”(也不会导致线程阻塞)。
下一句也是有问题的:
如果您要运行此版本的应用程序,您会发现“已完成”消息显示在“完成工作!”之前。信息
(我假设作者所说的“这个版本”指的是语法上相同但省略了的版本await
关键词)。
所提出的主张是不正确的。被调用的方法DoWorkAsync
仍然返回一个Task<String>
这不可能是有意义地传递给Console.WriteLine
: 返回的Task<String>
必须是awaited
first.
浏览有关 async/await 如何工作的各种在线文档和文章,我认为“await”会起作用,例如当遇到第一个“await”时,程序检查该方法是否已经完成,如果没有,它会立即“ return”到调用方法,然后在可等待任务完成后返回。
你的想法大体上是正确的。
但如果调用方法是Main()本身,那么它返回给谁呢?它只是等待等待完成吗?这就是代码表现如此的原因(在打印“Completed”之前等待 5 秒)吗?
它返回到由 CLR 维护的默认线程池。每个CLR程序都有一个线程池,这就是为什么即使是最微不足道的 .NET 程序进程也会出现在 Windows 任务管理器中,其线程数在 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 上,而 .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 线程(这还有更多细节,我认为它甚至可能使用主操作系统提供的 CLR 入口点线程 - 我不确定这些细节)。我们称之为Thread0
.
-
Thread0
然后运行Console.WriteLine(" Fun With Async ===>");
作为正常的方法调用。
-
Thread0
然后打电话DoWorkAsync()
也作为普通的方法调用.
-
Thread0
(里面DoWorkAsync
)然后调用Task.Run
,将委托(函数指针)传递给BlockingJob
.
- Remember that
Task.Run
is shorthand for "schedule (not immediately-run) this delegate on a thread in the thread-pool as a conceptual "job", and immediately return a Task<T>
to represent the status of that job".
- 例如,如果线程池耗尽或繁忙时
Task.Run
然后被称为BlockingJob
在线程返回到池之前,或者如果您手动增加池的大小,则根本不会运行。
-
Thread0
然后立即给出Task<String>
代表生命周期和完成BlockingJob
。请注意,此时BlockingJob
method 可以不可以尚未运行,因为这完全取决于您的调度程序。
-
Thread0
然后遇到第一个await
for BlockingJob
的职位Task<String>
.
- At this point actual CIL (MSIL) for
DoWorkAsync
contains an effective return
statement which causes real execution to return to Main
, where it then immediately returns to the thread-pool and lets the .NET async scheduler start worrying about scheduling.
-
So when Thread0
返回线程池,BlockingJob
可能会也可能不会被调用,具体取决于您的计算机设置和环境(例如,如果您的计算机只有 1 个 CPU 核心,情况会有所不同 - 但还有许多其他情况!)。
-
这是完全有可能的 that
Task.Run
放在BlockingJob
作业进入调度程序,然后才实际运行它Thread0
本身返回到线程池,然后调度程序运行BlockingJob
on Thread0
并且整个程序只使用了单线程。
- 但也有可能
Task.Run
会跑BlockingJob
立即在另一个池线程上(这就是这个简单程序中可能的情况)。
-
现在,假设Thread0
已屈服于池并且Task.Run
在线程池中使用了不同的线程(Thread1
) for BlockingJob
, then Thread0
将被暂停,因为没有其他计划的延续(从await
or ContinueWith
)也不是预定的线程池作业(来自Task.Run
或手动使用ThreadPool.QueueUserWorkItem
).
- (请记住,挂起的线程与阻塞的线程不同! - 请参阅脚注 1)
- So
Thread1
在跑BlockingJob
它会休眠(阻塞)这 5 秒,因为Thread.Sleep
块,这就是为什么你应该总是喜欢Task.Delay
in async
代码,因为它不会阻塞!)。
- 那5秒之后
Thread1
然后解锁并返回"Done with work!"
从那BlockingJob
调用 - 并将该值返回给Task.Run
的内部调度程序的调用站点和调度程序标记BlockingJob
工作已完成"Done with work!"
作为结果值(这由Task<String>.Result
value).
-
Thread1
然后返回到线程池。
- 调度程序知道有一个
await
存在于那个之上Task<String>
inside DoWorkAsync
被使用过Thread0
之前在步骤 8 中时Thread0
回到了水池。
- So because that
Task<String>
is now completed, it picks out another thread from the thread-pool (which may or may not be Thread0
- it could be Thread1
or another different thread Thread2
- again, it depends on your program, your computer, etc - but most importantly it depends on the synchronization-context and if you used ConfigureAwait(true)
or ConfigureAwait(false)
).
- 在没有同步上下文的简单控制台程序中(即notWinForms、WPF 或 ASP.NET(但不是 ASP.NET Core)),那么调度程序将使用any池中的线程(即没有线程亲和力)。我们称之为
Thread2
.
-
(我需要在这里离题解释一下,虽然你的async Task<String> DoWorkAsync
method 是 C# 源代码中的单个方法,但在内部DoWorkAsync
每个方法被分成“子方法”await
语句,每个“子方法”都可以直接进入)。
- (它们不是“子方法”,但实际上整个方法被重写为隐藏的状态机
struct
捕获本地函数状态。参见脚注 2)。
-
所以现在调度程序告诉Thread2
呼叫进入DoWorkAsync
与紧随其后的逻辑相对应的“子方法”await
。在这种情况下,它是String value = await threadTask;
line.
- 请记住,调度程序知道
Task<String>.Result
is "Done with work!"
,所以它设置String value
到那个字符串。
-
The DoWorkAsync
子方法Thread2
被调用的 then 也返回String value
- 但不是Main
,但是直接返回到调度程序 - 然后调度程序将该字符串值传递回Task<String>
为了await messageTask
in Main
然后选择另一个线程(或同一个线程)进入Main
的子方法,代表后面的代码await messageTask
,然后该线程调用Console.WriteLine( message );
以及正常方式的其余代码。
脚注
Footnote 1
请记住,挂起的线程与阻塞的线程不同:这是过于简单化的说法,但就本答案而言,“挂起的线程”具有空的调用堆栈,并且可以立即由调度程序投入工作做一些有用的事情,而“阻塞线程”有一个填充的调用堆栈,调度程序无法触及它或重新调整它的用途,除非它返回到线程池 - 请注意,线程可能会被“阻塞”,因为它很忙运行普通代码(例如while
循环或自旋锁),因为它被同步原语(例如Semaphore.WaitOne
,因为它正在睡觉Thread.Sleep
,或者因为调试器指示操作系统冻结线程)。
Footnote 2
在我的回答中,我说过 C# 编译器实际上会围绕每个await
语句进入“子方法”(实际上是一个状态机),这就是允许线程(any线程(无论其调用堆栈状态如何)来“恢复”其线程返回到线程池的方法。这是它的工作原理:
假设你有这个async
method:
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
and await
关键词)。
(This code omits lots of details like exception handling, the real values of state
, it inlines AsyncTaskMethodBuilder
, the capture of this
, and so on - but those details aren't important right now)
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
is a struct
而不是一个class
出于性能原因,我不会深入讨论。