1. Introduction
Writing programs that never crash is like believing bugs are afraid of your IDE. In reality, the more complex the code gets (especially in multithreaded and async environments), the more creative bugs become and the trickier the errors. Ignoring exceptions in threads and tasks easily leads to resource leaks, hangs, data loss, or unexpected crashes hours after startup.
In this lecture we’ll pull together best practices for handling errors in multithreaded and asynchronous programming in C#. You’ll learn how to properly catch exceptions, how to unpack a “bunch” of simultaneous errors (like AggregateException), what to do with tasks started “fire-and-forget”, and why ignoring exceptions is dangerous and pointless.
Why it’s not that simple
- The thread where an error occurs may differ from the thread where the try-catch lives.
- Async tasks don’t throw exceptions immediately — they’re “packaged” and wait to be observed at await (or synchronous waiting).
- When working with multiple tasks concurrently (for example, Task.WhenAll) multiple errors can occur — you need to account for them.
- fire-and-forget operations can completely “lose” an exception if there’s no explicit handler.
This behavior is execution context separation. Imagine the app as a circus with multiple rings: if there’s a fire in one ring, it’s not immediately visible in the others. You need to be able to track and put out those “fires” correctly.
2. Exceptions in tasks: Task and Task<TResult>
How tasks signal errors
When an unhandled exception happens inside a task, it doesn't “escape” immediately. The task becomes Faulted, and the exception is kept inside. You can get it:
- By awaiting completion with await (or via task.Wait()/task.Result — but better not to);
- By checking the task.Exception property — it will be an AggregateException.
Example
// Asynchronous method with an error
async Task FailAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Something went wrong");
}
async Task MainAsync()
{
try
{
await FailAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
If you don’t put a try-catch, the program will crash. If you do — the exception will be caught correctly, even if the error happened in another task.
AggregateException: bulk errors
When you await Task.WhenAll(tasks) errors from multiple tasks are collected into a single AggregateException (in its InnerExceptions).
async Task MultiFailAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("Error 1"));
Task t2 = Task.Run(() => throw new ArgumentException("Error 2"));
try
{
await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
if (ex is AggregateException agg)
{
foreach (var e in agg.InnerExceptions)
Console.WriteLine($"Exception: {e.Message}");
}
else
{
Console.WriteLine($"Single error: {ex.Message}");
}
}
}
Note: when using await Task.WhenAll(tasks) .NET “unwraps” AggregateException, and in the catch you will get the “first” exception. The full list is available via task.Exception.InnerExceptions if the task completed with an error.
3. “Fire and Forget”: why you can’t just forget a task
“Start and forget” often leads to hidden failures. Example:
Task.Run(() => { throw new Exception("Boom!"); }); // The error "flies" into the void.
The modern runtime stores the error in the task and may terminate the process because of an unobserved exception. The best approach is to keep a reference to the task and/or subscribe to TaskScheduler.UnobservedTaskException.
How to do it right?
- Keep the task so you can await it and handle the error;
- For fire‑and‑forget put a handler inside the delegate itself.
Task.Run(() => {
try
{
// Code that may throw
}
catch (Exception ex)
{
// Log, notify, but don't let the error escape
Console.WriteLine($"Error in fire-and-forget: {ex.Message}");
}
});
4. Errors in threads (Thread): "nothing to catch?"
An exception in a new Thread can’t be caught by an external try-catch — only inside the thread body.
var thread = new Thread(() =>
{
try
{
throw new Exception("Error in thread");
}
catch (Exception ex)
{
Console.WriteLine($"Caught error inside thread: {ex.Message}");
}
});
thread.Start();
If you don’t put a handler, the exception will terminate only that thread (if it’s a background thread: thread.IsBackground = true). For foreground threads, an unhandled exception can kill the whole process. Always put a try-catch inside the thread.
How to return results and errors from a thread?
- Use queues/collections to pass results/errors;
- Event model;
- Prefer switching to tasks — they’re easier to handle errors with.
5. Parallel loops: catch errors a bit differently
In parallel loops errors from different branches are aggregated into AggregateException.
try
{
Parallel.For(0, 5, i =>
{
if (i % 2 == 0)
throw new Exception($"Error in iteration {i}");
});
}
catch (AggregateException ex)
{
foreach (var e in ex.InnerExceptions)
Console.WriteLine($"[Parallel loop] Error: {e.Message}");
}
If you need to log local failures and continue other branches, put an inner try-catch in each branch.
6. Handling errors on cancellation of tasks
When cancelling via CancellationToken the convention is to throw OperationCanceledException — this is not a failure, but a normal stop.
async Task DoWorkAsync(CancellationToken token)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
}
// Somewhere in the code:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // About 200 ms later
try
{
await task;
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was canceled!");
}
Besides ThrowIfCancellationRequested(), many methods that accept a token (for example, Task.Delay, HttpClient.GetAsync) will themselves throw OperationCanceledException. Check whether cancellation is supported.
7. Useful nuances
Approaches for all cases
- Put try-catch at the top level of async methods — this way you’ll catch “escaped” errors.
- Don’t ignore tasks: keep references, await completions, log errors.
- For parallel operations (Task.WhenAll / Parallel.ForEach) account for AggregateException.
- Separate cancellation from failures: catch OperationCanceledException separately.
- Log errors, especially “quiet” ones that don’t crash the app.
- Preserve details: log all inner exceptions, not just the first.
- Handle errors locally: if you don’t know what to do — at least log.
Where to catch errors in multithreaded and async code
flowchart TD
A[Main thread / UI] -->|Starts task| B[Task/async]
B -->|Inside task| C[try/catch inside async method]
B -->|await on main thread| D[try/catch around await]
A -->|Starts Thread| E[Thread]
E -->|Inside| F[try/catch inside thread]
B -->|Many tasks| G[Task.WhenAll / Parallel.ForEach]
G -->|Error| H[AggregateException]
8. Typical mistakes and pitfalls
Mistake #1: waiting on a task via .Result or .Wait(). This can cause deadlocks and/or unexpected AggregateException.
Mistake #2: launching fire‑and‑forget without an internal try-catch — the task can silently fail with no diagnostics.
Mistake #3: missed errors in parallel loops — some work is not done and you don’t notice.
Mistake #4: not distinguishing cancellation (OperationCanceledException) from real errors.
Mistake #5: logging only the first error among multiple tasks — the others stay “in the shadow”.
Mistake #6: reusing one Exception object for all tasks — each error should have its own instance.
Mistake #7: no exception handling in the UI thread — a background error goes unnoticed, and the UI behaves “ghostly”.
GO TO FULL VERSION