簡體   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