Task.Factory.StartNew and long running async tasks
Let’s say you want to implement a Producer-Consumer pattern based on System.Threading.Channel
to process items asynchronously:
public class AsyncLogProcessor
{
private readonly Channel<string> _channel = Channel.CreateUnbounded<string>();
private readonly Task _processingTask;
public AsyncLogProcessor()
{
_processingTask = Task.Factory.StartNew(async () =>
{
await foreach (var log in _channel.Reader.ReadAllAsync())
{
// Processing a log item.
Console.WriteLine(log);
}
}, TaskCreationOptions.LongRunning);
}
public void ProcessLog(string log)
{
_channel.Writer.TryWrite(log);
}
}
And since you know that the processing task should run for the duration of the process, you use TaskCreationOptions.LongRunning
flag.
Do you have an issue with this solution?
I actually, do.
The LongRunning
flag tells the TPL to have a dedicated thread for a given callback, instead of getting a thread from the thread pool. But even though your task is semantically long running, the dedicated thread won’t be running for a long time.
Let’s simplify the code and add some tracing:
static void WriteLine(string message)
{
Console.WriteLine($"[{Environment.CurrentManagedThreadId}] [IsThreadPoolTHread: {Thread.CurrentThread.IsThreadPoolThread}] - {message}.");
}
static void Main(string[] args)
{
var task = Task.Factory.StartNew(async () =>
{
WriteLine("Task started");
await Task.Delay(1000);
WriteLine("Task completed");
}, TaskCreationOptions.LongRunning);
Thread.Sleep(100);
Console.WriteLine($"task.IsCompleted: " + task.IsCompleted);
Console.ReadLine();
}
The output:
[4] [IsThreadPoolThread: False] - Task started.
task.IsCompleted: True
[6] [IsThreadPoolThread: True] - Task completed.
Here what’s happening at runtime:
- The
Task started
message is printed from a dedicated thread. - The
Task completed
message is printed from a thread pool thread. - The task appears to be completed before the callback is done.
This happens because Task.Factory.StartNew
is not async-friendly. The actual type of the task
variable is Task<Task>
, and the parent task completes when the new thread starts executing the callback, not when the callback itself completes.
The LongRunning
flag is respected for running the first block of the async method before the first await
. The await
suspends the execution of the async method and the rest of the method is scheduled into the thread pool thread by the default task scheduler.
It is theoretically possible, that the callback that you provide to StartNew
has a long running piece before the first await
and you really want to run it in the dedicated thread. If this is the case the LongRunning
flag is legit, but this is very uncommon and if you hit such case, please add a lengthy comment explaining the performance benefits of this approach.
The guidelines for Task.Factory.StartNew
and async delegates:
- Avoid using
Task.Factory.StartNew
with async delegates. If you must, use theUnwarp
extension method to get the actual underlying task. - Do not use
LongRunning
flag withTask.Factory.StartNew
for async callbacks. The flag is useful for synchronous methods that block the thread, but not for async methods where the continuation will be scheduled on the thread pool.