【问题标题】:Propagation time of cancellation request to all tasks (TPL)取消请求到所有任务的传播时间 (TPL)
【发布时间】:2019-03-12 00:56:05
【问题描述】:

使用 TPL,我们有 CancellationTokenSource,它提供令牌,对于合作取消当前任务(或其开始)很有用。

问题:

将取消请求传播到所有挂钩的正在运行的任务需要多长时间? 是否有任何地方,代码可以检查:“从现在开始”每个感兴趣的Task,会发现已请求取消?


为什么需要它?

我希望进行稳定的单元测试,以证明取消在我们的代码中有效。

问题详情:

我们有生成任务的“执行器”,这些任务包含一些长时间运行的操作。 executor 的主要工作是限制启动了多少并发操作。所有这些任务都可以单独取消,并且这些操作将在内部尊重CancellationToken

我想提供单元测试,它表明当任务正在等待 slot 启动 给定操作 时发生取消,该任务将自行取消(最终)并且不会开始执行给定的操作

所以,想法是用单个 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();
}

此问题的可执行演示(通过 NUnit)可以在 GitHub 找到。

但是,该测试的实施有时会失败(没有预期的TaskCanceledException),在我的机器上可能有十分之一的运行。这个问题的一种“解决方案”是在取消请求之后插入Thread.Sleep。即使睡眠 3 秒,这个测试有时也会失败(在 20 次运行后发现),当它通过时,通常不需要长时间的等待(我猜)。参考请见diff

“其他问题”,是为了确保取消来自“等待时间”而不是来自Task.Run,因为ThreadPool可能很忙(其他正在执行的测试),并且在请求取消 - 这将使这个测试“假绿色”。 “通过 hack 轻松修复”是积极等待,直到第二个任务开始 - 它的 Status 变为 TaskStatus.Running。请检查branch 下的版本,看看没有这个 hack 的测试有时会是“绿色” - 所以示例错误可以通过它。

【问题讨论】:

  • 我正在寻找解决方案,在系统没有任何变化的情况下进行绿色测试。我不确定这个测试是否很好,甚至对我们的系统是否必要,因为通过的操作也应该检查令牌。我认为我们可以放弃它,但这对我来说更像是挑战。另外,我希望看到这个测试没有任何睡眠或黑客攻击。尽管如此,LimitingExecutor 的实现也可能会更好 - 无需从池中获取任务,然后将其冻结在信号量上,可能需要使用共享计数器和 Task.Delay 进行主动等待。
  • 为什么不使用自定义 TaskScheduler 或更好的 an ActionBlock<T> with a limited DOP instead ?一个动作块already supports cancellation.
  • TPL 已经通过自定义 TaskScheduler 类和工厂支持您提出的问题。检查this example of a LimitingTaskScheduler。它可用于创建一个新的 TaskFactory,其任务使用该特定的 TaskScheduler。
  • 感谢@Panagiotis Kanavos 指点我TPL 数据流库,因为以前我没有机会仔细研究这个库,它看起来很有趣。然而,我的(真正的)问题与转换数据和将多个异步进程绑定到没有直接关系。它更像调度程序,只需要防止一次启动(然后执行)过多的操作。尽管如此,我不认为实现整个 TaskScheduler 是正确的方向,或者会被我的团队接受为解决方案 - 感觉过于复杂。

标签: c# .net task task-parallel-library cancellation


【解决方案1】:

您的测试方法假定cancellingTask 始终占据LimitingExecutoractionTask 之前的槽(进入信号量)。不幸的是,这个假设是错误的,LimitingExecutor 不能保证这一点,这只是一个运气问题,这两个任务中的哪一个占用了插槽(实际上在我的计算机上它只发生在大约 5% 的运行中)。

要解决这个问题,你需要另一个ManualResetEvent,这将允许主线程等到cancellingTask实际占用slot:

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 不提供 API 来检测任务转换到 Running 状态,所以如果你不喜欢在循环中轮询 State 属性 + Thread.Sleep(),您需要修改LimitingExecutor.Do() 以提供此信息,可能使用另一个ManualResetEvent,例如:

public Task Do(Action work, CancellationToken token, ManualResetEvent taskRunEvent = null)
    => Task.Run(() =>
    {
        // Optional notification to the caller that task is now running
        taskRunEvent?.Set();

        // ...
    }, token);

【讨论】:

  • 谢谢 Ňuf :) 并发编程确实很难。非常感谢您指出我这个(现在看来)明显的问题:这些任务将不会按要求的顺序开始。因此,为了回答我的问题,我们在这里没有观察到 TPL 中请求取消的任何 传播时间问题。此外,我可以假设在CancellationTokenSource.Cancel 方法返回之后,任何感兴趣的任务都可以立即(从.NET 角度)观察到取消。如果我错了,请纠正我。
  • 如果我主动等待actionTask,我认为这不是很糟糕,但遗憾的是没有适用于这种情况的API - 但是,也许没有人真正需要它除了测试之外的生活,因为我以前从未见过这种需求。感谢您在 Do 方法中对 ManualResetEvent 的建议。这看起来是个好主意,我可能会接受它,但我觉得仅为测试更改“生产”代码并不是最佳实践 - 所以我将保持现状。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-05-15
  • 1970-01-01
相关资源
最近更新 更多