One major difference is in exception propagation. An exception, thrown inside an async Task
method, gets stored in the returned Task
object and remains dormant until the task gets observed via await task
, task.Wait()
, task.Result
or task.GetAwaiter().GetResult()
. It is propagated this way even if thrown from the synchronous part of the async
method.
Consider the following code, where OneTestAsync
and AnotherTestAsync
behave quite differently:
static async Task OneTestAsync(int n)
{
await Task.Delay(n);
}
static Task AnotherTestAsync(int n)
{
return Task.Delay(n);
}
// call DoTestAsync with either OneTestAsync or AnotherTestAsync as whatTest
static void DoTestAsync(Func<int, Task> whatTest, int n)
{
Task task = null;
try
{
// start the task
task = whatTest(n);
// do some other stuff,
// while the task is pending
Console.Write("Press enter to continue");
Console.ReadLine();
task.Wait();
}
catch (Exception ex)
{
Console.Write("Error: " + ex.Message);
}
}
If I call DoTestAsync(OneTestAsync, -2)
, it produces the following output:
Press enter to continue
Error: One or more errors occurred.await Task.Delay
Error: 2nd
Note, I had to press Enter to see it.
Now, if I call DoTestAsync(AnotherTestAsync, -2)
, the code workflow inside DoTestAsync
is quite different, and so is the output. This time, I wasn't asked to press Enter:
Error: The value needs to be either -1 (signifying an infinite timeout), 0 or a positive integer.
Parameter name: millisecondsDelayError: 1st
In both cases Task.Delay(-2)
throws at the beginning, while validating its parameters. This might be a made-up scenario, but in theory Task.Delay(1000)
may throw too, e.g., when the underlying system timer API fails.
On a side note, the error propagation logic is yet different for async void
methods (as opposed to async Task
methods). An exception raised inside an async void
method will be immediately re-thrown on the the current thread's synchronization context (via SynchronizationContext.Post
), if the current thread has one (SynchronizationContext.Current != null)
. Otherwise, it will be re-thrown via ThreadPool.QueueUserWorkItem
). The caller doesn't have a chance to handle this exception on the same stack frame.
I posted some more details about TPL exception handling behaviour here and here.
Q: Is it possible to mimic the exception propagation behavior of async
methods for non-async Task
-based methods, so that the latter doesn't throw on the same stack frame?
A: If really needed, then yes, there is a trick for that:
// async
async Task<int> MethodAsync(int arg)
{
if (arg < 0)
throw new ArgumentException("arg");
// ...
return 42 + arg;
}
// non-async
Task<int> MethodAsync(int arg)
{
var task = new Task<int>(() =>
{
if (arg < 0)
throw new ArgumentException("arg");
// ...
return 42 + arg;
});
task.RunSynchronously(TaskScheduler.Default);
return task;
}
Note however, under certain conditions (like when it's too deep on the stack), RunSynchronously
could still execute asynchronously.
Another notable difference is that
the async
/await
version is more prone to dead-locking on a non-default synchronization context. E.g., the following will dead-lock in a WinForms or WPF application:
static async Task TestAsync()
{
await Task.Delay(1000);
}
void Form_Load(object sender, EventArgs e)
{
TestAsync().Wait(); // dead-lock here
}
Change it to a non-async version and it won't dead-lock:
Task TestAsync()
{
return Task.Delay(1000);
}
The nature of the dead-lock is well explained by Stephen Cleary in his blog.