链接取消令牌

78
我使用一个取消令牌,以便我的服务可以被干净地关闭。该服务具有保持尝试连接到其他服务的逻辑,因此这个令牌是退出在不同线程中运行的重试循环的好方法。我的问题是,我需要调用一个具有内部重试逻辑的服务,但如果重试失败,则在设定的时间后返回。我想创建一个带有超时的新取消令牌来为我完成这个操作。问题在于,我的新令牌未与“主”令牌链接在一起,因此当主令牌被取消时,我的新令牌仍将存活,直到它超时或连接成功并返回。我想要做的是将这两个令牌链接在一起,以便当主令牌被取消时,我的新令牌也会被取消。我尝试使用CancellationTokenSource.CreateLinkedTokenSource方法,但当我的新令牌超时时,它也取消了主令牌。是否有一种方法来处理令牌所需的内容,或者需要更改重试逻辑(可能无法轻松实现这一点)?
这是我想做的事情: 主令牌-传递给各种功能,以便该服务可以干净地关闭。 临时令牌-传递给单个函数,并设置为一分钟后超时。 如果主令牌被取消,临时令牌也必须被取消。 当临时令牌过期时,它不应取消主令牌。
4个回答

130
你想要使用 CancellationTokenSource.CreateLinkedTokenSource。它允许你为“子”CancellationTokenSource拥有一个“父”CancellationToken
这里是一个简单的例子:
var parentCts = new CancellationTokenSource();
var childCts = CancellationTokenSource.CreateLinkedTokenSource(parentCts.Token);
        
childCts.CancelAfter(1000);
Console.WriteLine("Cancel child CTS");
Thread.Sleep(2000);
Console.WriteLine("Child CTS: {0}", childCts.IsCancellationRequested);
Console.WriteLine("Parent CTS: {0}", parentCts.IsCancellationRequested);
Console.WriteLine();
        
parentCts.Cancel();
Console.WriteLine("Cancel parent CTS");
Console.WriteLine("Child CTS: {0}", childCts.IsCancellationRequested);
Console.WriteLine("Parent CTS: {0}", parentCts.IsCancellationRequested);

预期输出:

取消子 CTS
子 CTS:真
父 CTS:假

取消父 CTS
子 CTS:真
父 CTS:真


4
你说得很对。我开始使用 CancellationTokenSource.CreateLinkedTokenSource,但认为它不起作用。我忘记了当令牌超时时会抛出异常。这个异常在我的代码中被更高层的代码捕获了。这给人的印象是它没有按照我的期望工作。通过将我的调用放在 try catch 块中,它可以正常工作。 - Retrocoder
3
如果你只想捕获内部令牌,我建议使用如下模式:try { doSomething(ct: childCts.Token); } catch (OperationCancelledException) when (childCts.IsCancellationRequested) {}。你可以把它放在重试循环中,并在循环内创建子令牌源。然后,当父令牌取消时,它会一直向上冒泡,但当子令牌取消时,只是进行重试。我无法从你的评论中确定你是否已经正确地执行了这个操作;-)。 - binki
2
此外,OperationCancelledException 有自己的 CancellationToken 属性,你应该使用 catch (ex) when (condition) 的方式进行检查。这样你就可以确保已经捕获了正确的取消操作。 - AgentFire
4
请注意,如果您的代码正在创建多个与单个父令牌相关联的子令牌源,则应确保处理每个子令牌源以避免内存泄漏。如果不这样做,由于父令牌源具有对每个子令牌源的引用,因此父令牌源将保持所有子令牌源的活动状态。在许多情况下,这通常只需要编写 using var childCts = 就可以解决问题。 - Matt
1
@AgentFire 异常的 CancellationToken 属性存在的问题是,你的取消令牌的使用者可以创建自己的关联 CTS。在这种情况下,即使你的 CTS 导致了取消,异常也会说不是你而是某个未知的令牌。你能做的最好的事情就是检查你的 CTS 是否为 IsCancellationRequested 并假设没有独立于你的源的隐藏取消。(如果有人让一个 OperationCanceledException 传播而调用者没有请求它,我会说这是一个 bug。) - relatively_random
显示剩余4条评论

15
如果你只有一个 CancellationToken 而不是 CancellationTokenSource,那么仍然可以创建一个关联的取消令牌。你只需使用 Register 方法触发 (伪) 子级的取消即可。
var child = new CancellationTokenSource();
token.Register(child.Cancel);

您可以像通常使用 CancellationTokenSource 一样执行任何操作。例如,您可以在一段时间后取消它,甚至覆盖先前的令牌。

child.CancelAfter(cancelTime);
token = child.Token;

2
@Matt,如果我们将using用于Register(child.Cancel)的结果会怎样?就像这样: using var _ = cancellationToken.Register(combinedTokenSource.Cancel); - valker
1
@valker,我喜欢使用“using”语句来处理由token.Register(child.Cancel)返回的CancellationTokenRegistration。这似乎是防止内存泄漏的最佳方法,否则如果父令牌存在很长时间并且在其生命周期内创建了许多子令牌,则会发生内存泄漏。(我删除了我的原始评论,因为它无法编辑。) - Matt
5
实际上,您完全不需要调用Register()。相反,只需使用CancellationTokenSource.CreateLinkedTokenSource(token)即可,这是i3arnon在被接受的答案中所示范的方法。 - Matt
这是一个很棒的方法,谢谢!虽然CreateLinkedTokenSource在其中一个子任务触发取消请求时没有触发取消,但这个方法运行得非常顺利! - undefined

9

正如i3arnon已经回答的那样,您可以使用CancellationTokenSource.CreateLinkedTokenSource()来完成此操作。我想尝试展示一种使用这样的令牌的模式,当您想区分取消整个任务与取消子任务而不取消整个任务时。

async Task MyAsyncTask(
    CancellationToken ct)
{
    // Keep retrying until the master process is cancelled.
    while (true)
    {
        // Ensure we cancel ourselves if the parent is cancelled.
        ct.ThrowIfCancellationRequested();

        using var childCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        // Set a timeout because sometimes stuff gets stuck.
        childCts.CancelAfter(TimeSpan.FromSeconds(32));
        try
        {
            await DoSomethingAsync(childCts.Token);
        }
        // If our attempt timed out, catch so that our retry loop continues.
        // Note: because the token is linked, the parent token may have been
        // cancelled. We check this at the beginning of the while loop.
        catch (OperationCancelledException) when (childCts.IsCancellationRequested)
        {
        }
    }
}

当临时标记过期时,不应取消主标记。
请注意,MyAsyncTask()的签名接受CancellationToken而不是CancellationTokenSource。由于该方法只能访问CancellationToken上的成员,因此它不会意外地取消主/父标记。我建议您以这样的方式组织代码,使得主任务的CancellationTokenSource对尽可能少的代码可见。在大多数情况下,这可以通过将CancellationTokenSource.Token传递给方法而不是共享CancellationTokenSource的引用来完成。 我没有调查过,但可能有一种方法,比如使用反射强制取消CancellationToken而不需要访问其CancellationTokenSource。希望这是不可能的,但如果可能的话,这将被认为是不良做法,通常不需要担心。

5

有几个答案提到了从父 token 创建一个链接的 token source。如果你从其他地方获得子 token,这种模式就会崩溃。相反,您可能需要从您的主 token 和传递给方法的 token 创建一个链接的 token source。

来自 Microsoft 文档:https://learn.microsoft.com/en-us/dotnet/standard/threading/how-to-listen-for-multiple-cancellation-requests

public void DoWork(CancellationToken externalToken)
{
  // Create a new token that combines the internal and external tokens.
  this.internalToken = internalTokenSource.Token;
  this.externalToken = externalToken;

  using (CancellationTokenSource linkedCts =
          CancellationTokenSource.CreateLinkedTokenSource(internalToken, externalToken))
  {
      try {
          DoWorkInternal(linkedCts.Token);
      }
      catch (OperationCanceledException) when (linkedCts.Token.IsCancellationRequested){
          if (internalToken.IsCancellationRequested) {
              Console.WriteLine("Operation timed out.");
          }
          else if (externalToken.IsCancellationRequested) {
              Console.WriteLine("Cancelling per user request.");
              externalToken.ThrowIfCancellationRequested();
          }
      }
      catch (Exception ex)
      {
          //standard error logging here
      }
  }
}

通常在将令牌传递给方法时,您只能访问取消令牌。要使用其他答案的方法,您可能必须重新引导所有其他方法以绕过令牌源。而此方法只需使用令牌即可。


这个问题的其他答案不需要你“传递令牌源”。CancellationTokenSource.CreateLinkedTokenSource方法是静态的,因此可以在任何地方调用它,传递相关的内部或外部令牌。 https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtokensource.createlinkedtokensource - Matt
2
@Matt,好久不见了。我花了一些时间才明白去年的柠檬在想什么。其他答案使用父令牌和子令牌源(大多数生成具有父令牌作为参数的childTokenSource)。问题本身是关于链接令牌的,许多用例需要您链接两个预先存在的令牌,这就是我的答案应该帮助的地方。 - The Lemon

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