有什么方法可以区分“取消”和“超时”?

22

我有一些代码正在通过调用其他服务来验证一些数据。我并行启动所有的调用,然后等待至少一个完成。如果任何请求失败,我不关心其他调用的结果。

我使用HttpClient进行调用,并传递了一个执行大量日志记录的HttpMessageHandler。基本上是这样:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    HttpResponseMessage response = null;

    try
    {
        response = await base.SendAsync(request, cancellationToken);
    }
    catch (OperationCanceledException ex)
    {
        LogTimeout(...);
        throw;
    }
    catch (Exception ex)
    {
        LogFailure(...);
        throw;
    }
    finally
    {
        LogComplete(...);
    }

    return response;
}

我遇到麻烦的部分是取消请求时。当我取消请求时,我是有意这样做的,所以我不希望它被记录为超时,但似乎取消和真正的超时之间没有任何区别。

有什么办法可以实现这一点吗?

编辑:我需要澄清一下。并行进行调用的服务正在传递带有超时的CancellationTokens:

var ct = new CancellationTokenSource(TimeSpan.FromSeconds(2));

当服务器响应时间超过2秒时,我会收到一个OperationCanceledException异常,如果我手动取消令牌源(比如因为另一个服务器在1秒后返回错误),那么我仍然会收到一个OperationCanceledException异常。理想情况下,我可以查看CancellationToken.IsCancellationRequested来确定是否因为超时而取消,而不是显式请求取消,但似乎无论如何取消都会得到相同的值。


有客户端的最后日期时间存储。然后将其与当前时间进行比较。如果日期时间差大于超时时间,则客户端被视为超时。否则,它是取消。类似这样的内容。 - Ian
你可以检查取消令牌是否已请求取消。即使发生竞态条件,并且在您想要取消它的同时请求超时,您可能仍希望忽略它,因为您已经取消了它。 - SimpleVar
2个回答

17

如果你想区分两种取消类型,那么你需要使用两个不同的取消标记。没有其他办法。这并不太难,因为它们可以链接,只是有点令人尴尬。

在我看来,最干净的方法是将超时代码移到SendAsync方法中,而不是调用方法:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
  using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
  {
    cts.CancelAfter(TimeSpan.FromSeconds(2));
    try
    {
      return await base.SendAsync(request, cts.Token);
    }
    catch (OperationCanceledException ex)
    {
      if (cancellationToken.IsCancellationRequested)
        return null;
      LogTimeout(...);
      throw;
    }
    catch (Exception ex)
    {
      LogFailure(...);
      throw;
    }
    finally
    {
      LogComplete(...);
    }
  }
}
如果您不想将超时代码移动到 SendAsync 中,则还需要在该方法之外记录日志。

如果您不想将超时代码移动到 SendAsync 中,则还需要在该方法之外记录日志。


谢谢您的建议。如果我可以说使用给定的 HttpClient 进行的所有调用都应该具有相同的超时时间,并且在构造 HttpMessageHandler 时只需将其作为参数传递,那么我可能可以通过这样做来解决问题,否则我认为我必须坚持 Chiune 提供的 CustomCancellationTokenSource 解决方案。 - Ben Randall

8
如果异常没有告诉您两种情况之间的区别,那么您需要检查TaskCancellationToken,以查看是否实际上发生了取消操作。 我倾向于询问Task,如果抛出未处理的OperationCanceledException(在base.SendAsync内使用CancellationToken.ThrowIfCancellationRequested)则其IsCanceled属性将返回true。像这样...
HttpResponseMessage response = null;
Task sendTask = null;

try
{
  sendTask = base.SendAsync(request, cancellationToken);
  await sendTask;
}
catch (OperationCanceledException ex)
{
  if (!sendTask.IsCancelled)
  {
    LogTimeout(...);
    throw;
  }
}

编辑

针对问题的更新,我想更新我的回答。您是正确的,无论是在CancellationTokenSource上特别请求取消还是由超时引起,都会导致完全相同的结果。如果反编译CancellationTokenSource,您将看到它只是设置了一个计时器(Timer) 的回调函数,当时间到达时将显式调用CancellationTokenSource.Cancel,因此两种方式最终都将调用相同的Cancel方法。

我认为,如果您想区分它们,您需要从CancellationTokenSource派生(它不是一个密封类),然后添加自己的自定义取消方法,该方法将设置一个标志,让您知道您明确取消了操作,而不是让它超时。

这很不幸,因为您将拥有您自己的自定义取消方法和原始的Cancel方法,并且必须确保使用自定义方法。您可以通过像这样隐藏现有的Cancel操作来摆脱自定义逻辑:

class CustomCancellationTokenSource : CancellationTokenSource
{
  public bool WasManuallyCancelled {get; private set;}

  public new void Cancel()
  {
    WasManuallyCancelled = true;
    base.Cancel();
  }
}

我认为隐藏基本方法会起作用,你可以试一下看看。


更新了答案,请告诉我您的想法。 - Chiune Sugihara
CustomCancellationTokenSource本质上就是我现在拥有的。我将其公开为一个新方法:CancelWithoutError(),该方法设置标志。不幸的是,在我的HttpMessageHandler中,如果不使用反射从CancellationToken中提取它,我无法访问CancellationTokenSource:(。相反,我设置了一个静态引用来指向“CurrentSafeCancellationTokenSource”,并在处理程序中检查是否正在使用安全令牌源,如果是,则检查是否手动取消了请求。这略微概述了一些细节,但基本上就是这样工作的。 - Ben Randall
我不知道你能否用简单的方式做得更好。如果想变得复杂,你可以分析抛出异常的堆栈跟踪,如果是超时引起的 Cancel 调用,则可以使用它来区分这两种情况。 - Chiune Sugihara
不幸的是,当调用者(在这种情况下是基础的 HttpClient)检查是否已经取消时,异常被抛出,因此您甚至看不到 Cancel 调用作为堆栈的一部分。 - Ben Randall
是的,我也研究了 CancellationToken.Register,但不幸的是它有一个问题,就是在调用异常处理程序之前不一定会被调用。 :( - Ben Randall
显示剩余3条评论

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