Search code examples
c#multithreadingasync-await

Why does running a hundred async tasks take longer than running a hundred threads?


Why does running a hundred async tasks take longer than running a hundred threads?

I have the following test class:

public class AsyncTests
{

    public void TestMethod1()
    {
        var tasks = new List<Task>();

        for (var i = 0; i < 100; i++)
        {
            var task = new Task(Action);
            tasks.Add(task);
            task.Start();
        }

        Task.WaitAll(tasks.ToArray());            
    }


    public void TestMethod2()
    {
        var threads = new List<Thread>();

        for (var i = 0; i < 100; i++)
        {
            var thread = new Thread(Action);
            threads.Add(thread);
            thread.Start();
        }

        foreach (var thread in threads)
        {
            thread.Join();
        }
    }

    private void Action()
    {
        var task1 = LongRunningOperationAsync();
        var task2 = LongRunningOperationAsync();
        var task3 = LongRunningOperationAsync();
        var task4 = LongRunningOperationAsync();
        var task5 = LongRunningOperationAsync();

        Task[] tasks = {task1, task2, task3, task4, task5};
        Task.WaitAll(tasks);
    }

    public async Task<int> LongRunningOperationAsync()
    {
        var sw = Stopwatch.StartNew();

        await Task.Delay(500);

        Debug.WriteLine("Completed at {0}, took {1}ms", DateTime.Now, sw.Elapsed.TotalMilliseconds);

        return 1;
    }
}

As far as can tell, TestMethod1 and TestMethod2 should do exactly the same. One uses TPL, two uses plain vanilla threads. One takes 1:30 minutes, two takes 0.54 seconds.

Why?


Solution

  • The Action method is currently blocking with the use of Task.WaitAll(tasks). When using Task by default the ThreadPool will be used to execute, this means you are blocking the shared ThreadPool threads.

    Try the following and you will see equivalent performance:

    1. Add a non-blocking implementation of Action, we will call it ActionAsync

      private Task ActionAsync()
      {
          var task1 = LongRunningOperationAsync();
          var task2 = LongRunningOperationAsync();
          var task3 = LongRunningOperationAsync();
          var task4 = LongRunningOperationAsync();
          var task5 = LongRunningOperationAsync();
      
          Task[] tasks = {task1, task2, task3, task4, task5};
          return Task.WhenAll(tasks);
      }
      
    2. Modify TestMethod1 to properly handle the new Task returning ActionAsync method

      public void TestMethod1()
      {
          var tasks = new List<Task>();
      
          for (var i = 0; i < 100; i++)
          {
              tasks.Add(Task.Run(new Func<Task>(ActionAsync)));
          }
      
          Task.WaitAll(tasks.ToArray());            
      }
      

    The reason you were having slow performance is because the ThreadPool will "slowly" spawn new threads if required, if you are blocking the few threads it has available, you will encounter a noticeable slowdown. This is why the ThreadPool is only intended for running short tasks.

    If you are intending to run a long blocking operation using Task then be sure to use TaskCreationOptions.LongRunning when creating your Task instance (this will create a new underlying Thread rather than using the ThreadPool).

    Some further evidence of the ThreadPool being the issue, the following also alleviates your issue (do NOT use this):

    ThreadPool.SetMinThreads(500, 500);
    

    This demonstrates that the "slow" spawning of new ThreadPool threads was causing your bottleneck.