1

I’m using Task.WhenAll with LINQ to run multiple async operations. However, when an exception is thrown inside one of the tasks, the stack trace seems incomplete compared to when I await each task directly.

The code I'm using:

public async Task RunAsync()
{
    var items = new[] { "a", "b", "c" };

    try
    {
        await Task.WhenAll(items.Select(async item =>
        {
            await Task.Delay(10);
            if (item == "b")
                throw new InvalidOperationException("Failed on b");
        }));
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex); // Stack trace looks shallow
    }
}

If I instead foreach the items and await each task separately, the stack trace is much clearer.

Question:

Why does Task.WhenAll combined with LINQ Select result in less detailed stack traces for exceptions? Is there a best practice to preserve full stack trace details in this pattern?

5
  • 2
    The best practice is to not use LINQ to spawn a myriad of tasks. What does the real code look like? What are you trying to achieve? Whatever it is, there are infinitely better ways to do it. Eg await Parallel.ForEachAsync( source,options,async (item,ct)=>{...}) will process items concurrently with controlled degree of parallelism, unlike that items.Select Commented Sep 8 at 9:16
  • 2
    PS: Post the exception in the question itself. Don't make people run the code just to see what you mean. Commented Sep 8 at 9:17
  • 1
    Could we have look at stacktrace? Commented Sep 8 at 9:20
  • 1
    Also, what you mean by shallow. What did you expect? Remember, you're looking at the behavior of await Task.WhenAll(IEnumerable<Func<Task>>), not any LINQ operation. The query completed and produces results, all of which yielded. Task.WhenAll waits on the continuations now. One more reason why such code should not be used. And isn't used Commented Sep 8 at 9:26
  • 1
    items.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 to Task.WhenAll. So the trace has fewer obvious frames than a named method call. Commented Sep 8 at 9:36

1 Answer 1

5

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);    
    }
}
Sign up to request clarification or add additional context in comments.

Comments

Your Answer

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

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.