Theodor Zoulias
Theodor Zoulias

Reputation: 43836

A canceled task propagates two different types of exceptions, depending on how it is waited. Why?

I encountered a strange behavior while writing some complex async/await code. I managed to create accidentally a canceled Task with a dual (schizophrenic) identity. It can either throw a TaskCanceledException or an OperationCanceledException, depending on how I wait it.

  1. Waiting it with Wait throws an AggregateException, that contains a TaskCanceledException.
  2. Waiting it with await throws an OperationCanceledException.

Here is a minimal example that reproduces this behavior:

var canceledToken = new CancellationToken(true);
Task task = Task.Run(() =>
{
    throw new OperationCanceledException(canceledToken);
});

try { task.Wait(); } // First let's Wait synchronously the task
catch (AggregateException aex)
{
    var ex = aex.InnerException;
    Console.WriteLine($"task.Wait() failed, {ex.GetType().Name}: {ex.Message}");
}

try { await task; } // Now let's await the same task asynchronously
catch (Exception ex)
{
    Console.WriteLine($"await task failed, {ex.GetType().Name}: {ex.Message}");
}

Console.WriteLine($"task.Status: {task.Status}");

Output:

task.Wait() failed, TaskCanceledException: A task was canceled.
await task failed, OperationCanceledException: The operation was canceled.
task.Status: Canceled

Try it on Fiddle.

Can anyone explain why is this happening?

P.S. I know that the TaskCanceledException derives from the OperationCanceledException. Still I don't like the idea of exposing an async API that demonstrates such a weird behavior.


Variants: The task below has a different behavior:

Task task = Task.Run(() =>
{
    canceledToken.ThrowIfCancellationRequested();
});

This one completes in a Faulted state (instead of Canceled), and propagates an OperationCanceledException with either Wait or await. This is quite puzzling because the CancellationToken.ThrowIfCancellationRequested method does nothing more than throwing an OperationCanceledException, according to the source code!

Also the task below demonstrates yet another different behavior:

Task task = Task.Run(() =>
{
    return Task.FromCanceled(canceledToken);
});

This task completes as Canceled, and propagates a TaskCanceledException with either Wait or await.

I have no idea what's going on here!

Upvotes: 2

Views: 1325

Answers (1)

Marc Gravell
Marc Gravell

Reputation: 1063774

Summarising multiple comments, but:

  • Task.Wait() is a legacy API that pre-dates await
  • for historic reasons, .Wait() would manifest cancellation as TaskCanceledException; to preserve backwards compatibility, .Wait() intervenes here, to expose all OperationCanceledException faults as TaskCanceledException, so that existing code continues to work correctly (in particular, so that existing catch (TaskCanceledException) handlers continue to work)
  • await uses a different API; .GetAwaiter().GetResult(), which behaves more like you would expect and want (although it is not expected to be used until the task is known to have completed), BUT!
  • in reality, you should almost never use .Wait() or .GetAwaiter().GetResult(), preferring await in almost all cases - see "sync over async"
  • if you're consuming an async API, and you choose (for whatever reason) to use .Wait() or GetAwaiter().GetResult(), then you are stepping into danger, and any consequences are now entirely your fault as the consumer; this is not something that a library author can, or should, compensate for (other than providing twin synchronous and asynchronous APIs)
  • in particular, note that while you might get away with subverting the awaiter API with Task[<T>], this pattern with various other awaitables would be an undefined behaviour (to be honest, I'm not sure it is really "defined" for Task[<T>])
  • equally: any deadlocks caused by "sync over async" (usually sync-context related) are entirely the problem of the consumer invoking a synchronous wait on an awaitable result
  • if in doubt: await (but equally, only await once; using await multiple times is also an undefined behaviour for awaitables other than Task[<T>])
  • for your exception handling: prefer catch (OperationCanceledException) over catch (TaskCanceledException), since the former will handle both via inheritance

Upvotes: 4

Related Questions