Task.WaitAll is not waiting - Explanation
Master System Design with Codemia
Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.
Introduction
Task.WaitAll is one of the most commonly used synchronization methods in .NET's Task Parallel Library (TPL). It blocks the calling thread until every task in the provided array has completed. Yet many developers run into a confusing situation where Task.WaitAll appears to return before all the work is actually finished. This article explains why that happens and how to fix it.
How Task.WaitAll Works
Task.WaitAll accepts an array of Task objects and blocks until each one transitions to a final state (completed, faulted, or canceled). Here is its simplest form:
This code works correctly when DoWork1 and DoWork2 are synchronous methods. The problem begins when asynchronous code enters the picture.
The Root Cause: async void and Unwrapped Tasks
The most common reason Task.WaitAll does not seem to wait is that the tasks it receives are not tracking the actual asynchronous work. This happens in two ways.
Passing an async void Lambda
When you pass an async void lambda to Task.Run, the returned Task represents only the synchronous portion of the lambda, up to the first await. Everything after that await runs as a fire-and-forget continuation.
Wait -- the above actually does work correctly because Task.Run has an overload that accepts Func<Task> and unwraps the inner task. So the above is fine. The real bug shows up when you store the tasks incorrectly or use async void delegates directly.
Because FireAndForget returns void, Task.Run sees a synchronous Action that returns immediately. The asynchronous continuation is completely invisible to the task system.
Forgetting to Unwrap Nested Tasks
Another variant occurs when you accidentally create a Task<Task> and wait on the outer task only.
Task.Factory.StartNew does not have a Func<Task> unwrapping overload like Task.Run does. The returned object is a Task<Task>, and calling WaitAll on it only waits for the outer task to finish -- which happens as soon as the inner Task object is created, not when it completes.
The Fix
Use async Task Instead of async void
Always return a Task from asynchronous methods so the caller can track their completion.
Use Task.Run Instead of Task.Factory.StartNew
Task.Run automatically unwraps Task<Task> when you pass a Func<Task> lambda. Prefer it over StartNew for async delegates.
Unwrap Manually If You Must Use StartNew
If you have a reason to use Task.Factory.StartNew, call .Unwrap() to get the inner task.
Prefer await Task.WhenAll in Async Contexts
If you are already inside an async method, use await Task.WhenAll() instead of Task.WaitAll(). The blocking call WaitAll can cause deadlocks in UI or ASP.NET synchronization contexts because it blocks the thread that the continuations need to resume on.
Common Pitfalls
- Using
async void: Methods with anasync voidsignature cannot be awaited or tracked. Always useasync Taskunless you are writing an event handler. - Mixing
StartNewwith async lambdas:Task.Factory.StartNewdoes not unwrap nested tasks. UseTask.Runor call.Unwrap()explicitly. - Blocking in an async context: Calling
Task.WaitAllon a thread with aSynchronizationContext(like a UI thread) can deadlock. Useawait Task.WhenAllinstead. - Catching only
AggregateException:WaitAllwraps faulted-task exceptions in anAggregateException. Forgetting to flatten or inspect inner exceptions hides the real error. - Ignoring cancellation: If any task is canceled,
WaitAllthrows anAggregateExceptioncontaining aTaskCanceledException. Handle this explicitly if you useCancellationToken.
Summary
Task.WaitAllappears not to wait when the tasks it receives do not represent the full asynchronous operation.- The most frequent cause is
async voidmethods or un-unwrappedTask<Task>objects fromTask.Factory.StartNew. - Always return
Taskfrom async methods and preferTask.RunoverStartNewfor async lambdas. - In async code, prefer
await Task.WhenAll()over the blockingTask.WaitAll()to avoid deadlocks. - Call
.Unwrap()onTask<Task>whenStartNewis unavoidable.

