1

This is a follow-up to this question.

Question: What would be a succinct way to express the following using async/await instead of .ContinueWith()?:

var task = Task.Run(() => LongRunningAndMightThrow());

m_cts = new CancellationTokenSource();
CancellationToken ct = m_cts.Token;

var uiTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
Task updateUITask = task.ContinueWith(t => UpdateUI(t), ct, TaskContinuationOptions.None, uiTaskScheduler);

I'm mainly interested in the case of a UI SynchronizationContext (e.g. for Winforms)

Note with this that the behavior has all the following desired behaviors:

  1. When the CancellationToken is cancelled, the updateUITask ends up cancelled as soon as possible (i.e. the LongRunningAndMightThrow work may still be going on for quite some time).

  2. The ct CancellationToken gets checked for cancellation on the UI thread prior to running the UpdateUI lambda (see this answer).

  3. The updateUITask will end up cancelled in some cases where the task completed or faulted (since the ct CancellationToken is checked on the UI thread before executing the UpdateUI lambda.

  4. There is no break in flow between the check of the CancellationToken on the UI thread and the running of the UpdateUI lambda. That is, if the CancellationTokenSource is only ever cancelled on the UI thread, then there is no race condition between the checking of the CancellationToken and the running of the UpdateUI lambda--nothing could trigger the CancellationToken in between those two events because the UI thread is not relinquished in between those two events.

Discussion:

  • One of my main goals in moving this to async/await is to get the UpdateUI work out of a lambda (for ease of readability/debuggability).

  • #1 above can be addressed by Stephen Toub's WithCancellation task extension method. (which you can feel free to use in the answers).

  • The other requirements seemed difficult to encapsulate into a helper method without passing UpdateUI as a lambda since I cannot have a break (i.e. an await) between the checking of the CancellationToken and the executing of UpdateUI (because I assume I cannot rely on the implementation detail that await uses ExecuteSynchronously as mentioned here. This is where it seems that having the mythical Task extension method .ConfigureAwait(CancellationToken) that Stephen talks about would be very useful.

  • I've posted the best answer I have right now, but I'm hoping that someone will come up with something better.

Sample Winforms Application demonstrating the usage:

public partial class Form1 : Form
{
    CancellationTokenSource m_cts = new CancellationTokenSource();

    private void Form1_Load(object sender, EventArgs e)
    {
        cancelBtn.Enabled = false;
    }

    private void cancelBtn_Click(object sender, EventArgs e)
    {
        m_cts.Cancel();
        cancelBtn.Enabled = false;
        doWorkBtn.Enabled = true;
    }

    private Task DoWorkAsync()
    {
        cancelBtn.Enabled = true;
        doWorkBtn.Enabled = false;

        var task = Task.Run(() => LongRunningAndMightThrow());

        m_cts = new CancellationTokenSource();
        CancellationToken ct = m_cts.Token;
        var uiTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
        Task updateUITask = task.ContinueWith(t => UpdateUI(t), ct, TaskContinuationOptions.None, uiTaskScheduler);

        return updateUITask;
    }

    private async void doWorkBtn_Click(object sender, EventArgs e)
    {
        try
        {
            await DoWorkAsync();
            MessageBox.Show("Completed");
        }
        catch (OperationCanceledException)
        {
            MessageBox.Show("Cancelled");
        }
        catch
        {
            MessageBox.Show("Faulted");
        }
    }

    private void UpdateUI(Task<bool> t)
    {
        // We *only* get here when the cancel button was *not* clicked.
        cancelBtn.Enabled = false;
        doWorkBtn.Enabled = true;

        // Update the UI based on the results of the task (completed/failed)
        // ...
    }

    private bool LongRunningAndMightThrow()
    {
        // Might throw, might complete
        // ...
        return true;
    }
}

Stephen Toub's WithCancellation extension method:

public static async Task<T> WithCancellation<T>(this Task<T> task, CancellationToken cancellationToken) 
{ 
    var tcs = new TaskCompletionSource<bool>(); 
    using(cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).TrySetResult(true), tcs)) 
    if (task != await Task.WhenAny(task, tcs.Task)) 
        throw new OperationCanceledException(cancellationToken); 
    return await task; 
}

Related Links:

Community
  • 1
  • 1
Matt Smith
  • 17,026
  • 7
  • 53
  • 103

2 Answers2

5

Writing a WithCancellation method can be done much simpler, in just one line of code:

public static Task WithCancellation(this Task task,
    CancellationToken token)
{
    return task.ContinueWith(t => t.GetAwaiter().GetResult(), token);
}
public static Task<T> WithCancellation<T>(this Task<T> task,
    CancellationToken token)
{
    return task.ContinueWith(t => t.GetAwaiter().GetResult(), token);
}

As for the operation you want to do, just using await instead of ContinueWith is just as easy as it sounds; you replace the ContinueWith with an await. Most of the little pieces can be cleaned up a lot though.

m_cts.Cancel();
m_cts = new CancellationTokenSource();
var result = await Task.Run(() => LongRunningAndMightThrow())
    .WithCancellation(m_cts.Token);
UpdateUI(result);

The changes are not huge, but they're there. You [probably] want to cancel the previous operation when starting a new one. If that requirement doesn't exist, remove the corresponding line. The cancellation logic is all already handled by WithCancellation, there is no need to throw explicitly if cancellation is requested, as that will already happen. There's no real need to store the task, or the cancellation token, as local variables. UpdateUI shouldn't accept a Task<bool>, it should just accept a boolean. The value should be unwrapped from the task before callingUpdateUI.

Servy
  • 202,030
  • 26
  • 332
  • 449
  • I like the new oneliner WithCancellation. I wonder why that wasn't used by Stephen. – Matt Smith Oct 10 '14 at 18:45
  • Your solution doesn't match the behavior of the ContinueWith scenario: (1) The CancellationToken is not necessarily checked on the UI thread, plus other things could happen on the UI in between checking the CancellationToken and running the UpdateUI. – Matt Smith Oct 10 '14 at 18:48
  • (2) The outer task will (in some cases) end up being in a Faulted state where the ContinueWith case would have ended up being cancelled. – Matt Smith Oct 10 '14 at 18:49
  • (3) In the ContinueWith case I run UpdateUI in the faulted case (as well as the completed case). In this example, the UpdateUI would only get called in the success case. – Matt Smith Oct 10 '14 at 18:51
  • 1
    @MattSmith 1) The cancellation token doesn't need to be checked in the UI thread; there is no reason to do so. 2) Why is this a problem if the two operations happen at approximately the exact same time. It's a race condition no matter how you code it as to which is going to happen first. There's no real problems created here. 3) This Your own code does the same thing; it only ever calls `UpdateUI` in the success case. This doesn't change that. If you have code that you want to run when there is an error, then use a `try/catch` and put the error handling code in there. – Servy Oct 10 '14 at 19:53
  • (1) It does for my purposes, and the ContinueWith version does that. (2) In the sample application, there is no race condition: if the user is able to press the cancel button, the task will end up cancelled. If user does not press the cancel button (or is not allowed to because it is disabled) then the task will not end up cancelled. (3) you are correct, that wasn't intended--I'll update my answer. – Matt Smith Oct 10 '14 at 19:57
  • 1) In what way does it actually matter? What is changed? 2) That's not true. The task can end up faulting just before the user presses the button, or after the button is pressed but before the handler is executed, etc. At the end of the day though, this is irrelevant. When someone goes to click a cancel button the operation can always finish just before they hit the button. That race condition exists all the way out to the fundamental UI level. – Servy Oct 10 '14 at 20:00
  • (2) No, in the program I showed there is no ordering in which the user clicks the cancel button and the outer task is not cancelled. The LongRunning task may complete or fault--and that is fine--but the outer task that gets returned will always be cancelled in the case that the user clicked the cancel button and will always *not* be cancelled in the case where they do not. You'll notice I disable the cancel button once it is clicked and it is also disabled once the operation has gone through completely. – Matt Smith Oct 10 '14 at 20:02
  • 1
    @MattSmith You didn't think through all of the situations I described. The cancel button can be clicked and the task can end up being faulted before that click event handler gets executed by the UI thread. There is a lag between when the user clicks a button and the corresponding action actually happens. Things can happen during that intervening time, such as this task faulting (or completing normally). On the other side of things the task that you have can fault or complete normally, schedule the continuations that you have to do things, and then have the user click cancel during that time. – Servy Oct 10 '14 at 20:08
  • I see. If I understand you correctly you're saying at time T1 the task completes/faults, and the continuation is placed on the message queue at position Q1 to be executed. At time T2 the mouse button is clicked and its handler ends up in the queue at Q2. T3, the continuation executes and disables the cancel button (but its too late) because at time T4, the click handler executes. Thus even though the user clicked the cancel button--the task didn't come back as cancelled. Is that right? Thank you--I hadn't fully considered that or understood it. – Matt Smith Oct 10 '14 at 20:15
  • @MattSmith That's one possible permutation, yes. There are of course any number of intricate ways of interviewing the various operations used here. At the end of the day it's a fundamental race condition; one operation will simply win, and nothing you do can change that. What you *can* do is ensure that the program handles the situation sensibly in that it acts throughout as if the operation is cancelled, faulted, or successful, rather than half and half. My code does that. – Servy Oct 10 '14 at 20:19
  • makes sense. That was a good lesson for me--thanks for hanging in there with me. – Matt Smith Oct 10 '14 at 20:28
  • A great solution, I'd only add `TaskContinuationsOptions.ExecuteSynchronously` to it as an optimization. A good discussion of races between the UI events and task completion/cancellation events, thanks @MattSmith and Servy. – noseratio Oct 10 '14 at 23:15
2

The following should be equivalent:

var task = Task.Run(() => LongRunningAndMightThrow());

m_cts = new CancellationTokenSource();
CancellationToken ct = m_cts.Token;

try
{
    await task.WithCancellation(ct);
}
finally
{
    ct.ThrowIfCancellationRequested();
    UpdateUI(task);
}

Notice that the try/finally is required for the case where the LongRunningAndMightThrow method faults, but by the time we return to the UI thread the CancellationToken has been triggered. Without it the returned outer Task would be faulted where in the original ContinueWith case, it would have been cancelled.

Matt Smith
  • 17,026
  • 7
  • 53
  • 103