C#
Task.WaitAll
multithreading
asynchronous programming
debugging

Task.WaitAll is not waiting - Explanation

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

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:

csharp
1Task task1 = Task.Run(() => DoWork1());
2Task task2 = Task.Run(() => DoWork2());
3
4Task.WaitAll(task1, task2);
5Console.WriteLine("Both tasks finished.");

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.

csharp
1// BUG: Task.WaitAll returns too early
2Task task1 = Task.Run(async () =>
3{
4    await Task.Delay(2000);
5    Console.WriteLine("Task 1 done");  // prints AFTER WaitAll returns
6});
7
8Task task2 = Task.Run(async () =>
9{
10    await Task.Delay(1000);
11    Console.WriteLine("Task 2 done");
12});
13
14Task.WaitAll(task1, task2);
15Console.WriteLine("All done?");  // prints before "Task 1 done"

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.

csharp
1// BUG: async void is invisible to WaitAll
2async void FireAndForget()
3{
4    await Task.Delay(2000);
5    Console.WriteLine("Finished");
6}
7
8Task task = Task.Run(() => FireAndForget());
9// task completes immediately because FireAndForget returns void
10Task.WaitAll(task);
11Console.WriteLine("All done?");  // prints before "Finished"

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.

csharp
1// BUG: waiting on the outer task, not the inner one
2Task outerTask = Task.Factory.StartNew(async () =>
3{
4    await Task.Delay(2000);
5    Console.WriteLine("Inner work done");
6});
7
8Task.WaitAll(outerTask);
9Console.WriteLine("All done?");  // prints before "Inner work done"

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.

csharp
1async Task DoWorkAsync()
2{
3    await Task.Delay(2000);
4    Console.WriteLine("Work done");
5}
6
7Task task1 = DoWorkAsync();
8Task task2 = DoWorkAsync();
9
10Task.WaitAll(task1, task2);
11Console.WriteLine("All done");  // correctly waits

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.

csharp
1Task task = Task.Run(async () =>
2{
3    await Task.Delay(2000);
4    Console.WriteLine("Done");
5});
6
7Task.WaitAll(task);  // correctly waits

Unwrap Manually If You Must Use StartNew

If you have a reason to use Task.Factory.StartNew, call .Unwrap() to get the inner task.

csharp
1Task task = Task.Factory.StartNew(async () =>
2{
3    await Task.Delay(2000);
4    Console.WriteLine("Done");
5}).Unwrap();
6
7Task.WaitAll(task);  // correctly waits

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.

csharp
1async Task RunAllAsync()
2{
3    Task t1 = DoWorkAsync();
4    Task t2 = DoWorkAsync();
5    await Task.WhenAll(t1, t2);  // non-blocking, deadlock-safe
6}

Common Pitfalls

  • Using async void: Methods with an async void signature cannot be awaited or tracked. Always use async Task unless you are writing an event handler.
  • Mixing StartNew with async lambdas: Task.Factory.StartNew does not unwrap nested tasks. Use Task.Run or call .Unwrap() explicitly.
  • Blocking in an async context: Calling Task.WaitAll on a thread with a SynchronizationContext (like a UI thread) can deadlock. Use await Task.WhenAll instead.
  • Catching only AggregateException: WaitAll wraps faulted-task exceptions in an AggregateException. Forgetting to flatten or inspect inner exceptions hides the real error.
  • Ignoring cancellation: If any task is canceled, WaitAll throws an AggregateException containing a TaskCanceledException. Handle this explicitly if you use CancellationToken.

Summary

  • Task.WaitAll appears not to wait when the tasks it receives do not represent the full asynchronous operation.
  • The most frequent cause is async void methods or un-unwrapped Task<Task> objects from Task.Factory.StartNew.
  • Always return Task from async methods and prefer Task.Run over StartNew for async lambdas.
  • In async code, prefer await Task.WhenAll() over the blocking Task.WaitAll() to avoid deadlocks.
  • Call .Unwrap() on Task<Task> when StartNew is unavoidable.

Related reading
Course
Intermediate
27 lessons
14 hours
OOD Fundamentals

Master object-oriented design from first principles, SOLID, design patterns, and classic interview problems with hands-on coding.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.