使用 TPL ,我们有CancellationTokenSource
提供令牌,可用于以协作方式取消当前任务(或其开始)。
将取消请求传播到所有已挂起的正在运行的任务需要多长时间?
可以在任何地方查看代码以检查:“从现在开始”每个有兴趣的Task
都会发现已请求取消吗?
我想进行稳定的单元测试,以表明取消功能在我们的代码中有效。
我们有产生任务的“执行程序”,这些任务包装了一些长时间运行的动作。执行程序的主要工作是限制启动了多少个并发动作。所有这些任务都可以单独取消,而且这些操作也会在内部遵守CancellationToken
。
我想提供单元测试,它表明当任务等待插槽开始执行给定动作时发生取消时,该任务将自行取消(最终)并且不会开始执行给定操作。
因此,想法是用单个 slot 准备LimitingExecutor
。然后开始阻止操作,该操作将在取消阻止时请求取消。然后“加入” 测试动作,该动作在执行时会失败。使用该设置,测试将调用 unblock ,然后断言 test action 的任务将在等待时抛出TaskCanceledException
。
[Test]
public void RequestPropagationTest()
{
using (var setupEvent = new ManualResetEvent(initialState: false))
using (var cancellation = new CancellationTokenSource())
using (var executor = new LimitingExecutor())
{
// System-state setup action:
var cancellingTask = executor.Do(() =>
{
setupEvent.WaitOne();
cancellation.Cancel();
}, CancellationToken.None);
// Main work action:
var actionTask = executor.Do(() =>
{
throw new InvalidOperationException(
"This action should be cancelled!");
}, cancellation.Token);
// Let's wait until this `Task` starts, so it will got opportunity
// to cancel itself, and expected later exception will not come
// from just starting that action by `Task.Run` with token:
while (actionTask.Status < TaskStatus.Running)
Thread.Sleep(millisecondsTimeout: 1);
// Let's unblock slot in Executor for the 'main work action'
// by finalizing the 'system-state setup action' which will
// finally request "global" cancellation:
setupEvent.Set();
Assert.DoesNotThrowAsync(
async () => await cancellingTask);
Assert.ThrowsAsync<TaskCanceledException>(
async () => await actionTask);
}
}
public class LimitingExecutor : IDisposable
{
private const int UpperLimit = 1;
private readonly Semaphore _semaphore
= new Semaphore(UpperLimit, UpperLimit);
public Task Do(Action work, CancellationToken token)
=> Task.Run(() =>
{
_semaphore.WaitOne();
try
{
token.ThrowIfCancellationRequested();
work();
}
finally
{
_semaphore.Release();
}
}, token);
public void Dispose()
=> _semaphore.Dispose();
}
可以通过GitHub找到该问题的可执行演示(通过NUnit)。
但是,在我的机器上,该测试的实施有时会失败(没有预期的TaskCanceledException
),大概是十分之一。解决此问题的一种方法是在取消请求之后立即插入Thread.Sleep
。即使睡眠3秒钟,该测试有时仍会失败(运行20次后才能发现),并且通过测试后,通常无需长时间等待(我想)。作为参考,请参阅diff。
“其他问题”是为了确保取消来自“等待时间”而不是来自Task.Run
,因为ThreadPool
可能很忙(其他正在执行的测试),并且它会推迟第二秒的开始取消请求后执行的任务-该测试将变为“绿色”。 “通过黑客轻松解决”是主动等待第二个任务开始-它的Status
变成TaskStatus.Running
。请检查此branch下的版本,看看没有此hack的测试有时会是“绿色”-因此示例错误可能会通过它。
答案 0 :(得分:0)
您的测试方法假定cancellingTask
始终在LimitingExecutor
之前占用actionTask
中的插槽(输入信号量)。不幸的是,这个假设是错误的,LimitingExecutor
不能保证这一点,只是运气,这两个任务中的哪一个占用了插槽(实际上,在我的计算机上,它仅在5%的运行次数中发生)。 / p>
要解决此问题,您需要另一个ManualResetEvent
,这将允许主线程等待cancellingTask
实际占用插槽:
using (var slotTaken = new ManualResetEvent(initialState: false))
using (var setupEvent = new ManualResetEvent(initialState: false))
using (var cancellation = new CancellationTokenSource())
using (var executor = new LimitingExecutor())
{
// System-state setup action:
var cancellingTask = executor.Do(() =>
{
// This is called from inside the semaphore, so it's
// certain that this task occupies the only available slot.
slotTaken.Set();
setupEvent.WaitOne();
cancellation.Cancel();
}, CancellationToken.None);
// Wait until cancellingTask takes the slot
slotTaken.WaitOne();
// Now it's guaranteed that cancellingTask takes the slot, not the actionTask
// ...
}
.NET Framework 不提供用于检测任务转换到Running
状态的API,因此,如果您不喜欢轮询State
属性+ {{1 }},您可能需要修改Thread.Sleep()
来提供此信息,可能使用另一个LimitingExecutor.Do()
,例如:
ManualResetEvent