Await 与 Wait 的类比以及 TaskCompletionSource





5.00/5 (12投票s)
通过类比解释同步等待和异步 await 之间的区别
引言
本文通过类比解释了同步等待和异步 await 之间的区别。然后,我将解释在使用错误类型的等待时,使用 TaskCompletionSource
会出现哪些问题。
同步等待
示例
Task t = Task.Run(() => Foo());
t.Wait();
类比
我从家出发,开车来到一个路障前。我留下一张字条给我在等待的工人,请他在看到我的字条时叫醒我。然后我打个盹。
过了一会儿,我在等待的工人看到了我的字条。他升起栅栏,敲了敲我的车窗叫醒我,然后我继续开车前进。工人继续他的工作。
代码示例
static void Main(string[] args)
{
Task t = Task.Run(() => Foo());
t.Wait();
Console.WriteLine("Finished waiting");
}
static void Foo()
{
Thread.Sleep(3000);
}
异步等待 - 在任何线程上继续
示例
Task t = FooAsync();
await t.ConfigureAwait(false);
ConfigureAwait(false)
意味着 await
之后的代码可以在后台线程池线程上运行。
类比
我开车来到一个路障前。我留下一张字条给我在等待的工人,请他帮我完成我的工作。然后我转身回家,我可以做些别的家务。
过了一会儿,我在等待的工人来了,看到了我的字条。他预料到这种字条,他把字条给了等候在池子里的 Fido(一只狗)。Fido 帮我完成了工作。工人继续上路。
代码示例
static async Task Main(string[] args)
{
Task t = FooAsync();
await t.ConfigureAwait(false);
Console.WriteLine("Finished awaiting");
}
private async static Task FooAsync()
{
await Task.Delay(3000);
}
异步等待 - 在原始线程上继续
示例
请注意,在这个例子中,我们没有指定 .ConfigureAwait(false)
。通常,这意味着我们在 GUI 线程上,并且我们需要在 GUI 线程上继续。
Task t = FooAsync();
await t;
类比
我开车来到一个路障前。我留下一张字条给我在等待的工人,请他在我可以继续我的工作时通知我。然后我转身回家,我可以做些别的家务。
过了一会儿,我在等待的工人来了,看到了我的字条,然后把字条放在我的邮箱里,告诉我继续我开始的工作。工人继续上路。
我最终去邮箱里查看,找到了指示我完成我开始的工作的字条。我回到我之前的位置,继续我之前的道路,完成我的工作。
代码示例
static async Task Main(string[] args)
{
Task t = FooAsync();
await t;
Console.WriteLine("Finished waiting");
}
如何出错
想象一下,如果我在等待的工人期望处理同步等待,但我却对他进行了 await
操作,会发生什么。
我开车来到一个路障前。我留下一张字条给我在等待的工人,请他帮我完成我的工作。然后我转身回家。
过了一会儿,一个同步工人来了。这个同步工人期望收到一个字条,上面写着“敲我的车窗叫醒我”。这是一个快速的操作,只需要他一点时间,他很乐意这样做然后就上路了。然而,他收到的字条上写着“帮我完成我的工作”。突然,我们可怜的同步工人被命令完成我的工作!他会本分地升起栅栏,沿着路前进完成我的工作。谁知道他刚刚陷入了一个多么耗时的任务?他可能离开一小会儿,也可能离开很长时间,他甚至可能 *永远* 不回来!谁知道呢?这完全取决于我的工作的其余部分。除非他完成我的工作,否则他无法返回继续他的工作,或者遇到另一个 await
允许他返回。如果我的工作的其余部分是一个永远不会结束的任务,他将 *永远* 不会回来。如果有人决定需要等待这位现在可能永远不会返回的工人,这可能导致死锁。
等待一个同步的 TaskCompletionSource
上述情况发生在我们创建了一个同步的 TaskCompletionSource
,然后又对其进行 await
操作的时候。同步的 TaskCompletionSource
期望的是调用其任务的 Wait()
。Wait()
就像我留下一张字条,请求敲车窗叫醒我以便我继续;而 await
就像我留下一张字条,让工人完成我的工作。如果我 await 一个同步的 TaskCompletionSource
并使用 ConfigureAwait(false)
,我就是留下一张字条让工人完成我的工作,这是他没有预料到的,而且可能需要很长时间。
代码示例
下面是一个完整的程序,我们将对其进行深入分析(虽然有些牵强,但它展示了问题)。
static async Task Main(string[] args)
{
TaskCompletionSource<int> tcs = new TaskCompletionSource<int>();
Task.Run( () =>
{
DoWork();
tcs.SetResult(5);
MoreWork();
});
await tcs.Task;
int result = tcs.Task.Result;
}
private static void DoWork()
{
Thread.Sleep(3000);
}
private static void MoreWork()
{
int j = 2000;
for (int i = 0; i < j; ++i)
{
int x = i;
}
int y = 1;
}
由于 Main()
是一个异步任务,编译器会创建一些隐藏的代码来包装 Main()
,这些代码处理调用 Main()
的 async
部分。这段包装代码大致如下(这就是 await
在控制台程序中的底层工作方式)。
Static void RealMain()
{
private volatile object m_lock = new Object();
Task t = Main();
t.continuationObject = new Action(() =>
lock (m_lock)
{
Monitor.PulseAll(m_lock));
});
lock (m_lock)
{
Monitor.Wait(m_lock);
}
if (!t.IsRanToCompletion) ThrowForNonSuccess(t);
return;
}
[参见 await 关键字工作原理详解。在那篇文章中,包装代码被称为 Main()
,它调用 SubMain()
。在这里,我们的包装代码被称为 RealMain()
,它调用 Main()
。]
现在我们将追踪上述程序运行时执行的路径。下面的图表起初看起来有点复杂,但不用担心,我们将逐一检查下面的 10 个步骤。
-
程序开始调用
Main()
Task t = Main();
-
Main()
创建一个同步的TaskCompletionSource
,然后调用Task.Run()
来创建一个后台任务。接着调用await tcs.Task
TaskCompletionSource tcs = new TaskCompletionSource(); Task.Run( () => { DoWork(); tcs.SetResult(5); MoreWork(); }); await tcs.Task;
-
tcs.Task
返回一个未完成的任务t
给调用者。 -
调用者设置一个 continuation
Action()
,该操作将在任务t
完成时调用。然后调用者调用Monitor.Wait(m_lock)
,主线程等待其他人调用Monitor.PulseAll(m_lock)
来释放它。t.continuationObject = new Action(() => lock (m_lock) { Monitor.PulseAll(m_lock)); }); lock (m_lock) { Monitor.Wait(m_lock);
-
与此同时,我们的后台
Task.Run()
任务调用DoWork()
。当DoWork()
返回后,后台线程调用tcs.SetResult(5)
。DoWork(); tcs.SetResult(5);
-
tcs.SetResult(5)
将数字 5 放入tcs.m_result
的 *私有* 字段中。tcs.Task
的状态被设置为完成,并且任何 continuation actions 都会被调用。我们确实有一个 continuation action,它是在第 2 步末尾的await tcs.Task
中设置的。这时,同步的TaskCompletionSource tcs
期望 continuation action 是“请敲车窗叫醒司机,以便司机继续前行。”。然而,tcs
发现的字条写着“帮我完成工作。”。后台任务跳转到await tcs.Task
后面的代码行,并运行int result = tcs.Task.Result
,这将数字 5 从tcs.m_result
复制到局部变量result
。int result = tcs.Task.Result; }
-
运行
int result = tcs.Task.Result
后,后台线程到达任务Main()
的末尾。任务Main()
现在已完成。将任务设置为完成的一部分是调用为任务t
设置的任何 continuationAction()
。在第 4 步中设置了一个 continuationAction()
,即调用Monitor.PulseAll(m_lock)
。lock (m_lock) { Monitor.PulseAll(m_lock); });
调用
Monitor.PulseAll(m_lock)
会释放调用Main()
并正在Monitor.Wait(m_lock)
处等待的线程。 -
后台线程在完成了意料之外的“帮我完成工作”的任务后,现在继续做自己的工作,并调用
MoreWork()
。MoreWork();
与此同时,我们最初开始的线程恢复运行。它检查任务是否成功完成,如果没有,它会调用
ThrowForNonSuccess(t)
。如果任务成功完成,它就调用return
。if (!t.IsRanToCompletion) ThrowForNonSuccess(t); return;
-
现在我们遇到了一个有趣的竞态条件:后台线程终于返回继续处理自己的任务
MoreWork()
,与此同时,主线程被唤醒并调用return
。从RealMain()
返回意味着程序已经运行完毕。然后操作系统开始处理进程本身,包括后台线程。 -
后台线程开始处理
MoreWork()
,几秒钟后,死神来了,结束了它的生命,因为程序RealMain()
已经结束了。MoreWork(); → ☠
在上面的程序中,你可以尝试在行 y = 1
处设置一个断点,并改变 int j = 2000
的值。如果 `j` 的值太高,断点将永远不会被命中,因为进程在循环完成之前就被终止了。(即使使用 `j` 的值为 0
,断点也可能永远不会被命中。这完全取决于另一个线程终止进程的速度。)
如何避免这种情况
在源代码中搜索
new TaskCompletionSource
一个同步的 TaskCompletionSource
通常没有参数,看起来像这样
var tcs = new TaskCompletionSource<int>();
一个异步的 TaskCompletionSource
将带有参数 TaskCreationOptions.RunContinuationsAsynchronously
,像这样
var tcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
现在找出 tcs.Task
的使用位置,并确定它是使用 Wait()
还是 await
。确保 *等待* 的类型与创建的 TaskCompletionSource
的类型匹配。
我们不希望看到同步的 TaskCompletionSource
与
await tcs.Task;
这是我们上面分析的例子。
在变量名中添加“sync”或“async”
另一种使其清晰的方法是重命名变量,以便名称包含这是同步还是异步 TaskCompletionSource
。例如
var syncTcs = new TaskCompletionSource();
Task syncTask = syncTcs.Task;
...
await syncTask;
糟糕,我们正在 await 一个同步的 Task
。最好修复它。
同样的问题也可能发生在反方向
var asyncTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Task asyncTask = asyncTcs.Task;
...
asyncTask.Wait();
糟糕,我们正在等待一个异步 Task
。最好修复它。
对异步任务调用 Wait()
的隐藏问题在于,我们占用了一个线程来等待,并且我们需要 *另一个* 线程在我们等待时来完成这项工作。有可能出现所有可用线程都忙于等待异步任务,而没有更多线程可用去完成任何任务的情况。(有关更多信息,请参阅 线程池耗尽)。
唯一一个虽然错误但却能正常工作的案例
唯一一个可以侥幸 await 同步 TaskCompletionSource
的情况是,当被 await 的后台任务之后再也没有其他事情要做的时候。工人正准备回家,他做的最后一件事是查看我留下的字条。他本以为只是敲敲我的车窗叫醒我然后就回家了;结果他却被卷入了完成我的工作。这没关系,因为他之后已经没有别的事情可做了。(仍然存在别人在等待这位工人完成他的工作的可能性。这仍然是错误的,应该被修复。)
参考文献
历史
- 2023年11月11日:初始版本