5

Consider a Winforms application, where we have a button that generates some results. If the user presses the button a second time, it should cancel the first request to generate results and start a new one.

We're using the below pattern, but we are unsure if some of the code is necessary to prevent a race condition (see the commented out lines).

    private CancellationTokenSource m_cts;

    private void generateResultsButton_Click(object sender, EventArgs e)
    {
        // Cancel the current generation of results if necessary
        if (m_cts != null)
            m_cts.Cancel();
        m_cts = new CancellationTokenSource();
        CancellationToken ct = m_cts.Token;

        // **Edit** Clearing out the label
        m_label.Text = String.Empty;
        // **Edit**

        Task<int> task = Task.Run(() =>
        {
            // Code here to generate results.
            return 0;
        }, ct);

        task.ContinueWith(t =>
        {
            // Is this code necessary to prevent a race condition?
            // if (ct.IsCancellationRequested)
            //     return;

            int result = t.Result;
            m_label.Text = result.ToString();
        }, ct, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.FromCurrentSynchronizationContext());
    }

Notice:

  • We only ever cancel the CancellationTokenSource on the main thread.
  • We use the same CancellationToken in the continuation as we do in the original task.

We're wondering whether or not the following sequence of events is possible:

  1. User clicks "generate results" button. Initial task t1 is started.
  2. User clicks "generate results" button again. Windows message is posted to queue, but the handler hasn't been executed yet.
  3. Task t1 finishes.
  4. TPL starts prepares to start the continuation (since the CancellationToken is not cancelled yet). The task scheduler posts the work to the Windows message queue (to get it to run on the main thread).
  5. The generateResultsButton_Click for the 2nd click starts executing and the CancellationTokenSource is cancelled.
  6. The continuations work starts and it operates as though the token were not cancelled (i.e. it displays its results in the UI).

So, I think the question boils down to:

When work is posted to the main thread (by using TaskScheduler.FromCurrentSynchronizationContext()) does the TPL check the CancellationToken on the main thread before executing the task's action, or does it check the cancellation token on whatever thread it happens to be on, and then post the work to the SynchronizationContext?

Matt Smith
  • 17,026
  • 7
  • 53
  • 103
  • I'm not sure what "race condition" you're talking about. But, no, you don't need the extra cancellation check because you used the `TaskContinuationOptions.OnlyOnRanToCompletion` option. – Peter Ritchie Mar 27 '13 at 18:07
  • We want to cancel updating the UI even in the case where the task completes, but the user has cancelled (by re-clicking the button). I've updated the code to include clearing out the UI label to demonstrate the problem better. Once the user clicks the button, we want the label to be empty until the results for that click are displayed. – Matt Smith Mar 27 '13 at 19:29
  • Once the continuation starts, you're on the UI thread (due to `TaskScheduler.FromCurrentSynchronizationContext`). The UI thread can't do anything else until that continuation is done (one thread, one thing at at time). So, in the case of 4/5/6, 5 will *not* occur until the continuation is complete (i.e. it's not 5, it's really 6) – Peter Ritchie Mar 27 '13 at 19:52
  • Right, I get that, and I may not be explaining it with proper terms, but when the task t1 finishes, the TPL has control and "prepares" to start the continuation (that's what I meant by step 4). This preparation work is presumably not done on the main thread. Presumably one of the things it does in preparation is check the cancellation token prior to starting the continuations action. The question is: Does that preparation work all happen on the background thread (and then the actual work is posted to the main thread), or does that preparation work get posted to main thread as well? – Matt Smith Mar 27 '13 at 20:03
  • That type of work would not be done on the SynchronizationContext thread (i.e. the UI thread in your case). The Click processing and the continuation cannot happen at the same time. You either get the click before the continuation or after. If it's before, you'll *always* get the cancellation in the continuation--regardless of *where* before (while Task is running or the prep for the continuation). It's unclear why you think this could be an issue? – Peter Ritchie Mar 27 '13 at 20:26
  • Okay, if that is the case, then I *do* need the commented out code. Otherwise, the following sequence is possible: 1. user presses button, windows message posted. 2. t1 completes. 3. tpl checks cancellation token, it is not cancelled, so post work via windows message to main thread. 4. message loop is pumped and button handler is first: it cancels the cts. 5. next in the message loop the continuation action executes. since it does not check the cancellation token, it posts the results to the UI. Bug: stale data is displayed. – Matt Smith Mar 28 '13 at 01:36
  • Yes, you need that code because the task (running on a different thread than the UI thread) can be running or complete while the Click event is being handled and the token is being set to *cancelled*. This is only because you want to still cancel the UI update despite the task completing successfully (which makes no sense to me if what you end up doing is starting the same task again to do the same work that just completed successfully). – Peter Ritchie Mar 28 '13 at 13:47
  • The token is never set to cancelled on a background thread--only the UI thread. This is a small sample that demonstrates the problem. In the real scenario, there are settings that would change between clicking the "generate" button, so it wouldn't be the same work. As Jacob's answer points out, the work of the TPL checking the cancellation token *DOES* happen on the main thread in this example, so the code is not necessary to get the semantics I want. – Matt Smith Mar 28 '13 at 13:54
  • You pass the cancellation token in to Task.Run(). So, technically the token might be checked on a thread other than the main thread. If it does, your continuation won't get executed. If settings change and the "task" wouldn't be the same, that makes sense. But then why the token? The code to update the UI should detect the settings change and ignore the result of the background task. It seems the result is out-of-date regardless whether the cancellation token is cancelled or not. – Peter Ritchie Mar 28 '13 at 14:21
  • Right, and its fine if the token gets checked on background threads *too*, I won't have the race condition as long as just before running the continuation, the cancellation token is checked on the main thread (which it is). The user is allowed to change settings while waiting for the results, but they don't commit to generating results until pressing the button. They also don't "cancel" the current calculation just by changing settings. That said, even if changing the settings were to cancel things, I would use the CancellationTokenSource and would have the same situation. – Matt Smith Mar 28 '13 at 14:40

2 Answers2

5

Assuming I read the question correctly, you are worried about the following sequence of events:

  1. The button is clicked, task T0 is scheduled on the thread pool, continuation C0 is scheduled as a continuation of T0, to be run on the synchronization context's task scheduler
  2. The button is clicked again. Let's say the message pump is busy doing something else, so now the message queue consists of one item, the click handler.
  3. T0 completes, this causes C0 to be posted to the message queue. The queue now contains two items, the click handler and the execution of C0.
  4. The click handler message is pumped, and the handler signals the token driving the cancellation of T0 and C0. Then it schedules T1 on the thread pool and C1 as a continuation in the same manner as step 1.
  5. The 'execute C0' message is still in the queue, so it gets processed now. Does it execute the continuation you intended to cancel?

The answer is no. TryExecuteTask will not execute a task which has been signaled for cancellation. It's implied by that documentation, but spelled out explicitly on the TaskStatus page, which specifies

Canceled -- The task acknowledged cancellation by throwing an OperationCanceledException with its own CancellationToken while the token was in signaled state, or the task's CancellationToken was already signaled before the task started executing.

So at the end of the day T0 will be in the RanToCompletion state and C0 will be in the Canceled state.

This is all, of course, assuming that the current SynchronizationContext does not allow tasks to be run concurrently (as you are aware, the Windows Forms one does not -- I'm just noting that this is not a requirement of synchronization contexts)

Also, it's worth noting that the exact answer to your final question about whether the cancellation token is checked in the context of when cancellation is requested or when the task is executed, the answer is really both. In addition to the final check in TryExecuteTask, as soon as cancellation is requested the framework will call TryDequeue, an optional operation that task schedulers can support. The synchronization context scheduler does not support it. But if it somehow did, the difference might be that the 'execute C0' message would be ripped out of the thread's message queue entirely and it wouldn't even try to execute the task.

Jacob
  • 1,699
  • 10
  • 11
  • 1
    Great answer. So, the key is that TryExecuteTask will be executed on the main thread (in my specific case) and in TryExecuteTask the cancellation token will be checked. This matches what is shown here: http://blogs.msdn.com/b/pfxteam/archive/2009/09/22/9898090.aspx?Redirected=true – Matt Smith Mar 28 '13 at 03:05
-1

The way I see it, regardless of which thread checks the CencellationToken, you have to consider the possibility that your continuation can get scheduled and the user can cancel the request while the continuation is being executed. So I think the check that was commented out should be checked and should probably be checked AGAIN after reading the result:

        task.ContinueWith(t =>
    {
        // Is this code necessary to prevent a race condition?
        if (ct.IsCancellationRequested)
            return;

        int result = t.Result;

        if (ct.IsCancellationRequested)
            return;

        m_label.Text = result.ToString();
    }, ct, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.FromCurrentSynchronizationContext());

I would also add a continutation to handle the cancellation condition separately:

        task.ContinueWith(t =>
    {
        // Do whatever is appropriate here.

    }, ct, TaskContinuationOptions.OnlyOnCanceled, TaskScheduler.FromCurrentSynchronizationContext());

This way you have all possibilities covered.

Neerav
  • 199
  • 1
  • 5
  • Notice that (a) my continuation is run on the main thread, and that (b) my CancellationTokenSource is only ever cancelled on the main thread. So, no, the CancellationToken cannot become cancelled while my continuation's action is running. – Matt Smith Mar 27 '13 at 16:37
  • Also, there is nothing to do in the case of cancellation, so no need for a continuation for that. – Matt Smith Mar 27 '13 at 16:38
  • If the code reaches the continuation, the previous task succeeded. There should be no problem continuing with the continuation despite a cancellation because the `Result` will have valid data. – Peter Ritchie Mar 27 '13 at 18:09
  • You'd have to decide that a "cancel" also means cancelling the update to the UI--which seems pointless to me. – Peter Ritchie Mar 27 '13 at 18:09
  • Yes, we want the cancel to mean cancel updating the UI. Otherwise, if the user presses the "generate results" button a second time, they will see the UI update with (stale) results and assume it is the new results just hitting button, but actually the results are the old results. – Matt Smith Mar 27 '13 at 19:21