Шаблон для самоотдачи и перезапуска задачи

Есть ли рекомендованный шаблон для самостоятельной отмены и перезапуска задач?

Например, я работаю над API для проверки орфографии. Сеанс проверки орфографии завернут как Task. Каждый новый сеанс должен отменить предыдущий и дождаться его завершения (для правильного повторного использования ресурсов, таких как поставщик услуг проверки орфографии и т.д.).

Я придумал что-то вроде этого:

class Spellchecker
{
    Task pendingTask = null; // pending session
    CancellationTokenSource cts = null; // CTS for pending session

    // SpellcheckAsync is called by the client app
    public async Task<bool> SpellcheckAsync(CancellationToken token)
    {
        // SpellcheckAsync can be re-entered
        var previousCts = this.cts;
        var newCts = CancellationTokenSource.CreateLinkedTokenSource(token);
        this.cts = newCts;

        if (IsPendingSession())
        {
            // cancel the previous session and wait for its termination
            if (!previousCts.IsCancellationRequested)
                previousCts.Cancel();
            // this is not expected to throw
            // as the task is wrapped with ContinueWith
            await this.pendingTask; 
        }

        newCts.Token.ThrowIfCancellationRequested();
        var newTask = SpellcheckAsyncHelper(newCts.Token);

        this.pendingTask = newTask.ContinueWith((t) => {
            this.pendingTask = null;
            // we don't need to know the result here, just log the status
            Debug.Print(((object)t.Exception ?? (object)t.Status).ToString());
        }, TaskContinuationOptions.ExecuteSynchronously);

        return await newTask;
    }

    // the actual task logic
    async Task<bool> SpellcheckAsyncHelper(CancellationToken token)
    {
        // do not start a new session if the the previous one still pending
        if (IsPendingSession())
            throw new ApplicationException("Cancel the previous session first.");

        // do the work (pretty much IO-bound)
        try
        {
            bool doMore = true;
            while (doMore)
            {
                token.ThrowIfCancellationRequested();
                await Task.Delay(500); // placeholder to call the provider
            }
            return doMore;
        }
        finally
        {
            // clean-up the resources
        }
    }

    public bool IsPendingSession()
    {
        return this.pendingTask != null &&
            !this.pendingTask.IsCompleted &&
            !this.pendingTask.IsCanceled &&
            !this.pendingTask.IsFaulted;
    }
}

Клиентское приложение (UI) должно просто иметь возможность вызывать SpellcheckAsync столько раз, сколько нужно, не беспокоясь об отмене ожидающего сеанса. Основной цикл doMore работает в потоке пользовательского интерфейса (так как он включает в себя пользовательский интерфейс, а все вызовы поставщика услуг проверки орфографии связаны с IO).

Мне немного неудобно в том, что мне пришлось разделить API на два peices, SpellcheckAsync и SpellcheckAsyncHelper, но я не могу придумать лучшего способа сделать это, и он еще не протестирован.

Ответ 1

Я думаю, что общее понятие довольно хорошее, хотя я рекомендую не использовать ContinueWith.

Я бы просто написал его с помощью регулярного await, и большая часть логики "я уже работаю" не нужна:

Task pendingTask = null; // pending session
CancellationTokenSource cts = null; // CTS for pending session

// SpellcheckAsync is called by the client app on the UI thread
public async Task<bool> SpellcheckAsync(CancellationToken token)
{
    // SpellcheckAsync can be re-entered
    var previousCts = this.cts;
    var newCts = CancellationTokenSource.CreateLinkedTokenSource(token);
    this.cts = newCts;

    if (previousCts != null)
    {
        // cancel the previous session and wait for its termination
        previousCts.Cancel();
        try { await this.pendingTask; } catch { }
    }

    newCts.Token.ThrowIfCancellationRequested();
    this.pendingTask = SpellcheckAsyncHelper(newCts.Token);
    return await this.pendingTask;
}

// the actual task logic
async Task<bool> SpellcheckAsyncHelper(CancellationToken token)
{
    // do the work (pretty much IO-bound)
    using (...)
    {
        bool doMore = true;
        while (doMore)
        {
            token.ThrowIfCancellationRequested();
            await Task.Delay(500); // placeholder to call the provider
        }
        return doMore;
    }
}

Ответ 2

Здесь самая последняя версия шаблона отмены и перезапуска, который я использую:

class AsyncWorker
{
    Task _pendingTask;
    CancellationTokenSource _pendingTaskCts;

    // the actual worker task
    async Task DoWorkAsync(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        Debug.WriteLine("Start.");
        await Task.Delay(100, token);
        Debug.WriteLine("Done.");
    }

    // start/restart
    public void Start(CancellationToken token)
    {
        var previousTask = _pendingTask;
        var previousTaskCts = _pendingTaskCts;

        var thisTaskCts = CancellationTokenSource.CreateLinkedTokenSource(token);

        _pendingTask = null;
        _pendingTaskCts = thisTaskCts;

        // cancel the previous task
        if (previousTask != null && !previousTask.IsCompleted)
            previousTaskCts.Cancel();

        Func<Task> runAsync = async () =>
        {
            // await the previous task (cancellation requested)
            if (previousTask != null)
                await previousTask.WaitObservingCancellationAsync();

            // if there a newer task started with Start, this one should be cancelled
            thisTaskCts.Token.ThrowIfCancellationRequested();

            await DoWorkAsync(thisTaskCts.Token).WaitObservingCancellationAsync();
        };

        _pendingTask = Task.Factory.StartNew(
            runAsync,
            CancellationToken.None,
            TaskCreationOptions.None,
            TaskScheduler.FromCurrentSynchronizationContext()).Unwrap();
    }

    // stop
    public void Stop()
    {
        if (_pendingTask == null)
            return;

        if (_pendingTask.IsCanceled)
            return;

        if (_pendingTask.IsFaulted)
            _pendingTask.Wait(); // instantly throw an exception

        if (!_pendingTask.IsCompleted)
        {
            // still running, request cancellation 
            if (!_pendingTaskCts.IsCancellationRequested)
                _pendingTaskCts.Cancel();

            // wait for completion
            if (System.Threading.Thread.CurrentThread.GetApartmentState() == ApartmentState.MTA)
            {
                // MTA, blocking wait
                _pendingTask.WaitObservingCancellation();
            }
            else
            {
                // TODO: STA, async to sync wait bridge with DoEvents,
                // similarly to Thread.Join
            }
        }
    }
}

// useful extensions
public static class Extras
{
    // check if exception is OperationCanceledException
    public static bool IsOperationCanceledException(this Exception ex)
    {
        if (ex is OperationCanceledException)
            return true;

        var aggEx = ex as AggregateException;
        return aggEx != null && aggEx.InnerException is OperationCanceledException;
    }

    // wait asynchrnously for the task to complete and observe exceptions
    public static async Task WaitObservingCancellationAsync(this Task task)
    {
        try
        {
            await task;
        }
        catch (Exception ex)
        {
            // rethrow if anything but OperationCanceledException
            if (!ex.IsOperationCanceledException())
                throw;
        }
    }

    // wait for the task to complete and observe exceptions
    public static void WaitObservingCancellation(this Task task)
    {
        try
        {
            task.Wait();
        }
        catch (Exception ex)
        {
            // rethrow if anything but OperationCanceledException
            if (!ex.IsOperationCanceledException())
                throw;
        }
    }
}

Использование теста (для всего DoWorkAsync) выводится только один вывод "Пуск/Готово":

private void MainForm_Load(object sender, EventArgs e)
{
    var worker = new AsyncWorker();
    for (var i = 0; i < 10; i++)
        worker.Start(CancellationToken.None);
}

Ответ 3

Надеемся, что это будет полезно - попытался создать класс Helper, который можно повторно использовать:

class SelfCancelRestartTask
{
    private Task _task = null;
    public CancellationTokenSource TokenSource { get; set; } = null;

    public SelfCancelRestartTask()
    {
    }

    public async Task Run(Action operation)
    {
        if (this._task != null &&
            !this._task.IsCanceled &&
            !this._task.IsCompleted &&
            !this._task.IsFaulted)
        {
            TokenSource?.Cancel();
            await this._task;
            TokenSource = new CancellationTokenSource();
        }
        else
        {
            TokenSource = new CancellationTokenSource();
        }
        this._task = Task.Run(operation, TokenSource.Token);
    }

Ответ 4

Приведенные выше примеры, похоже, имеют проблемы, когда асинхронный метод вызывается несколько раз быстро друг за другом, например четыре раза. Затем все последующие вызовы этого метода отменяют первую задачу, и в итоге генерируются три новые задачи, которые запускаются одновременно. Поэтому я придумал это:

    private List<Tuple<Task, CancellationTokenSource>> _parameterExtractionTasks = new List<Tuple<Task, CancellationTokenSource>>();

    /// <remarks>This method is asynchronous, i.e. it runs partly in the background. As this method might be called multiple times 
    /// quickly after each other, a mechanism has been implemented that <b>all</b> tasks from previous method calls are first canceled before the task is started anew.</remarks>
    public async void ParameterExtraction() {

        CancellationTokenSource newCancellationTokenSource = new CancellationTokenSource();

        // Define the task which shall run in the background.
        Task newTask = new Task(() => {
            // do some work here
                }
            }
        }, newCancellationTokenSource.Token);

        _parameterExtractionTasks.Add(new Tuple<Task, CancellationTokenSource>(newTask, newCancellationTokenSource));

        /* Convert the list to arrays as an exception is thrown if the number of entries in a list changes while 
         * we are in a for loop. This can happen if this method is called again while we are waiting for a task. */
        Task[] taskArray = _parameterExtractionTasks.ConvertAll(item => item.Item1).ToArray();
        CancellationTokenSource[] tokenSourceArray = _parameterExtractionTasks.ConvertAll(item => item.Item2).ToArray();

        for (int i = 0; i < taskArray.Length - 1; i++) { // -1: the last task, i.e. the most recent task, shall be run and not canceled. 
            // Cancel all running tasks which were started by previous calls of this method
            if (taskArray[i].Status == TaskStatus.Running) {
                tokenSourceArray[i].Cancel();
                await taskArray[i]; // wait till the canceling completed
            }
        }

        // Get the most recent task
        Task currentThreadToRun = taskArray[taskArray.Length - 1];

        // Start this task if, but only if it has not been started before (i.e. if it is still in Created state). 
        if (currentThreadToRun.Status == TaskStatus.Created) {
            currentThreadToRun.Start();
            await currentThreadToRun; // wait till this task is completed.
        }

        // Now the task has been completed once. Thus we can recent the list of tasks to cancel or maybe run.
        _parameterExtractionTasks = new List<Tuple<Task, CancellationTokenSource>>();
    }