65.9K
CodeProject 正在变化。 阅读更多。
Home

Await 与 Wait 的类比以及 TaskCompletionSource

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2023年11月13日

CPOL

9分钟阅读

viewsIcon

9458

downloadIcon

202

通过类比解释同步等待和异步 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 个步骤。

Code Flow Diagram

  1. 程序开始调用 Main()

    Task t = Main();
  2. Main() 创建一个同步的 TaskCompletionSource,然后调用 Task.Run() 来创建一个后台任务。接着调用 await tcs.Task

    TaskCompletionSource tcs = new TaskCompletionSource();
    Task.Run( () =>
    {
        DoWork();
        tcs.SetResult(5);
        MoreWork();
    });
    
    await tcs.Task;
  3. tcs.Task 返回一个未完成的任务 t 给调用者。

  4. 调用者设置一个 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);
  5. 与此同时,我们的后台 Task.Run() 任务调用 DoWork()。当 DoWork() 返回后,后台线程调用 tcs.SetResult(5)

    DoWork();
    tcs.SetResult(5);
  6. 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;
    }
  7. 运行 int result = tcs.Task.Result 后,后台线程到达任务 Main() 的末尾。任务 Main() 现在已完成。将任务设置为完成的一部分是调用为任务 t 设置的任何 continuation Action()。在第 4 步中设置了一个 continuation Action(),即调用 Monitor.PulseAll(m_lock)

    lock (m_lock)
    {
        Monitor.PulseAll(m_lock);
    });

    调用 Monitor.PulseAll(m_lock) 会释放调用 Main() 并正在 Monitor.Wait(m_lock) 处等待的线程。

  8. 后台线程在完成了意料之外的“帮我完成工作”的任务后,现在继续做自己的工作,并调用 MoreWork()

    MoreWork();

    与此同时,我们最初开始的线程恢复运行。它检查任务是否成功完成,如果没有,它会调用 ThrowForNonSuccess(t)。如果任务成功完成,它就调用 return

    if (!t.IsRanToCompletion) ThrowForNonSuccess(t);
    return;
  9. 现在我们遇到了一个有趣的竞态条件:后台线程终于返回继续处理自己的任务 MoreWork(),与此同时,主线程被唤醒并调用 return。从 RealMain() 返回意味着程序已经运行完毕。然后操作系统开始处理进程本身,包括后台线程。

  10. 后台线程开始处理 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日:初始版本
© . All rights reserved.