仅使用 async/await 时是否存在竞争条件?

Do race conditions exist when using just async/await?

.NET 5.0

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Threading.Tasks;
using System;
using System.Collections.Generic;

namespace AsyncTest
{
    [TestClass]
    public class AsyncTest
    {
        public async Task AppendNewIntVal(List<int> intVals)
        {
            await Task.Delay(new Random().Next(15, 45));

            intVals.Add(new Random().Next());
        }

        public async Task AppendNewIntVal(int count, List<int> intVals)
        {
            var appendNewIntValTasks = new List<Task>();

            for (var a = 0; a < count; a++)
            {
                appendNewIntValTasks.Add(AppendNewIntVal(intVals));
            }

            await Task.WhenAll(appendNewIntValTasks);
        }

        [TestMethod]
        public async Task TestAsyncIntList()
        {
            var appendCount = 30;
            var intVals = new List<int>();

            await AppendNewIntVal(appendCount, intVals);

            Assert.AreEqual(appendCount, intVals.Count);
        }
    }
}

以上代码编译运行,但测试失败,输出类似:

Assert.AreEqual failed. Expected:<30>. Actual:<17>.

在上面的示例中,“实际”值是 17,但在执行过程中会有所不同。

我知道我对异步编程在 .NET 中的工作原理缺乏一些了解,因为我没有得到预期的输出。

据我了解,AppendNewIntVal 方法会启动 N 个任务,然后等待它们全部完成。如果他们都完成了,我希望他们每个人都会在列表中附加一个值,但事实并非如此。看起来存在竞争条件,但我认为这是不可能的,因为代码不是多线程的。我错过了什么?

是的,如果您不立即等待每个可等待对象,即此处:

appendNewIntValTasks.Add(AppendNewIntVal(intVals));

这一行在异步术语中等同于(在基于线程的代码中)Thread.Start,我们现在在内部异步代码周围没有安全性:

intVals.Add(new Random().Next());

当两个流同时调用 Add 时,现在可以以相同的并发方式失败。您可能还应该避免 new Random(),因为 不一定是随机的 (它是基于许多框架版本的时间,并且最终可能导致两个流获得相同的种子) .

所以:显示的代码确实很危险。

明显安全的版本是:

        public async Task AppendNewIntVal(int count, List<int> intVals)
        {
            for (var a = 0; a < count; a++)
            {
                await AppendNewIntVal(intVals);
            }
        }

可能 推迟 await,但您在这样做时明确选择了并发,并且您的代码需要以适当的防御方式处理它。

是的,当以引入并发的方式使用 async/await 时,竞争条件确实是可能的。要引入并发,您必须:

  1. 并发启动多个异步操作,即启动下一个操作而不等待上一个操作完成,并且
  2. 没有适当的环境同步¹机制,即 SynchronizationContext,它将同步异步操作的后续执行。

在您的情况下,两个条件都满足,因此多个线程上的延续 运行 并发。由于 List<T> class is not thread-safe,你会得到未定义的行为。

要查看 SynchronizationContext 在这种情况下的效果,您可以安装 Nito.AsyncEx.Context 软件包并执行以下操作:

[TestMethod]
public void TestAsyncIntList()
{
    AsyncContext.Run(async () =>
    {
        var appendCount = 30;
        var intVals = new List<int>();

        await AppendNewIntVal(appendCount, intVals);

        Assert.AreEqual(appendCount, intVals.Count);
    });
}

仅供参考,许多类型的应用程序在启动时会自动安装 SynchronizationContext(WPF 和 Windows 表单等)。控制台应用程序没有,因此在编写支持异步的控制台应用程序时需要格外小心。

¹ 值得注意的是 synchronized/unsynchronizedsynchronous/asynchronous 大多是无关的。对于不熟悉这些术语的人来说,这可能会造成混淆。第一项是关于防止多个线程并发访问共享资源。第二项是关于在不阻塞线程的情况下做某事。

是的,竞争条件确实存在。

异步方法基本上是tasks that can potentially run in parallel, depending on a task scheduler they are submitted to. The default one is ThreadPoolTaskScheduler, which is a wrapper around ThreadPool。因此,如果您将任务提交给可以并行执行多个任务的调度程序(线程池),您可能会 运行 进入竞争条件。

您可以让您的代码更安全一些:

lock (intVals) intVals.Add(new Random().Next());

但这又打开了另一个蠕虫罐:)

如果您对有关异步编程的更多详细信息感兴趣,请参阅this link. Also this article非常有用并解释了异步编程中的最佳实践。

快乐(异步)编码!