何时应该处理CancellationTokenSource?

214
CancellationTokenSource类是可释放的。在Reflector中快速查看证明了KernelEvent的使用,这是一个(很可能是)非托管资源。 由于CancellationTokenSource没有终结器,如果我们不处理它,垃圾回收程序也不会这样做。
另一方面,如果您查看MSDN文章Cancellation in Managed Threads中列出的示例,只有一个代码片段处置了令牌。
那么在代码中正确处理它的方法是什么呢?
  1. 如果您不等待任务完成,就不能用using语句包装启动并行任务的代码,而且只有在不等待时才有意义取消。
  2. 当然,您可以为任务添加ContinueWithDispose调用,但这是正确的方法吗?
  3. 对于可取消的PLINQ查询,它们不会进行后续同步,只是在结尾做些事情。比如.ForAll(x => Console.Write(x))?
  4. 它是否可重复使用?同一个令牌能否用于多个调用,然后与主机组件(比如UI控件)一起处理?
因为它没有像Reset方法一样清理IsCancelRequestedToken字段,所以我认为它不可重复使用,因此每次启动任务(或PLINQ查询)都应该创建一个新的。这是真的吗?如果是,我的问题是如何正确和建议地处理这些许多CancellationTokenSource实例的Dispose
7个回答

112

谈到是否有必要调用CancellationTokenSource上的Dispose方法...在我的项目中出现了内存泄漏的问题,结果发现是由于CancellationTokenSource引起的。

我的项目有一个服务,它不断地读取数据库并启动不同的任务,我将链接的取消标记传递给我的工作线程,即使它们已经完成了数据处理,取消标记也没有被处理掉,这导致了内存泄漏。

MSDN 《托管线程中的取消》清楚地指出:

请注意,在使用完链接的令牌源后,必须调用Dispose。有关更完整的示例,请参见如何:侦听多个取消请求

我在实现中使用了ContinueWith


20
这是Bryan Crosby当前被接受的答案中一个重要遗漏 - 如果您创建了一个_链接的_ CTS,您就有可能导致内存泄漏。这种情况与从未取消注册的事件处理程序非常相似。 - Søren Boisen
5
由于同样的问题,我出现了泄漏。使用分析器,我可以看到回调注册持有对关联CTS实例的引用。检查CTS Dispose实现代码[此处](http://referencesource.microsoft.com/#mscorlib/system/threading/CancellationTokenSource.cs,548)非常有启发性,并强调了@SørenBoisen关于事件处理程序注册泄漏的比较。 - BitMask777
上面的评论反映了讨论状态,其中@Bryan Crosby的其他答案被接受。 - George Mamaladze
2
2020年的文档明确指出:重要提示:CancellationTokenSource类实现了IDisposable接口。当您使用取消标记源完成后,应确保调用CancellationTokenSource.Dispose方法以释放其持有的任何非托管资源。- https://learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads?view=netframework-4.8 - Endrju

68

我认为当前的回答都不令人满意。经过研究,我找到了 Stephen Toub 的回复 (reference):

这要看情况而定。 在 .NET 4 中,CTS.Dispose 有两个主要目的。如果 CancellationToken 的 WaitHandle 已被访问(因此惰性分配它),Dispose 将处理该句柄的处理。此外,如果 CTS 是通过 CreateLinkedTokenSource 方法创建的,则 Dispose 将取消 CTS 与其关联的令牌之间的链接。在 .NET 4.5 中,Dispose 有一个附加目的,即如果 CTS 在内部使用计时器(例如调用了 CancelAfter),则计时器将被处理。

CancellationToken.WaitHandle 很少被使用,因此清理它通常不是使用 Dispose 的好理由。但是,如果您使用 CreateLinkedTokenSource 创建 CTS,或者使用 CTS 的计时器功能,则使用 Dispose 可能会更有影响力。

我认为加粗的部分是重要的。他使用了“更有影响力”的说法,这让意思有点模糊。我的理解是,在这些情况下调用Dispose应该是必须的,否则就不需要使用Dispose

12
更有影响力的意味着将子CTS添加到父CTS中。如果不处理子项,则在父项存在较长时间时会出现内存泄漏。因此,处理链接的CTS非常重要。 - Grigory
而且你永远不知道你在某个地方传递的令牌会被链接到哪里。 - Shadow
1
又有一条来自 Stephen Toub 的评论(我刚找到):“通常来说,处理可处置的东西是好的。但是有时,特别是在处理异步代码或生命周期不清晰的代码时,这样的一般准则需要根据其收益/风险进行权衡。对于CTS而言,最重要的是在使用CreateLinkedTokenSource创建时将其Dispose,次之是在使用超时时将其Dispose。” 时间为2020年10月28日。 - Theodor Zoulias

32
你应该始终处理 CancellationTokenSource
如何处理取决于具体情况。有几种不同的情况:
  1. 如果你正在等待一些并行工作结束,使用 using 就足够了。

  2. 如果你在使用任务,就像你提到的那样,可以使用 ContinueWith 任务来处理取消令牌源。

  3. 对于 PLINQ,可以使用 using,因为你在并行运行它,但等待所有并行运行的工作完成。

  4. 对于 UI,可以为每个可取消的操作创建一个新的 CancellationTokenSource ,它不与单个取消触发器相关联。维护一个 List<IDisposable> 并将每个源添加到列表中,在组件被处理时处理所有源。

  5. 对于线程,创建一个新线程,使所有工作线程加入,并在所有工作线程完成时关闭单个源。查看 CancellationTokenSource, When to dispose?

总之,IDisposable 实例应始终被处理。示例通常没有进行处理,因为它们只是快速演示核心用法,或者因为将演示的类的所有方面添加到演示中会过于复杂。示例只是一个演示,不一定是(甚至通常不是)生产质量的代码。并非所有示例都可以直接复制到生产代码中。

对于第二点,您是否有任何理由不能在任务上使用await并在等待后的代码中处理CancellationTokenSource - stijn
20
有些需要注意的地方。如果在你使用 await 等待操作期间,取消令牌源被取消了,你可能会因为出现 OperationCanceledException 而恢复。此时你可能会调用 Dispose() 方法。但是如果还有正在运行并且使用相应的 CancellationToken 的操作,那么即使源已被处理,该标记仍然会报告 CanBeCanceledtrue。如果它们尝试注册取消回调函数,则会抛出 ObjectDisposedException 异常。在操作成功完成后调用 Dispose() 是足够安全的。当你真正需要取消某些操作时,情况变得非常棘手 - Mike Strobel
11
因为Mike Strobel给出的原因而被downvote - 强制规则总是调用Dispose可能会在处理CTS和Task时遇到棘手的情况,因为它们具有异步性质。规则应该改为:始终处置_linked_ token sources。 - Søren Boisen
1
您的链接指向已删除的答案。 - Trisped
最新的.NET源代码CancellationTokenSource中也有一些很好的信息在一个代码注释中。 - Glenn Slayden
显示剩余2条评论

30

我查看了ILSpy中的CancellationTokenSource,但我只找到了m_KernelEvent,它实际上是一个ManualResetEvent,这是一个WaitHandle对象的包装类。这应该由GC适当处理。


9
我有同样的感觉,GC会清理掉所有的东西。我会尝试验证一下。为什么微软在这种情况下实现了dispose(释放)?可能是为了摆脱事件回调并避免传播到第二代GC。在这种情况下,调用Dispose是可选的 - 如果可以,就调用它,如果不能则忽略它。这不是最好的方式。 - George Mamaladze
7
我调查了这个问题。CancellationTokenSource被垃圾回收了。你可以通过dispose来帮助在GEN 1 GC中完成它。已经接受。 - George Mamaladze
3
我也独立进行了此项调查,并得出了同样的结论:尽可能处理,但不要为了在偶尔出现的情况下而苦恼——你已经把 CancellationToken 发送到边远地区,并且不想等待它们回信告诉你他们已经完成了。由于 CancellationToken 的使用性质,在偶尔出现这种情况是正常的,相信我,这真的没问题。 - Joe Amenta
10
我的上面的评论不适用于链接令牌源;我无法证明将其留下不处理是可以的,而这个帖子和MSDN中的智慧表明可能并不是这样。 - Joe Amenta

27

这个答案在谷歌搜索中仍然出现,我认为被投票赞成的答案没有给出完整的故事。查看了 CancellationTokenSource (CTS) 和 CancellationToken (CT) 的 source code 后,我认为对于大多数用例,以下代码序列是可以的:

if (cancelTokenSource != null)
{
    cancelTokenSource.Cancel();
    cancelTokenSource.Dispose();
    cancelTokenSource = null;
}

上述提到的 m_kernelHandle 内部字段是支持 CTS 和 CT 类中的 WaitHandle 属性的同步对象。只有在访问该属性时才会实例化它。因此,除非您在Task中使用 WaitHandle 进行一些老式的线程同步,否则调用 dispose 将不会产生任何效果。
当然,如果您正在使用它,则应按照上面其他答案建议的方式延迟调用 Dispose,直到使用句柄的任何 WaitHandle 操作完成,因为正如 Windows API documentation for WaitHandle 所描述的那样,结果是未定义的。

10
MSDN的文章[管理线程中的取消](https://msdn.microsoft.com/en-us/library/dd997364.aspx)指出:“侦听器通过轮询、回调或等待处理程序监视令牌的`IsCancellationRequested`属性的值。”换句话说:使用等待处理程序的可能不是您(即发出异步请求的人),而是侦听器(即响应请求的人)。这意味着作为负责处理的人,您实际上不能控制是否使用等待处理程序。 - herzbube
根据MSDN的说法,已注册的回调函数如果出现异常,将导致.Cancel抛出异常。如果发生这种情况,您的代码将不会调用.Dispose()。回调函数应该小心避免这种情况的发生,但这种情况确实可能发生。 - Joseph Lennox

27

我已经提出这个问题并得到很多有用的答案,但我遇到了一个与此相关的有趣问题,所以想在这里发表另一个回答:

只有当你确定没有人会尝试获取CTS的Token属性时,才应该调用CancellationTokenSource.Dispose().否则,不要 调用 Dispose(),因为它会创建竞争条件。例如,参见此处:

https://github.com/aspnet/AspNetKatana/issues/108

在修复此问题的过程中,先前执行cts.Cancel(); cts.Dispose();的代码被修改为只执行cts.Cancel();,因为任何不幸尝试在调用Dispose之后获取取消令牌以观察其取消状态的人都需要处理ObjectDisposedException - 除了他们原本打算处理的OperationCanceledException之外。

Tratcher还发现了与此修复相关的另一个关键观察结果:“只有那些不会被取消的令牌需要处理清理工作,因为取消操作也会执行相同的清理工作。” 即:只需执行Cancel()而不是Dispose()就足够了!


12
我写了一个线程安全的类,将 CancellationTokenSource 绑定到一个 Task 上,并确保当其关联的 Task 完成时,CancellationTokenSource 将被释放。它使用锁来确保在 CancellationTokenSource 被释放期间和之后不会被取消。这是为了符合 文档 的规定:

只有在对 CancellationTokenSource 对象的所有其他操作完成后才能使用 Dispose 方法。

而且还有 以下内容

Dispose 方法会使 CancellationTokenSource 处于不可用状态。

这里是 CancelableExecution 类:
public class CancelableExecution
{
    private readonly bool _allowConcurrency;
    private Operation _activeOperation;

    // Represents a cancelable operation that signals its completion when disposed
    private class Operation : IDisposable
    {
        private readonly CancellationTokenSource _cts;
        private readonly TaskCompletionSource _completionSource;
        private bool _disposed;

        public Task Completion => _completionSource.Task; // Never fails

        public Operation(CancellationTokenSource cts)
        {
            _cts = cts;
            _completionSource = new TaskCompletionSource(
                TaskCreationOptions.RunContinuationsAsynchronously);
        }

        public void Cancel() { lock (this) if (!_disposed) _cts.Cancel(); }

        void IDisposable.Dispose() // It is disposed once and only once
        {
            try { lock (this) { _cts.Dispose(); _disposed = true; } }
            finally { _completionSource.SetResult(); }
        }
    }

    public CancelableExecution(bool allowConcurrency)
    {
        _allowConcurrency = allowConcurrency;
    }
    public CancelableExecution() : this(false) { }

    public bool IsRunning => Volatile.Read(ref _activeOperation) != null;

    public async Task<TResult> RunAsync<TResult>(
        Func<CancellationToken, Task<TResult>> action,
        CancellationToken extraToken = default)
    {
        ArgumentNullException.ThrowIfNull(action);
        CancellationTokenSource cts = CancellationTokenSource
            .CreateLinkedTokenSource(extraToken);
        using Operation operation = new(cts);
        // Set this as the active operation
        Operation oldOperation = Interlocked
            .Exchange(ref _activeOperation, operation);
        try
        {
            if (oldOperation is not null && !_allowConcurrency)
            {
                oldOperation.Cancel();
                // The Operation.Completion never fails.
                await oldOperation.Completion; // Continue on captured context.
            }
            cts.Token.ThrowIfCancellationRequested();
            // Invoke the action on the initial SynchronizationContext.
            Task<TResult> task = action(cts.Token);
            return await task.ConfigureAwait(false);
        }
        finally
        {
            // If this is still the active operation, set it back to null.
            Interlocked.CompareExchange(ref _activeOperation, null, operation);
        }
        // The operation is disposed here, along with the cts.
    }

    public Task RunAsync(Func<CancellationToken, Task> action,
        CancellationToken extraToken = default)
    {
        ArgumentNullException.ThrowIfNull(action);
        return RunAsync<object>(async ct =>
        {
            await action(ct).ConfigureAwait(false);
            return null;
        }, extraToken);
    }

    public Task CancelAsync()
    {
        Operation operation = Volatile.Read(ref _activeOperation);
        if (operation is null) return Task.CompletedTask;
        operation.Cancel();
        return operation.Completion;
    }

    public bool Cancel() => CancelAsync().IsCompleted == false;
}
CancelableExecution类的主要方法是RunAsyncCancel。默认情况下,不允许并发(重叠)操作,这意味着第二次调用RunAsync将自动取消前一个操作(如果它仍在运行),然后开始新的操作。
此类可用于任何类型的应用程序。但其主要用途是在UI应用程序中,用于在具有启动和取消异步操作按钮的表单内,或者在更改选定项时每次取消并重新启动操作的列表框内。以下是第一个用例的示例:
private readonly CancelableExecution _cancelableExecution = new();

private async void btnExecute_Click(object sender, EventArgs e)
{
    string result;
    try
    {
        Cursor = Cursors.WaitCursor;
        btnExecute.Enabled = false;
        btnCancel.Enabled = true;
        result = await _cancelableExecution.RunAsync(async ct =>
        {
            await Task.Delay(3000, ct); // Simulate some cancelable I/O operation
            return "Hello!";
        });
    }
    catch (OperationCanceledException)
    {
        return;
    }
    finally
    {
        btnExecute.Enabled = true;
        btnCancel.Enabled = false;
        Cursor = Cursors.Default;
    }
    this.Text += result;
}

private void btnCancel_Click(object sender, EventArgs e)
{
    _cancelableExecution.Cancel();
}
< p > RunAsync 方法接受一个额外的 CancellationToken 作为参数,该参数与内部创建的 CancellationTokenSource 相关联。在高级场景中,提供此可选令牌可能很有用。

对于与 .NET Framework 兼容的版本,您可以查看此答案的 第三个修订版本


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接