Почему запуск сотни задач async занимает больше времени, чем запуск сотен потоков?

Почему запуск сотни задач async занимает больше времени, чем запуск сотен потоков?

У меня есть следующий тестовый класс:

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;
    }
}

Насколько можно судить, TestMethod1 и TestMethod2 должны делать то же самое. Один использует TPL, два используют простые ванильные нити. Один занимает 1:30 минут, два - 0,54 секунды.

Почему?

Ответ 1

Метод Action в настоящее время блокируется с использованием Task.WaitAll(tasks). При использовании Task по умолчанию используется ThreadPool, это означает, что вы блокируете общие потоки ThreadPool.

Попробуйте следующее, и вы увидите эквивалентную производительность:

  • Добавьте неблокирующую реализацию Action, мы будем называть ее 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);
    }
    
  • Измените TestMethod1, чтобы правильно обработать новый возвращаемый ActionAsync метод ActionAsync

    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());            
    }
    

Причина, по которой у вас была низкая производительность, заключается в том, что ThreadPool будет "медленно" создавать новые потоки, если это необходимо, если вы блокируете несколько потоков, доступных у вас, вы столкнетесь с заметным замедлением. Вот почему ThreadPool предназначен только для выполнения коротких задач.

Если вы намереваетесь выполнить длинную операцию блокировки с помощью Task, тогда не забудьте использовать TaskCreationOptions.LongRunning при создании экземпляра Task (это создаст новый базовый Thread вместо использования ThreadPool)..

Некоторое дополнительное доказательство проблемы ThreadPool, следующее также устраняет вашу проблему (НЕ используйте это):

ThreadPool.SetMinThreads(500, 500);

Это демонстрирует, что "медленное" нерестование новых потоков ThreadPool вызывало ваше узкое место.

Ответ 2

Задачи выполняются по потокам из threadpool. Threadpool как ограниченное количество потоков, которые повторно используются. Вся задача или все запрошенные действия ставятся в очередь и выполняются этими потоками, когда они неактивны.

Предположим, что ваш threadpool имеет 10 потоков, и вы ожидаете 100 задач, затем выполняются 10 задач, а остальные 90 задач просто ждут в очереди до тех пор, пока не будут завершены первые 10 задач.

Во втором методе тестирования вы создаете 100 потоков, посвященных их задачам. Поэтому вместо 10 потоков, выполняемых одновременно, выполняется 100 потоков.