仅使用 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 时,竞争条件确实是可能的。要引入并发,您必须:
- 并发启动多个异步操作,即启动下一个操作而不等待上一个操作完成,并且
- 没有适当的环境同步¹机制,即
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/unsynchronized 和 synchronous/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非常有用并解释了异步编程中的最佳实践。
快乐(异步)编码!
.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 时,竞争条件确实是可能的。要引入并发,您必须:
- 并发启动多个异步操作,即启动下一个操作而不等待上一个操作完成,并且
- 没有适当的环境同步¹机制,即
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/unsynchronized 和 synchronous/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非常有用并解释了异步编程中的最佳实践。
快乐(异步)编码!