我正在尝试创建一个 Task 的子类,它会自行取消并等待它被释放。 在单元测试时,我遇到了奇怪的失败测试用例。最终归结为重复。我什至可以多次运行相同的测试,并且仅在第一次运行中获得通过。
这是我的 AutoCancelTask 类的代码(抱歉代码相当长......我试图制作一个可以复制粘贴的最小功能示例......):
using System;
using System.Threading.Tasks;
using System.Threading;
namespace Tests
{
/// <summary>
/// A Task that is cancelled and waited for, when disposed.
/// </summary>
public class AutoCancelTask : Task
{
private readonly CancellationTokenSource _cts;
private AutoCancelTask(Action<CancellationToken> action, CancellationTokenSource cts, TaskCreationOptions options)
: base(() => action(cts.Token), cts.Token, options)
{
_cts = cts;
}
/// <summary>
/// Wrap a cancellable Action in a Task that is automatically cancelled and waited for when disposed.
/// </summary>
/// <param name="action">The Action to wrap.</param>
/// <param name="options">The TaskCreationOptions to use.</param>
/// <returns>The started task.</returns>
public static Task Run(Action<CancellationToken> action, TaskCreationOptions options = TaskCreationOptions.None)
{
Task t = new AutoCancelTask(action, new CancellationTokenSource(), options);
t.Start();
return t;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
try
{
_cts.Cancel();
}
catch (ObjectDisposedException)
{
// nothing to do in this case
}
try
{
Wait();
}
catch (ObjectDisposedException) { }
catch (AggregateException ex)
{
if (1 < ex.InnerExceptions.Count || false == (ex.InnerExceptions[0] is TaskCanceledException))
{
// rethrow if the aggregate does not contain a single TaskCancelledException
throw ex;
}
}
_cts.Dispose();
}
base.Dispose(disposing);
}
}
}
这是我的测试课
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;
namespace Tests
{
public class TestDataWrapper
{
internal bool? IsCancelled { get; private set; } = null;
internal void Action(CancellationToken cancel)
{
IsCancelled = Task.Delay(100, cancel).ContinueWith(t => t.IsCanceled).Result;
}
}
[TestClass()]
public class Tests
{
public static IEnumerable<object[]> Datas { get; } =
Enumerable.Range(0, 5).Select(_ => new object[] { new TestDataWrapper() });
[DataTestMethod]
[DynamicData(nameof(Datas))]
public void AutoCancelTask_Cancels(TestDataWrapper data)
{
using (AutoCancelTask.Run(data.Action)) { }
Assert.AreEqual(true, data.IsCancelled);
}
}
}
显然,我期望测试用例能够通过。但是,只有第一个执行通过,而其他执行失败并显示消息
Assert.AreEqual failed. Expected:<True>. Actual:<(null)>.
。
有趣的是,执行时间也有所不同: 运行 1:~30 毫秒 运行 2:~300 毫秒 运行 4-5: <1ms
由于结果为空且不为假,我怀疑问题出在任务中的某个位置,甚至在操作开始之前就被取消了。然而,这让我感到困惑,因为我没有使用任何静态的或在测试运行之间共享的东西。
为了确保我正确理解
ContinueWith
表达式,我尝试将 Action
方法替换为以下内容:
internal void Action(CancellationToken cancel)
{
Task t = Task.Delay(100, cancel);
try
{
t.Wait();
}
catch
{
// ignore
}
IsCancelled = t.IsCanceled;
t.Dispose();
}
这会产生相同的结果。
然后我尝试用调试输出向我的班级发送垃圾邮件,突然间我所有的测试运行都通过了。当我在此处的 Dispose 方法中添加延迟时,它也有效:
if (disposing)
{
try
{
Delay(1).Wait(); // <-- this fixes it
_cts.Cancel();
}
catch (ObjectDisposedException)
{
// nothing to do in this case
}
}
这更加坚定了我的怀疑,即后续任务不能在另一个任务取消后太快开始......
我宁愿避免那里的延迟,因为这对我来说似乎不正确。你能为我解释一下这里发生了什么吗?
您的任务很可能在有机会运行之前就被取消了。开始任务 会将其放入队列中以供稍后执行。在此测试中,您将在创建任务后立即取消任务。
因此,当任务被拿起执行时,会检查其取消标记,如果取消,则直接将其设置为已取消状态。所以
data.Action
永远不会运行,并且你的标志永远不会设置。添加延迟将为框架提供足够的时间来开始执行任务。
请注意,这种方法似乎很容易出现死锁。如果您在 UI 线程上使用此 AutoCancelTask,并且该任务需要将任何内容分派到同一 UI 线程,则会出现死锁。 该任务无法完成,因为它等待 UI 线程,并且 UI 线程无法继续,因为它正在等待该任务。