Search code examples
c#asynchronousuwptaskcancellation

How can I create a Task that can cancel itself and another Task if needed?


Let's say I have a simple UWP app (so no .NET 5 or C# 8 without workarounds irrelevant to this situation), with many pages that contain buttons, all of which must be able to start work by calling SeriousWorkAsync and FunWorkAsync:

public async Task SeriousWorkAsync(SeriousObject obj)
{
    Setup(obj);
    for (int i = 0; i < 10000; i++)
    {
        await SeriousThingAsync(i);
    }
}

public async Task FunWorkAsync(FunObject obj)
{
    Setup(obj);
    for (int i = 0; i < 10000; i++)
    {
        await FunnyThingAsync(i);
    }
}

My requirements are as follows:

  • None of the buttons can be disabled at any point.
  • No tasks should ever run concurrently.
  • Whenever I call SeriousWorkAsync, I want FunWorkAsync to finish execution, and after cancellation is complete, SeriousWorkAsync should start.
  • Likewise, if I call SeriousWorkAsync while another call to SeriousWorkAsync is executing, I have to cancel that another call, and the newer call should only do stuff after cancellation is complete.
  • If there's any extra calls, the first call should cancel first, and only the last call should execute.

So far, the best solution I could come up with is delaying the Task in a loop until the other one's cancelled, with a few boolean flags that are set as soon as the method finishes execution:

private bool IsDoingWork = false;
private bool ShouldCancel = false;

public async Task FunWorkAsync(FunObject obj)
{
    CancelPendingWork();
    while (IsDoingWork)
    {
        await Task.Delay(30);
    }

    IsDoingWork = true;
    Setup(obj);
    for (int i = 0; i < 10000; i++)
    {
        if (ShouldCancel)
        {
            break;
        }
        await FunnyThingAsync(i);
    }

    IsDoingWork = false;
}

private void CancelPendingWork()
{
    if (IsDoingWork)
    {
        ShouldCancel = true;
    }
}

However, this feels like a very dirty workaround, and it doesn't address my last requirement. I know I should use CancellationToken, but my attempts at using it have been unsuccessful so far, even after a lot of searching and brainstorming. So, how should I go about this?


Solution

  • After a lot of searching, I came across "A pattern for self-cancelling and restarting task". This was exactly what I needed, and after some tweaks, I can safely say I got what I wanted. My implementation goes as follows:

    using System;
    using System.Diagnostics;
    using System.Threading;
    using System.Threading.Tasks;
    
    /// <summary>
    /// The task that is currently pending.
    /// </summary>
    private Task _pendingTask = null;
    
    /// <summary>
    /// A linked token source to control Task execution.
    /// </summary>
    private CancellationTokenSource _tokenSource = null;
    
    /// <summary>
    /// Does some serious work.
    /// </summary>
    /// <exception cref="OperationCanceledException">Thrown when the
    /// operation is cancelled.</exception>
    public async Task SeriousWorkAsync(CancellationToken token)
    {
        await CompletePendingAsync(token);
        this._pendingTask = SeriousImpl(this._tokenSource.Token);
        await this._pendingTask;
    }
    
    /// <summary>
    /// Does some fun work.
    /// </summary>
    /// <exception cref="OperationCanceledException">Thrown when the
    /// operation is cancelled.</exception>
    public async Task FunWorkAsync(CancellationToken token)
    {
        await CompletePendingAsync(token);
        this._pendingTask = FunImpl(this._tokenSource.Token);
        await this._pendingTask;
    }
    
    /// <summary>
    /// Cancels the pending Task and waits for it to complete.
    /// </summary>
    /// <exception cref="OperationCanceledException">If the new token has
    /// been canceled before the Task, an exception is thrown.</exception>
    private async Task CompletePendingAsync(CancellationToken token)
    {
        // Generate a new linked token
        var previousCts = this._tokenSource;
        var newCts = CancellationTokenSource.CreateLinkedTokenSource(token);
        this._tokenSource = newCts;
    
        if (previousCts != null)
        {
            // Cancel the previous session and wait for its termination
            previousCts.Cancel();
            try { await this._pendingTask; } catch { }
        }
    
        // We need to check if we've been canceled
        newCts.Token.ThrowIfCancellationRequested();
    }
    

    Ideally, calling the methods would look like this:

    try
    {
        await SeriousWorkAsync(new CancellationToken());
    }
    catch (OperationCanceledException) { }
    

    If you prefer, you can wrap your methods inside a try catch and always generate a new token, so consumers wouldn't need to apply special handling for cancellation:

    var token = new CancellationToken();
    try
    {
        await CompletePendingAsync(token);
        this._pendingTask = FunImpl(this._tokenSource.Token);
        await this._pendingTask;
    }
    catch { }
    

    Lastly, I tested using the following implementations for SeriousWorkAsync and FunWorkAsync:

    private async Task SeriousImpl(CancellationToken token)
    {
        Debug.WriteLine("--- Doing serious stuff ---");
        for (int i = 1000; i <= 4000; i += 1000)
        {
            token.ThrowIfCancellationRequested();
            Debug.WriteLine("Sending mails for " + i + "ms...");
            await Task.Delay(i);
        }
        Debug.WriteLine("--- Done! ---");
    }
    
    private async Task FunImpl(CancellationToken token)
    {
        Debug.WriteLine("--- Having fun! ---");
        for (int i = 1000; i <= 4000; i += 1000)
        {
            token.ThrowIfCancellationRequested();
            Debug.WriteLine("Laughing for " + i + "ms...");
            await Task.Delay(i);
        }
        Debug.WriteLine("--- Done! ---");
    }