简体   繁体   中英

Do multiple awaits to the same Task from a single thread resume in FIFO order?

Supposing a Task is created and await ed 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.

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;

}

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.

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: await ing 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.

ie

_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 .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM