Executing tasks serially on the ThreadPool
is quite easy, by using the ExclusiveScheduler
property of a ConcurrentExclusiveSchedulerPair
instance, and using it as a TaskScheduler
every time we start a task:
var taskFactory = new TaskFactory(
new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
Task task1 = taskFactory.StartNew(() => DoSomething());
Task task2 = taskFactory.StartNew(() => DoSomethingElse());
The DoSomething()
and DoSomethingElse
will both run on the ThreadPool
, the one after the other. It is guaranteed that the two invocations will not overlap, and also that they will be invoked in the same order that they were scheduled initially.
But what will happen if any of these invocations fail? Here is the problem: any exception thrown by the DoSomething()
or the DoSomethingElse
will be trapped inside the respective Task
(the task1
or the task2
). Which means that we can't just start the tasks and forget about them. We have the responsibility to store the tasks somewhere, and eventually await
them and handle their exceptions. Which may be exactly what we want.
But what if we just want to schedule the tasks and "forget" about them, and in the unlikely scenario that any of them fails to have the exception propagate as an unhandled exception and terminate the process? This is not as crazy as it sounds. Some tasks may be so critical for the life of the application, and so unlikely that they'll ever fail, and so hard to devise a strategy for observing their exceptions manually, that having their exception escalate to an instant application termination (after raising the AppDomain.UnhandledException
event) may be the lesser evil of the available options. So is it possible to do this? Yes, but it is surprising difficult and tricky:
using System.Runtime.ExceptionServices;
var taskFactory = new TaskFactory(
new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
void RunOnThreadPoolExclusive(Action action)
{
_ = taskFactory.StartNew(() =>
{
try
{
action();
}
catch (Exception ex)
{
var edi = ExceptionDispatchInfo.Capture(ex);
ThreadPool.QueueUserWorkItem(_ => edi.Throw());
}
});
}
RunOnThreadPoolExclusive(() => DoSomething());
RunOnThreadPoolExclusive(() => DoSomethingElse());
The action
is invoked in a try/catch block. In case of failure, the exception is captured in a ExceptionDispatchInfo
instance, to preserve its stack trace, and then it's rethrown on the ThreadPool
. Notice that the taskFactory.StartNew
still returns a Task
, which is discarded by using a discard (_
), because now it's highly unlikely that the task can fail. But did we really make any progress? We started with the premise that the DoSomething
is very unlikely to fail, and we ended up discarding a Task
that we assess as highly unlikely to fail. Not very satisfying indeed! Can we do better? Yes! Enter the infamous world of async void
:
var taskFactory = new TaskFactory(
new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
async void RunOnThreadPoolExclusive(Action action)
{
await taskFactory.StartNew(action);
}
RunOnThreadPoolExclusive(() => DoSomething());
RunOnThreadPoolExclusive(() => DoSomethingElse());
The async void
methods have the interesting characteristic that any exception thrown inside them is raised on the SynchronizationContext
that was captured when the async void
method started, or (as a fallback) on the ThreadPool
. So if for example the RunOnThreadPoolExclusive
is invoked on the UI thread of a WinForms application, and the action fails, a message box will popup, asking the user if they want to continue or quit the application (screenshot). So the error is not necessarily fatal, since the user may opt to ignore the error and continue. Which might be exactly what we want. Or might not.
To clarify, the error will be thrown on the UI thread, but the DoSomething()
/DoSomethingElse()
will still be invoked on the ThreadPool
. This has not changed.
So how exactly can we ensure that the error will be thrown on the ThreadPool
, and nowhere else, no matter what, irrespective of the current context, and without allowing any task to become fire-and-forget? Here is how:
var taskFactory = new TaskFactory(
new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
void RunOnThreadPoolExclusive(Action action)
{
Task task = taskFactory.StartNew(action);
ThreadPool.QueueUserWorkItem(async state => await (Task)state, task);
}
RunOnThreadPoolExclusive(() => DoSomething());
RunOnThreadPoolExclusive(() => DoSomethingElse());
Serialized execution on the ThreadPool
, in the correct order, with the errors thrown on the ThreadPool
, and with no leaked fire-and-forget tasks. Perfect!