Cancellation confusion and Task.Run

Spread the love

Code that heavily relies on Task.Run tends to have issues. In my experience, this is a clear sign that the asynchronous model within the codebase is spotty at best. Indeed, a “really async” design would not generally rely on Task.Run because one should be able to invoke methods that are already Task-aware. But, hey, there are commonly used blocking methods like GetFiles which we don’t control but still must shoehorn into an otherwise asynchronous system. Everything has its place after all, and I tend to agree that “considered harmful” should be considered harmful.

All that being said, Task.Run can be hard to use correctly, as Stephen Cleary points out. This is especially true when it is mixed with another beginner’s minefield, cooperative cancellation a la CancellationToken. Consider this innocuous example:

private static async Task RunWithTimeout(Action blocking, TimeSpan timeout)
{
    using (CancellationTokenSource cts = new CancellationTokenSource(timeout))
    {
        try
        {
            await Task.Run(blocking, cts.Token);
        }
        catch (OperationCanceledException)
        {
            throw new TimeoutException("Timed out!");
        }
    }
}

Ostensibly, this would run a blocking method but bail out after a given timeout if the method had not completed yet. Let’s try it:

private static async Task TryIt()
{
    Console.WriteLine("Starting...");
    Stopwatch stopwatch = Stopwatch.StartNew();
    try
    {
        await RunWithTimeout(() => Thread.Sleep(5000), TimeSpan.FromMilliseconds(100));
    }
    catch (Exception e)
    {
        Console.WriteLine("Caught exception: {0}", e);
    }

    stopwatch.Stop();
    Console.WriteLine("Done. Waited {0} ms.", stopwatch.ElapsedMilliseconds);
}

Unfortunately, the output of the program is as follows:

Starting...
Done. Waited 5006 ms.

Didn’t we cancel after 100 milliseconds? Yes, but the cancellation has no effect on Task.Run unless the task has not yet started. You can observe this by changing the timeout from 100 ms to TimeSpan.Zero:

Starting...
Caught exception: System.TimeoutException: Timed out!
 . . . 
Done. Waited 35 ms.

In this case, the CancellationToken was in the canceled state right from the start, preventing the action from ever being invoked and letting us quickly get back to work.

If you really want this pattern to work, you will need some more machinery. The most basic pattern might look like this:

private static async Task RunWithTimeout(Action blocking, TimeSpan timeout)
{
    using (CancellationTokenSource cts = new CancellationTokenSource(timeout))
    {
        Task main = Task.Run(blocking, cts.Token);
        Task wait = Task.Delay(-1, cts.Token);
        Task result = await Task.WhenAny(main, wait);
        if (cts.IsCancellationRequested)
        {
            throw new TimeoutException("Timed out!");
        }
        else
        {
            cts.Cancel();
        }

        await result;
    }
}

Here we launch two tasks concurrently. The first task is the main blocking action, as before. The second is an infinite wait, for which the only purpose is to observe the cancellation after the timeout. At least one of these tasks will complete, and the WhenAny call will return the task which actually did. However, we don’t actually need to check the result if cancellation has happened; we can be assured that the timeout has expired in that case. Otherwise, we explicitly cancel (to unblock that no-longer-needed Task.Delay and free up resources), and simply await the result which will necessarily be the “main” task.

Running the sample again, we now get the intended result:

Starting...
Caught exception: System.TimeoutException: Timed out!
 . . . 
Done. Waited 138 ms.

If you find yourself in the land of Task.Run, heed the above warnings. A task you thought would be canceled may not be so cooperative.

Leave a Reply

Your email address will not be published. Required fields are marked *