3

Supposing a Task is created and awaited multiple times from a single thread. Is the resume order FIFO?

Simplistic example: Is the Debug.Assert() really an invariant?

Task _longRunningTask;

async void ButtonStartSomething_Click()
{
    // Wait for any previous runs to complete before starting the next
    if (_longRunningTask != null) await _longRunningTask;

    // Check our invariant
    Debug.Assert(_longRunningTask == null, "This assumes awaits resume in FIFO order");

    // Initialize
    _longRunningTask = Task.Delay(10000);

    // Yield and wait for completion
    await _longRunningTask;

    // Clean up
    _longRunningTask = null;
}

Initialize and Clean up are kept to a bare minimum for the sake of simplicity, but the general idea is that the previous Clean up MUST be complete before the next Initialize runs.

antak
  • 19,481
  • 9
  • 72
  • 80
  • If it isn't single thread, it is definitely not completed/resumed in FIFO order, but since the question is on single thread.... do you try it out? – Ian May 26 '16 at 03:07
  • 4
    I don't see anything in the documentation that requires FIFO completion notifications. I would make `_longRunningTask` be the `Task.Delay(10000)` followed by a continuation that nulls out the `_longRunningTask`. That way, you are guaranteed that awaiting the `_longRunningTask` will not complete until the variable is nulled out. – Raymond Chen May 26 '16 at 03:10
  • Even if you test and get reliable FIFO continuations 100% of the time in your WinForms or WPF app, this is an unreasonable risk to take. What if someone takes your code and integrates it into a lib that can run in a console app or ASP.NET? What if they notice that your async chain includes a long-running blocking operation and wrap the whole thing in `Task.Run`? You're introducing a lot of brittleness just to save yourself a simple `SemaphoreSlim.WaitAsync()/Release()` combo. – Kirill Shlenskiy May 26 '16 at 03:37

3 Answers3

2

The order of execution is pre-defined, however there is potential race condition on _longRunningTask variable if ButtonStartSomething_Click() is called concurrently from more than one thread (not likely the case).

Alternatively, you can explicitly schedule tasks using a queue. As a bonus a work can be scheduled from non-async methods, too:

void ButtonStartSomething_Click()
{

        _scheduler.Add(async() =>
        {
              // Do something
              await Task.Delay(10000);
              // Do something else
        });
}

Scheduler _scheduler;  




class Scheduler
{
    public Scheduler()
    {
        _queue = new ConcurrentQueue<Func<Task>>();
        _state = STATE_IDLE;
    }


    public void Add(Func<Task> func) 
    {
       _queue.Enqueue(func);
       ScheduleIfNeeded();
    }

    public Task Completion
    {
        get
        {
            var t = _messageLoopTask;
            if (t != null)
            {
                return t;
            }
            else
            {
                return Task.FromResult<bool>(true);
            }
        }
    }

    void ScheduleIfNeeded()
    {

        if (_queue.IsEmpty) 
        {
            return;
        }

        if (Interlocked.CompareExchange(ref _state, STATE_RUNNING, STATE_IDLE) == STATE_IDLE)
        {
            _messageLoopTask = Task.Run(new Func<Task>(RunMessageLoop));
        }
    }

    async Task RunMessageLoop()
    {
        Func<Task> item;

        while (_queue.TryDequeue(out item))
        {
            await item();
        }

        var oldState = Interlocked.Exchange(ref _state, STATE_IDLE);
        System.Diagnostics.Debug.Assert(oldState == STATE_RUNNING);

        if (!_queue.IsEmpty)
        {
            ScheduleIfNeeded();
        }
    }


    volatile Task _messageLoopTask; 
    ConcurrentQueue<Func<Task>> _queue;
    static int _state;
    const int STATE_IDLE = 0;
    const int STATE_RUNNING = 1;

}
alexm
  • 6,854
  • 20
  • 24
2

The short answer is: no, it's not guaranteed.

Furthermore, you should not use ContinueWith; among other problems, it has a confusing default scheduler (more details on my blog). You should use await instead:

private async void ButtonStartSomething_Click()
{
  // Wait for any previous runs to complete before starting the next
  if (_longRunningTask != null) await _longRunningTask;
  _longRunningTask = LongRunningTaskAsync();
  await _longRunningTask;
}

private async Task LongRunningTaskAsync()
{
  // Initialize
  await Task.Delay(10000);

  // Clean up
  _longRunningTask = null;
}

Note that this could still have "interesting" semantics if the button can be clicked many times while the tasks are still running.

The standard way to prevent the multiple-execution problem for UI applications is to disable the button:

private async void ButtonStartSomething_Click()
{
  ButtonStartSomething.Enabled = false;
  await LongRunningTaskAsync();
  ButtonStartSomething.Enabled = true;
}

private async Task LongRunningTaskAsync()
{
  // Initialize
  await Task.Delay(10000);
  // Clean up
}

This forces your users into a one-operation-at-a-time queue.

Stephen Cleary
  • 437,863
  • 77
  • 675
  • 810
  • Also, if splitting up a function is unsightly (e.g. if there're lots of variables to bring across), an alternative is using anonymous functions: `_longRunningTask = ((Func)(async () => { await Task.Delay(10); }))();` – antak May 27 '16 at 01:18
1

Found the answer under Task.ContinueWith(). It appear to be: no

Presuming await is just Task.ContinueWith() under the hood, there's documentation for TaskContinuationOptions.PreferFairness that reads:

A hint to a TaskScheduler to schedule task in the order in which they were scheduled, so that tasks scheduled sooner are more likely to run sooner, and tasks scheduled later are more likely to run later.

(bold-facing added)

This suggests there's no guarantee of any sorts, inherent or otherwise.

Correct ways to do this

For the sake of someone like me (OP), here's a look at the more correct ways to do this.

Based on Stephen Cleary's answer:

private async void ButtonStartSomething_Click()
{
    // Wait for any previous runs to complete before starting the next
    if (_longRunningTask != null) await _longRunningTask;

    // Initialize
    _longRunningTask = ((Func<Task>)(async () =>
    {
        await Task.Delay(10);

        // Clean up
        _longRunningTask = null;
    }))();

    // Yield and wait for completion
    await _longRunningTask;
}

Suggested by Raymond Chen's comment:

private async void ButtonStartSomething_Click()
{
    // Wait for any previous runs to complete before starting the next
    if (_longRunningTask != null) await _longRunningTask;

    // Initialize
    _longRunningTask = Task.Delay(10000)
        .ContinueWith(task =>
        {
            // Clean up
            _longRunningTask = null;

        }, TaskContinuationOptions.OnlyOnRanToCompletion);

    // Yield and wait for completion
    await _longRunningTask;
}

Suggested by Kirill Shlenskiy's comment:

readonly SemaphoreSlim _taskSemaphore = new SemaphoreSlim(1);

async void ButtonStartSomething_Click()
{
    // Wait for any previous runs to complete before starting the next
    await _taskSemaphore.WaitAsync();
    try
    {
        // Do some initialization here

        // Yield and wait for completion
        await Task.Delay(10000);

        // Do any clean up here
    }
    finally
    {
        _taskSemaphore.Release();
    }
}

(Please -1 or comment if I've messed something up in either.)

Handling exceptions

Using continuations made me realize one thing: awaiting at multiple places gets complicated really quickly if _longRunningTask can throw exceptions.

If I'm going to use continuations, it looks like I need to top it off by handling all exceptions within the continuation as well.

i.e.

_longRunningTask = Task.Delay(10000)
    .ContinueWith(task =>
    {
        // Clean up
        _longRunningTask = null;

    }, TaskContinuationOptions.OnlyOnRanToCompletion);
    .ContinueWith(task =>
    {
        // Consume or handle exceptions here

    }, TaskContinuationOptions.OnlyOnFaulted);

// Yield and wait for completion
await _longRunningTask;

If I use a SemaphoreSlim, I can do the same thing in the try-catch, and have the added option of bubbling exceptions directly out of ButtonStartSomething_Click.

Community
  • 1
  • 1
antak
  • 19,481
  • 9
  • 72
  • 80