There is no way to embed rich async features into a custom TaskScheduler
. This class was not designed with async
/await
in mind. The standard way to use a custom TaskScheduler
is as an argument to the Task.Factory.StartNew
method. This method does not understand async delegates. It is possible to provide an async delegate, but it is treated as any other delegate that returns some result. To get the actual awaited result of the async delegate one must call Unwrap()
to the task returned.
This is not the problem though. The problem is that the TaskScheduler
infrastructure does not treat the async delegate as a single unit of work. Each task is split into multiple mini-tasks (using every await
as a separator), and each mini-task is processed individually. This severely restricts the asynchronous functionality that can be implemented on top of this class. As an example here is a custom TaskScheduler
that is intended to queue the supplied tasks one at a time (to limit the concurrency in other words):
public class MyTaskScheduler : TaskScheduler
{
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);
protected async override void QueueTask(Task task)
{
await _semaphore.WaitAsync();
try
{
await Task.Run(() => base.TryExecuteTask(task));
await task;
}
finally
{
_semaphore.Release();
}
}
protected override bool TryExecuteTaskInline(Task task,
bool taskWasPreviouslyQueued) => false;
protected override IEnumerable<Task> GetScheduledTasks() { yield break; }
}
The SemaphoreSlim
should ensure that only one Task
would run at a time. Unfortunately it doesn't work. The semaphore is released prematurely, because the Task
passed in the call QueueTask(task)
is not the task that represents the whole work of the async delegate, but only the part until the first await
. The other parts are passed to the TryExecuteTaskInline
method. There is no way to correlate these task-parts, because no identifier or other mechanism is provided. Here is what happens in practice:
var taskScheduler = new MyTaskScheduler();
var tasks = Enumerable.Range(1, 5).Select(n => Task.Factory.StartNew(async () =>
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Item {n} Started");
await Task.Delay(1000);
Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Item {n} Finished");
}, default, TaskCreationOptions.None, taskScheduler))
.Select(t => t.Unwrap())
.ToArray();
Task.WaitAll(tasks);
Output:
05:29:58.346 Item 1 Started
05:29:58.358 Item 2 Started
05:29:58.358 Item 3 Started
05:29:58.358 Item 4 Started
05:29:58.358 Item 5 Started
05:29:59.358 Item 1 Finished
05:29:59.374 Item 5 Finished
05:29:59.374 Item 4 Finished
05:29:59.374 Item 2 Finished
05:29:59.374 Item 3 Finished
Disaster, all tasks are queued at once.
Conclusion: Customizing the TaskScheduler
class is not the way to go when advanced async features are required.
Update: Here is another observation, regarding custom TaskScheduler
s in the presence of an ambient SynchronizationContext
. The await
mechanism by default captures the current SynchronizationContext
, or the current TaskScheduler
, and invokes the continuation on either the captured context
or the scheduler. If both are present, the current SynchronizationContext
is preferred, and the current TaskScheduler
is ignored. Below is a demonstration of this behavior, in a WinForms application¹:
private async void Button1_Click(object sender, EventArgs e)
{
await Task.Factory.StartNew(async () =>
{
MessageBox.Show($"{Thread.CurrentThread.ManagedThreadId}, {TaskScheduler.Current}");
await Task.Delay(1000);
MessageBox.Show($"{Thread.CurrentThread.ManagedThreadId}, {TaskScheduler.Current}");
}, default, TaskCreationOptions.None,
TaskScheduler.FromCurrentSynchronizationContext()).Unwrap();
}
Clicking the button causes two messages to popup sequentially, with this information:
1, System.Threading.Tasks.SynchronizationContextTaskScheduler
1, System.Threading.Tasks.ThreadPoolTaskScheduler
This experiment shows that only the first part of the asynchronous delegate, the part before the first await
, was scheduled on the non-default scheduler.
This behavior limits even further the practical usefulness of custom TaskScheduler
s in an async/await-enabled environment.
¹ Windows Forms applications have a WindowsFormsSynchronizationContext
installed automatically, when the Application.Run
method is called.