This is a bad practice on multiple levels. First, that Select... fires a task for all items at once, whether the machine or remote services can handle this or not. That's a great way to crash the local server, cause deadlocks or get throttled by remote services.
Second, what the code awaits is Task.WhenAll(IEnumerable<Task>), not the Select query. That LINQ query is the factory method that produces the live tasks. The query run and produced the IEnumerable. As the documentation remarks explain,
If any of the supplied tasks completes in a faulted state, the returned task will also complete in a Faulted state, where its exceptions will contain the aggregation of the set of unwrapped exceptions from each of the supplied tasks.
The stack trace isn't shallow. The exceptions will be thrown by the tasks, not the methods that produced the tasks. There's no parent/caller relation between them.
In case of failure Task.WhenAll will AggregateException containing all faults. This code will print both failures :
string[] traps=["b","e"];
var tsk=Task.WhenAll(items.Select(async item =>
{
await Task.Delay(10);
if (traps.Contains(item))
{
Console.WriteLine(item);
throw new Exception($"Failed on {item}");
}
}));
await Task.Delay(1000);
Console.WriteLine(tsk.Exception);
This prints :
e
b
System.AggregateException: One or more errors occurred. (Failed on b) (Failed on e)
---> System.Exception: Failed on b
at Program.<>c__DisplayClass0_0.<<Main>b__0>d.MoveNext()
--- End of inner exception stack trace ---
---> (Inner Exception #1) System.Exception: Failed on e
at Program.<>c__DisplayClass0_0.<<Main>b__0>d.MoveNext()<---
That's another problem - there's no guaranteed order to these exceptions. If one of the tasks takes longer to fail, the exception may appear later. Using this code :
if (item=="b")
await Task.Delay(100);
else
await Task.Delay(10);
Results in :
System.AggregateException: One or more errors occurred. (Failed on e) (Failed on b)
---> System.Exception: Failed on e
at Program.<>c__DisplayClass0_0.<<Main>b__0>d.MoveNext()
--- End of inner exception stack trace ---
---> (Inner Exception #1) System.Exception: Failed on b
at Program.<>c__DisplayClass0_0.<<Main>b__0>d.MoveNext()<---
Finally await unpacks the AggregateException and returns only the first real exception. This isn't a bug. Without this, chained tasks would return nested AggregateException objects, the way they did before 2012.
This code will only print one of the exceptions :
string[] traps=["b","e"];
try
{
await Task.WhenAll(items.Select(async item =>
{
await Task.Delay(10);
if (traps.Contains(item))
{
Console.WriteLine(item);
throw new Exception($"Failed on {item}");
}
}));
}
catch (Exception ex)
{
Console.WriteLine(ex);
}
This prints :
e
b
System.Exception: Failed on e
at Program.<>c__DisplayClass0_0.<<Main>b__0>d.MoveNext()
--- End of stack trace from previous location ---
at Program.Main()
On top of this, Task.WhenAll has no way of stopping the live tasks if one of them fails. Maybe we want to stop them, maybe we don't. We have no way of doing so right now.
How to fix this
At least one way is to process the data using Parallel.ForEachAsync. By default ForEachAsync uses as many worker tasks as virtual cores but that can be controlled.
await Parallel.ForEachAsync(items, async (item,cancellationToken) =>
{
await Task.Delay(10,cancellationToken);
if (traps.Contains(item))
{
Console.WriteLine(item);
throw new Exception($"Failed on {item}");
}
});
The number of concurrent tasks can be specified through the ParallelOptions argument. Cancellation can be controlled through the same parameter, or through an explicit CancellationToken parameter :
var options =new ParallelOptions { MaxDegreeOfParallelism=2 };
await Parallel.ForEachAsync(items, options, async (item,cancellationToken)...
Reporting/Monitoring/Cancelling
The solution to this is to not let exceptions leak outside the task methods. We can use eg ILogger to log exceptions or store intermediate results into a ConcurrentDictionary. Should we want to stop processing in case of error, we can do so by triggering the cancellation token, eg :
var cts=new CancellationTokenSource();
var failures=new ConcurrentDictionary<string,Exception>();
var results=new ConcurrentDictionary<string,Result>();
var options = new ParallelOptions {
CancellationToken = cts.Token,
MaxDegreeOfParallelism = 4
};
await Parallel.ForEachAsync(items, options, async (item,cancellationToken) =>
{
try
{
var result=await ActualAsyncMethod(item,cancellationToken)
results.TryAdd(item,result);
}
catch(VeryBadException e)
{
logger.LogError(e,"Crashed on {item}",item);
failures.TryAdd(item,e);
cts.Cancel();
}
catch(Exception e)
{
logger.LogWarning(e,"Failed on {item}",item);
failures.TryAdd(item,e);
}
}
await Parallel.ForEachAsync( source,options,async (item,ct)=>{...})will process items concurrently with controlled degree of parallelism, unlike thatitems.Selectawait Task.WhenAll(IEnumerable<Func<Task>>), not any LINQ operation. The query completed and produces results, all of which yielded.Task.WhenAllwaits on the continuations now. One more reason why such code should not be used. And isn't useditems.Select(async item => { … })the compiler generates an anonymous async method with no meaningful name. That lambda itself is never awaited directly – it’s wrapped into a Task, which is then passed toTask.WhenAll. So the trace has fewer obvious frames than a named method call.