Search code examples
c#.netasynchronoustasktask-parallel-library

Task.Factory.StartNew + TaskCreationOptions.LongRunning explanation


I'm trying to understand what David Fowler said about Task.Factory.StartNew + TaskCreationOptions.LongRunning here.

💡 NOTE: Don't use TaskCreationOptions.LongRunning with async code as this will create a new thread which will be destroyed after first await.

I know that there is no point of having Task.Run or Task.Factory.StartNew in this case because SendLoopAsync and ReceiveLoopAsync are completely async. I also know that if there is a time-consuming synchronous part inside either one of these methods, the Task.Run/Task.Factory.StartNew should be inside that method.

What does David Fowler mean in his statement? That there shouldn't be TaskCreationOptions.LongRunning from within an async task? Or he meant that SendLoopAsync/ReceiveLoopAsync should not be async? I also know that TaskCreationOptions.LongRunning means that the task will start immediately, which isn't the case with just a normal task which gets scheduled by the scheduler, and might take some time to wind up. You can notice this behavior when starting multiple connections concurrently, which caused the Send and Receive loop to start with a significant delay.

public async Task StartAsync(CancellationToken cancellationToken)
{
    _ = Task.Factory.StartNew(_ => SendLoopAsync(cancellationToken), TaskCreationOptions.LongRunning, cancellationToken);
    _ = Task.Factory.StartNew(_ => ReceiveLoopAsync(cancellationToken), TaskCreationOptions.LongRunning, cancellationToken);
}

private async Task SendLoopAsync()
{
    await foreach (var message in _outputChannel.Reader.ReadAllAsync(_cancellationSource?.Token))
    {
        if (_clientWebSocket.State == WebSocketState.Open)
        {
            await _clientWebSocket.SendAsync(message.Data.AsMemory(), message.MessageType, true, CancellationToken.None).ConfigureAwait(false);
        }
    }
}

Solution

  • David Fowler means that the SendLoopAsync/ReceiveLoopAsync should not be async. There is no point at starting a task as LongRunning, if this task is going to use the starting thread for a duration measured in nanoseconds. The ThreadPool was invented in order to handle exactly these types of situations. In case the ThreadPool is not responsive enough because it has become saturated, then it's more logical to try to find the cause of the saturation and fix it, instead of bypassing the ThreadPool, and creating new threads every time you have some microseconds-worth of work to do.

    Here is a demonstration of what happens when LongRunning is combined with async:

    Stopwatch stopwatch = Stopwatch.StartNew();
    Thread workerThread = null;
    ConcurrentQueue<(string, long, System.Threading.ThreadState)> entries = new();
    Task<Task> taskTask = Task.Factory.StartNew(async () =>
    {
        workerThread = Thread.CurrentThread;
        entries.Enqueue(("A", stopwatch.ElapsedMilliseconds, workerThread.ThreadState));
        await Task.Delay(500);
        entries.Enqueue(("D", stopwatch.ElapsedMilliseconds, workerThread.ThreadState));
    }, default, TaskCreationOptions.LongRunning, TaskScheduler.Default);
    
    taskTask.Wait();
    entries.Enqueue(("B", stopwatch.ElapsedMilliseconds, workerThread.ThreadState));
    
    workerThread.Join();
    entries.Enqueue(("C", stopwatch.ElapsedMilliseconds, workerThread.ThreadState));
    
    await taskTask.Unwrap();
    entries.Enqueue(("E", stopwatch.ElapsedMilliseconds, workerThread.ThreadState));
    
    foreach (var (title, elapsed, state) in entries)
        Console.WriteLine($"{title } after {elapsed,3} msec worker thread is {state}");
    

    Output:

    A after   2 msec worker thread is Background
    B after   6 msec worker thread is Background, Stopped
    C after   6 msec worker thread is Stopped
    D after 507 msec worker thread is Stopped
    E after 507 msec worker thread is Stopped
    

    Try it on Fiddle.

    The lifetime of the worker thread was at most 6 milliseconds. All it really had to do was to instantiate an async state machine, and schedule a callback using a System.Threading.Timer component. 6 milliseconds look to me like an eon for such a minuscule workload. Most probably these 6 milliseconds were spent for inter-thread communication, and for the thread's creation and destruction.