3
\$\begingroup\$

Some Task methods don't take cancellation tokens. This is a problem because some long-running tasks may never finish and there is no way to send them a cancellation request. This seems a bit dodgy to me but this is a workaround. Is there anything wrong with this? Is there a better way?

public static Task<T> SynchronizeWithCancellationToken<T>(this Task<T> task, int delayMilliseconds = 10, CancellationToken cancellationToken = default)
{
    if (task == null) throw new ArgumentNullException(nameof(task));

    while (!task.IsCompleted && !task.IsFaulted && !task.IsCanceled)
    {
        Thread.Sleep(delayMilliseconds);

        if (cancellationToken.IsCancellationRequested)
        {
            return Task.FromCanceled<T>(cancellationToken);
        }
    }

    return task;
}

Example

The methods where this is a problem tend to be obscure APIs that haven't had much love in a long time or APIs that are wrappers for other platforms. One example is RequestWaitAsync on UsbDeviceConnection. It's a wrapper for an Android USB API . The issue can be worked around like so:

var buffers = await GetReadResultAsync(bufferLength, byteBuffer).SynchronizeWithCancellationToken(cancellationToken);

Another way to workaround the issue might be with Task.Run

public async Task<ReadResult> ReadAsync(uint bufferLength, CancellationToken cancellationToken = default)
{
    return await Task.Run(async () =>
    {
        try
        {

            //...

            return await GetReadResultAsync(bufferLength, byteBuffer);

        }
        catch (Exception ex)
        {
            //...
        }
    }, cancellationToken);
}

Which of these two work arounds are the safest?

Unit Test

This is a pathetic attempt at a unit test. It passes:

[TestMethod]
public async Task TestSynchronizeWithCancellationToken()
{
    var stopWatch = new Stopwatch();
    stopWatch.Start();

    var completed = false;

    var task = Task.Run<bool>(() =>
    {
        //Iterate for one second
        for (var i = 0; i < 100; i++)
        {
            Thread.Sleep(10);
        }

        return true;
    });

    var cancellationTokenSource = new CancellationTokenSource();

    //Start a task that will cancel in 500 milliseconds
    var cancelTask = Task.Run<bool>(() =>
    {
        Thread.Sleep(500);
        cancellationTokenSource.Cancel();
        return true;
    });

    //Get a task that will finish when the cancellation token is cancelled
    var syncTask = task.SynchronizeWithCancellationToken(cancellationToken: cancellationTokenSource.Token);

    //Wait for the first task to finish
    var completedTask = (Task<bool>) await Task.WhenAny(new Task[]
    {
        syncTask,
        cancelTask
    });

    //Ensure the task didn't wait a long time
    Assert.IsTrue(stopWatch.ElapsedMilliseconds < 1000);

    //Ensure the task wasn't completed
    Assert.IsFalse(completed);
}
\$\endgroup\$
2
  • 1
    \$\begingroup\$ You might want to take a look at stackoverflow.com/a/25652873 \$\endgroup\$ Commented Mar 16, 2020 at 13:06
  • \$\begingroup\$ FWIW nothing in this code snippet sets completed to true \$\endgroup\$
    – David
    Commented Nov 8, 2023 at 16:30

1 Answer 1

4
\$\begingroup\$

Starting with the first function, I would make a few changes:

public static async Task<T> SynchronizeWithCancellationToken<T>(this Task<T> task, int delayMilliseconds = 10, CancellationToken cancellationToken = default)
{
    if (task == null)
        throw new ArgumentNullException(nameof(task));

    while (!task.IsCompleted)
    {
        await Task.Delay(delayMilliseconds);
        cancellationToken.ThrowIfCancellationRequested();
    }

    return await task;
}

This is about as good as it gets to "add cancellation" to a Task that doesn't do cancellation. The key to understanding Task cancellation in .NET is that it is cooperative. This means that simply passing a cancellation token around doesn't do anything on its own; instead the Task implementation itself has to handle cancellation. This makes a lot of sense since only the Task implementation itself knows when best to allow cancellation without causing invalid state or corruption.

I would definitely recommend taking the time to do a read-through of the MSDN article on this: https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation?view=netframework-4.8

Your second function attempts to use the Task.Run overload that takes a CancellationToken in order to cancel the operation. However, Task.Run only uses the token to avoid starting the Task (if it's already cancelled when it goes to run it), and it's left to the user to use the token inside the implementation passed to Task.Run to handle further cancellation. See here: https://stackoverflow.com/questions/22637642/using-cancellationtoken-for-timeout-in-task-run-does-not-work

The first method is definitely the one you want to use. Just remember that it isn't true cancellation which would require you to modify the implementation of the API function and add the cooperative cancellation bits. When you add cancellation like this, without the implementation bits, you're only cancelling the wait for the Task to finish, not the Task itself. If the Task has further operations to carry out, they will still be carried out. This can often times be a deal breaker with some APIs, and you might be forced to find an alternative implementation that handles cancellation, or even implement your own.

Edit:

It also slipped my mind that you can use CancellationToken.Register to asynchronously wait and get rid of the polling...

    public static async Task ToTask(this CancellationToken token)
    {
        var source = new TaskCompletionSource<object>();
        using (token.Register(() => source.SetResult(null)))
            await source.Task;
    }

    public static async Task<T> SynchronizeWithCancellationToken<T>(this Task<T> task,
        CancellationToken cancellationToken = default, string message = default)
    {
        if (task == null)
            throw new ArgumentNullException(nameof(task));

        if (await Task.WhenAny(task, cancellationToken.ToTask()) == task)
            return await task;
        else
            throw new OperationCanceledException(message ?? "The operation was canceled.");
    }
\$\endgroup\$
3
  • \$\begingroup\$ I don't think you need to wait for the task twice, since you've already "awaited" for it using WhenAny, you can just get the result, right? \$\endgroup\$ Commented Nov 24, 2021 at 16:23
  • \$\begingroup\$ One reason to reason to use await instead of .Result in this case is that .Result will always wrap any exceptions thrown by the Task in an AggregateException which can be annoying to handle further up. There's probably a few other quirks I can't think of at the moment as well... \$\endgroup\$
    – user220212
    Commented Nov 27, 2021 at 1:40
  • 1
    \$\begingroup\$ Ah yep it was something to do with deadlocks; here's the more in-depth info on it, if you're interested: stackoverflow.com/questions/24623120/… \$\endgroup\$
    – user220212
    Commented Nov 27, 2021 at 1:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.