Async/await, Task.Run или Parallel.For?

Библиотека .NET предлагает различные способы достижения асинхронности. Но какой из них выбрать в конкретной ситуации, когда надо запустить несколько задач одновременно?

Ответ на этот вопрос зависит от того, какие ресурсы наиболее интенсивно используются:

  • если CPU, то лучше использовать Task.Run(), Parallel.For() и т. д.;
  • а если I/O (диск, сеть, внешние устройства), то async/await.

Теория

Теперь разберем почему именно так. Целью данного материала не является детальное раскрытие внутренних принципов работы указанных методов. Поэтому дальнейшее описание несколько упрощено для лучшего понимания общей картины происходящего.

Parallel.For(), Task.Run()

Parallel.For(), Task.Run() и схожие с ними методы берут свободный поток для каждой запускаемой задачи. Он будет активным, даже в случае если CPU практически не используется (ожидание ответа сервера, данных с диска и т.д.). Число одновременно выполняемых потоков конечно. Это означает, что в случае с активным использованием I/O операций, в какой-то момент, новые задачи будут ждать завершения уже запущенных. При этом CPU практически не нагружен и простаивает. Т. е. не смотря на наличие ресурсов, часть I/O операций будет отложена.

Указанная выше проблема не актуальна в случае потоков, интенсивно использующих CPU. В этой ситуации процессор будет постоянно загружен работой и простоя не будет.

async/await

Пара async/await работает по другому. По своей сути это конечный автомат, который позволяет во время ожидания использовать тот же самый поток для следующей в списке задачи. Таким образом, используя небольшое число потоков можно инициировать большее число I/O операций и ожидать их завершения. При этом свободные потоки смогут продолжать обслуживать другие задачи приложения.

Практика

Посмотрим описанное выше на примере. Асинхронное обращение к внешнему ресурсу будем имитировать с помощью Task.Delay(). А в роли задачи, которая занимает время процессора, выступит Thread.Sleep().

Создадим консольное приложение и добавим в него класс TestMethods. Ради лучшего понимания кода пожертвуем принципом DRY при написании тестовых методов. Каждый из них будет запускать определенный тип теста (async/await, Parallel.For, Task.Run) и замерять результаты его выполнения. Последние вернем в виде экземпляра Tuple<long, int>, в котором сохранены время выполнения теста и зафиксированное число использованных потоков.

namespace AsyncVsTaskDemo
{
    using System;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.Linq;
    using System.Threading;
    using System.Threading.Tasks;

    internal class TestMethods
    {
        private const int _maxTasks = 100;
        private readonly object _lock = new object();
        private readonly List<int> _loggedThreads = new List<int>();

        public Tuple<long, int> ExecuteParallelTest()
        {
            this._loggedThreads.Clear();

            var sw = new Stopwatch();
            sw.Restart();

            Parallel.For(0, _maxTasks, this.LongRunningTask);

            sw.Stop();
            return new Tuple<long, int>(sw.ElapsedMilliseconds, this._loggedThreads.Count);
        }

        public Tuple<long, int> ExecuteTaskTest()
        {
            this._loggedThreads.Clear();

            var tasks = new List<Task>();

            var sw = new Stopwatch();
            sw.Restart();

            for (var i = 0; i < _maxTasks; i++) {
                var closure = i;
                tasks.Add(Task.Run(() => this.LongRunningTask(closure)));
            }
            Task.WaitAll(tasks.ToArray());

            sw.Stop();

            return new Tuple<long, int>(sw.ElapsedMilliseconds, this._loggedThreads.Count);
        }

        public Tuple<long, int> ExecuteAwaitTest()
        {
            this._loggedThreads.Clear();

            var tasks = new List<Task>();

            var sw = new Stopwatch();
            sw.Restart();

            for (var i = 0; i < _maxTasks; i++)
                tasks.Add(this.LongRunningAsync());
            Task.WaitAll(tasks.ToArray());

            sw.Stop();

            return new Tuple<long, int>(sw.ElapsedMilliseconds, this._loggedThreads.Count);
        }

        private void LongRunningTask(int i)
        {
            this.LogCurrentThread();
            Thread.Sleep(100);
        }

        private async Task LongRunningAsync()
        {
            this.LogCurrentThread();
            await Task.Delay(100);
        }

        private void LogCurrentThread()
        {
            lock (this._lock) {
                var threadId = Thread.CurrentThread.ManagedThreadId;
                if (this._loggedThreads.All(tid => tid != threadId))
                    this._loggedThreads.Add(threadId);
            }
        }
    }
}

Следующий шаг – написание кода, запускающего тесты. Расположим его в классе Program:

namespace AsyncVsTaskDemo
{
    using System;
    using System.Collections.Generic;
    using System.Linq;

    class Program
    {
        private static readonly TestMethods _tests = new TestMethods();

        static void Main(string[] args)
        {
            Program.RunTest("await", _tests.ExecuteAwaitTest);
            Program.RunTest("Parallel.For", _tests.ExecuteParallelTest);
            Program.RunTest("Task.Run", _tests.ExecuteTaskTest);

            Console.ReadKey(true);
        }

        static void RunTest(string testName, Func<Tuple<long, int>> testMethod)
        {
            const int testLoops = 10;

            var elapsedTime = new List<long>();
            var threadCount = new List<int>();

            for (var i = 0; i < testLoops; i++) {
                var result = testMethod();
                elapsedTime.Add(result.Item1);
                threadCount.Add(result.Item2);
            }

            Console.WriteLine("{0} average time: {1} ms", testName, elapsedTime.Average());
            Console.WriteLine("Thread count: min {0} – max {1}",
                threadCount.Min(),
                threadCount.Max());
        }
    }
}

Остается только запустить тесты и посмотреть на результаты:

await average time: 112.1 ms
Thread count: min 1 – max 1
Parallel.For average time: 930.1 ms
Thread count: min 9 – max 21
Task.Run average time: 879.5 ms
Thread count: min 8 – max 21

Хорошо видно, что в случае с использованием async/await время выполнения теста приблизительно равно времени "ожидания ответа". Кроме того, было зафиксировано использование только одного потока. Схожий код (см. методы ExecuteAawitTest() и ExecuteTaskTest()) с использованием Task.Run() не сильно отличается по результатам от Parallel.For(). Оба этих теста использовали достаточно большое количество потоков, при этом часть из них попросту простаивала.

Добавить комментарий