繁体   English   中英

几秒钟后更改 state 的 CancellationTokenSource

[英]CancellationTokenSource for changing state after few seconds

我试图重现人们在 Outlook / Gmail 中看到的效果:打开未读的 email 几秒钟后,它被标记为已读。 但是,如果在该时间过去之前单击不同的 email,则 email 将保持未读状态。

private CancellationTokenSource? _cts;

private async Task OnEmailClicked(int id)
{

  if (_cts != null)
  {
    _cts.Cancel();                                // I suspect problem is here
    _cts.Token.ThrowIfCancellationRequested();    // or here
    _cts.Dispose();
    _cts = null;
  }

  await LoadEmail(id);

  try
  {
    _cts = new CancellationTokenSource();
    await Task.Delay(3000, _cts.Token);
  }
  catch (TaskCanceledException)
  {
    return;
  }
  finally {
    _cts.Dispose();
    _cts = null;
  }

  await MarkEmailAsRead(id);
}

这给了我奇怪的结果。 有时有效,有时无效。 我不确定在哪里/如何创建、取消和处置令牌源。 显然我的代码是错误的。

实现这一目标的正确方法是什么?

(我不需要“修复”这段代码——我很乐意把它扔掉并以正确的方式进行,如果你能告诉我怎么做的话。我只是把它包含进来以演示我已经尝试过的东西。)

首先,调用ThrowIfCancellationRequested()会破坏事情。 每次命中该行时都会抛出异常(因为您刚刚取消了该行之前的标记)并且该方法的 rest 将不会运行。

我看到的另一个问题是您在取消后将 _cts 设置为_cts 请记住,您将同时使用此方法的两个版本。 假设该方法的两个版本都将在 UI 线程上运行,那么整个代码块将不间断地运行(我已经删除ThrowIfCancellationRequested() ):

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

然后,一旦LoadEmail(id) ,该方法的先前不完整版本就有机会运行,它进入catchfinally调用_cts.Dispose() 但是_cts已经设置为null ,因此您将获得NullReferenceException并且MarkEmailAsRead(id)将永远不会运行。

所以我只会在一个地方而不是两个地方调用Dispose() 但我也会保留对我们创建的CancellationTokenSource的引用,以便如果将新内容分配给_cts ,我们仍然可以在我们创建的那个上调用Dispose() 这看起来像这样:

private CancellationTokenSource? _cts;

private async Task OnEmailClicked(int id)
{
  
  _cts?.Cancel();

  // Keep a local reference to the token source so that we can still call Dispose()
  // on the object we created here even if _cts gets assigned a new object by another
  // run of this method
  var cts = _cts = new CancellationTokenSource();
  
  await LoadEmail(id);
  
  try
  {
    await Task.Delay(3000, cts.Token);
  }
  catch (TaskCanceledException)
  {
    return;
  }
  finally {
    // Only assign _cts = null if another run hasn't already assigned
    // a new token
    if (cts == _cts) _cts = null;
    cts.Dispose();
  }

  await MarkEmailAsRead(id);
}

我没有对此进行测试,但看看它是否满足您的要求。

如果cts_cts的使用让您感到困惑,阅读参考类型可能会有所帮助。 但重要的是,将新实例分配给_cts不会破坏它过去引用的 object,并且仍然引用该 object 的代码的任何其他部分仍然可以使用它。

如果您从未见过?. 之前,它是null-conditional operator

据我了解,每次单击不同的 email 时,您基本上都会重新启动看门狗定时器三秒钟。如果 WDT 到期而没有获得新的单击,那么您提前 go 并将最新的 email 标记为“读取”。

多年来,我(成功地)使用相同的方法在为 WDT 启动新任务时取消旧任务。 但后来我意识到我可以这样做:

int _wdtCount = 0;
// Returning 'void' to emphasize that this should 'not' be awaited!
async void onEmailClicked(int id)
{
    _wdtCount++;
    var capturedCount = _wdtCount;
    await Task.Delay(TimeSpan.FromSeconds(3));   
    // If the 'captured' localCount has not changed after waiting 3 seconds
    // it indicates that no new selections have been made in that time.        
    if(capturedCount.Equals(_wdtCount))
    {
        await MarkEmailAsRead(id);
    }
}

async Task MarkEmailAsRead(int id)
{
    Console.WriteLine($"The email with the ID '{id}' has been marked read");
}

无论如何,对我有用,这只是一个想法......

试验台

#region T E S T
onEmailClicked(id: 1);
await Task.Delay(TimeSpan.FromSeconds(1));


onEmailClicked(id: 2);
await Task.Delay(TimeSpan.FromSeconds(1));


onEmailClicked(id: 3);
await Task.Delay(TimeSpan.FromSeconds(4));


onEmailClicked(id: 10);
await Task.Delay(TimeSpan.FromSeconds(2));


onEmailClicked(id: 20);
await Task.Delay(TimeSpan.FromSeconds(1));


onEmailClicked(id: 30);
await Task.Delay(TimeSpan.FromSeconds(4));
#endregion T E S T

安慰


编辑

虽然我意识到该帖子不再有除任务取消之外的娱乐性“其他”选项的措辞,但我仍然想通过将WatchDogTimer放入 class 来合并优秀评论并改进我的原始答案......

class WatchDogTimer
{
    int _wdtCount = 0;
    public TimeSpan Interval { get; set; } = TimeSpan.FromSeconds(1);
    public void ThrowBone(Action action)
    {
        _wdtCount++;
        var capturedCount = _wdtCount;
        Task
            .Delay(Interval)
            .GetAwaiter()
            .OnCompleted(() =>
            {                    
                // If the 'captured' localCount has not changed after 
                // awaiting the Interval, it indicates that no new
                // 'bones' have been thrown during that interval.    
                if (capturedCount.Equals(_wdtCount))
                {
                    action();
                }
            });
    }
}

...并从 pos 中消除async void

void onEmailClicked(int id)
    => _watchDog.ThrowBone(() => MarkEmailAsRead(id));

void MarkEmailAsRead(int id)
{
    // BeginInvoke(()=>
    // {
    Console.WriteLine($"Expired: {_stopWatch.Elapsed}");
    Console.WriteLine($"The email with the ID '{id}' has been marked read");
    // });
}

试验台

#region T E S T
// Same test parameters as before.
System.Diagnostics.Stopwatch _stopWatch = 
    new System.Diagnostics.Stopwatch();
WatchDogTimer _watchDog = 
    new WatchDogTimer { Interval = TimeSpan.FromSeconds(3) };

_stopWatch.Start();
Console.WriteLine(_stopWatch.Elapsed);
onEmailClicked(id: 1);
await Task.Delay(TimeSpan.FromSeconds(1));


Console.WriteLine(_stopWatch.Elapsed);
onEmailClicked(id: 2);
await Task.Delay(TimeSpan.FromSeconds(1));


Console.WriteLine(_stopWatch.Elapsed);
onEmailClicked(id: 3);
await Task.Delay(TimeSpan.FromSeconds(4));


Console.WriteLine(_stopWatch.Elapsed);
onEmailClicked(id: 10);
await Task.Delay(TimeSpan.FromSeconds(2));


Console.WriteLine(_stopWatch.Elapsed);
onEmailClicked(id: 20);
await Task.Delay(TimeSpan.FromSeconds(1));


Console.WriteLine(_stopWatch.Elapsed);
onEmailClicked(id: 30);
await Task.Delay(TimeSpan.FromSeconds(4));
#endregion T E S T

经过的屏幕截图

首先,我假设您的代码是单线程的。 CancellationTokenSource.Dispose方法不是线程安全的,因此尝试处置在多线程环境中共享的CancellationTokenSource一场小小的噩梦 您可以通过在与CancellationTokenSource交互的代码中搜索Task.Run和 .ConfigureAwait .ConfigureAwait(false)来检查您的代码是否是单线程的。 如果你找到任何东西,你可能会遇到麻烦。

其次,您应该知道CancellationTokenSource.Cancel方法是“不稳定的”。 它立即调用之前通过CancellationToken.Register路由注册的所有回调。 await Task.Delay(3000, _cts.Token);这样的代码隐式注册回调,因此执行流程可能会跳转到await之后的代码,然后继续执行Cancel之后的代码。 您的代码应该为此类跳转做好准备。 您不应该假设Cancel会很快完成,也不应该假设它不会抛出。 如果注册的回调失败,则Cancel调用将显示异常。

至于你的代码的细节,你的错误是你与_cts领域的互动太多了。 您应该将交互减少到最低限度。 您还应该假设在await之后_cts不会存储您在await之前分配的相同实例。 您的代码是单线程的,但是是异步的。 异步代码具有复杂的执行流程。 我同意 Gabriel Luci 的观点,你应该只在一个地方Dispose 使用async void并没有那么糟糕,因为 UI 事件处理程序通常为async void 当您完成应用程序并尝试关闭 Window 时,它可能会咬住您,因为您将无法在关闭之前等待挂起的异步操作完成。 所以你可能会在关闭 Window 后遇到烦人的错误,因为异步操作会尝试与已被释放的 UI 组件进行交互。 理想情况下,您应该存储最新异步操作( Task )的引用,并在开始下一个操作之前或在关闭 Window 之前等待它。但是为了保持答案简单,我将省略这种复杂性。 这是我的建议:

private CancellationTokenSource _cts;

private async void OnEmailClicked(int id)
{
    _cts?.Cancel();

    CancellationTokenSource cts = new();
    try
    {
        _cts = cts;
        await LoadEmail(id);
        try { await Task.Delay(3000, cts.Token); }
        catch (OperationCanceledException) { return; } // Do nothing
    }
    finally
    {
        if (cts == _cts) _cts = null;
        cts.Dispose();
    }
    await MarkEmailAsRead(id);
}

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM