Какова цель методов * Async в .NET Framework, учитывая возможность запуска любого метода асинхронно с помощью Task.Run?

Короткий вопрос:

Почему .Net Framework добавила много асинхронных версий метода вместо разработчиков, просто используя Task.Run для асинхронного запуска синхронных методов?

Подробный вопрос:

  • Я понимаю концепцию асинхроничности.
  • Я знаю о Tasks
  • Я знаю о ключевыхх async/wait.
  • Я знаю, что * методы Async в .NET Framework делают.

То, что я не понимаю, является целью методов * Async в библиотеке.

Предположим, что у вас есть две строки кода:

F1();
F2();

В отношении потока данных/управления существует только два случая:

  • F2 необходимо выполнить после завершения F1.
  • F2 не нужно ждать завершения F1.

Я не вижу других случаев. Я не вижу никакой общей необходимости знать конкретный поток, который выполняет какую-то функцию (кроме пользовательского интерфейса). Базовый режим выполнения кода в потоке является синхронным. Для parallelism требуется несколько потоков. Асинхронизация основана на parallelism и переупорядочении кода. Но база все еще синхронна.

Разница не имеет значения, когда рабочая нагрузка F1 мала. Но когда A занимает много времени, чтобы закончить, нам может понадобиться посмотреть на ситуацию, и, если F2 не нужно ждать завершения F1, мы можем запустить F1 параллельно с F2.

Давным-давно мы сделали это с помощью потоков/потоков. Теперь имеем Tasks.

Если мы хотим параллельно запустить F1 и F2, мы можем написать:

var task1 = Task.Run(F1);
F2();

задачи классные, и мы можем использовать await в тех местах, где нам, наконец, нужно завершить задачу.

До сих пор я не вижу необходимости создавать метод F1Async().

Теперь посмотрим на некоторые особые случаи. Единственный реальный частный случай, который я вижу, - это пользовательский интерфейс. Поток пользовательского интерфейса является особенным и останавливается, что заставляет замораживать пользовательский интерфейс, который является плохим. Как я вижу, Microsoft советует нам отмечать обработчики событий пользовательского интерфейса async. Маркировка методов async означает, что мы можем использовать ключевое слово await, чтобы в основном планировать тяжелую обработку на другом потоке и освобождать поток пользовательского интерфейса до завершения обработки.

То, что я не получаю снова, - это то, зачем нам нужны любые методы Async, чтобы их можно было ждать. Мы всегда можем просто написать await Task.Run(F1);. Зачем нам нужно F1Async?

Вы можете сказать, что методы * Async используют специальную магию (например, обработку внешних сигналов), которые делают их более эффективными, чем их синхронные копии. Дело в том, что я не вижу этого в этом.

Посмотрим, например, на Stream.ReadAsync. Если вы посмотрите на исходный код, ReadAsync просто тратит несколько сотен строк кода колоколов и свистков, чтобы создать задачу, которая просто вызывает синхронный метод Read. Зачем тогда это нужно? Почему бы просто не использовать Task.Run с Stream.Read?

Вот почему я не понимаю необходимости раздувать библиотеки, создавая тривиальные * Async копии синхронных методов. MS могла бы даже добавить синтаксический сахар, чтобы мы могли написать await async Stream.Read вместо await Stream.ReadAsync или Task.Run(Stream.Read).

Теперь вы можете спросить: "Почему бы не сделать методы * Async единственными и удалить синхронные методы?". Как я сказал ранее, режим выполнения базового кода является синхронным. Легко запускать синхронный метод асинхронно, но не наоборот.

Итак, какова цель методов * Async в .NET Framework, учитывая возможность запуска любого метода асинхронно с помощью Task.Run?

P.S. Если незамерзание пользовательского интерфейса так важно, почему бы не просто запустить обработчики async по умолчанию и предотвратить любую возможность замораживания?

Аргумент "без потоков":

Люди, отвечающие на этот вопрос, похоже, подразумевают, что преимущество методов Async состоит в том, что они эффективны, потому что они не создают новые потоки. Проблема в том, что я не вижу такого поведения. Параллельные асинхронные задачи ведут себя так же, как я думал, - поток создается (или берется из пула потоков) для каждой параллельной задачи (не все задачи выполняются параллельно).

Вот мой тестовый код:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication32167 {
    class Program {
        static async Task TestAsync() {
            var httpClient = new HttpClient() { Timeout = TimeSpan.FromMinutes(20) };

            var tasks = Enumerable.Range(1, 100).Select((i) =>
                httpClient.GetStringAsync("http://localhost/SlowWebsite/"));

            Console.WriteLine("Threads before completion: " + Process.GetCurrentProcess().Threads.Count);

            await Task.WhenAll(tasks);

            Console.WriteLine("Threads after completion: " + Process.GetCurrentProcess().Threads.Count);
        }

        static void Main(string[] args) {
            Console.WriteLine("Threads at start: " + Process.GetCurrentProcess().Threads.Count);

            var timer = new Stopwatch();
            timer.Start();

            var testTask = TestAsync();

            var distinctThreadIds = new HashSet<int>();
            while (!testTask.IsCompleted) {
                var threadIds = Process.GetCurrentProcess().Threads.OfType<ProcessThread>().Select(thread => thread.Id).ToList();
                distinctThreadIds.UnionWith(threadIds);
                Console.WriteLine("Current thread count: {0}; Cumulative thread count: {1}.", threadIds.Count, distinctThreadIds.Count);
                Thread.Sleep(250);
            }

            testTask.Wait();

            Console.WriteLine(timer.Elapsed);
            Console.ReadLine();
        }
    }
}

Этот код пытается запустить 100 HttpClient.GetStringAsync задач, отправляющих запросы на веб-сайт, на которые требуется 1 минута ответа. В то же время он подсчитывает количество активных потоков и кумулятивное число разных, созданных процессом. Как я и предсказывал, эта программа создает много новых потоков. Результат выглядит следующим образом:

Current thread count: 4; Cumulative thread count: 4.
....
Current thread count: 25; Cumulative thread count: 25.
....
Current thread count: 7; Cumulative thread count: 63.
Current thread count: 9; Cumulative thread count: 65.
00:10:01.9981006

Это означает, что:

  • В ходе выполнения async-задачи создаются 61 новый поток.
  • Максимальное число новых активных потоков - 21.
  • Выполнение занимает 10 раз больше времени (10 минут вместо 1). Это было вызвано локальными ограничениями IIS.

Ответ 1

Маркировка методов async означает, что мы можем использовать ключевое слово ожидания, чтобы в основном планировать тяжелую обработку в другом потоке и освобождать поток пользовательского интерфейса до завершения обработки.

Это совсем не так, как работает async. Смотрите async intro.

Вы можете сказать, что методы * Async используют специальную магию (например, обработку внешних сигналов), которые делают их более эффективными, чем их синхронные копии. Дело в том, что я не вижу этого в этом.

В чистом асинхронном коде нет нити (как я расскажу в своем блоге). Фактически, на уровне драйвера устройства все (нетривиальные) операции ввода/вывода являются асинхронными. Это синхронные API (на уровне ОС), которые являются абстракционным слоем над естественными асинхронными API-интерфейсами.

Посмотрите, например, на Stream.ReadAsync.

Stream - необычный случай. Как базовый класс, он должен максимально предотвращать нарушения. Поэтому, когда они добавили виртуальный метод ReadAsync, им пришлось добавить реализацию по умолчанию. Эта реализация должна использовать неидеальную реализацию (Task.Run), что является неудачным. В идеальном мире ReadAsync будет (или вызывать) абстрактную асинхронную реализацию, но это нарушит каждую существующую реализацию Stream.

Для более правильного примера сравните разницу между WebClient и HttpClient.

Ответ 2

Сделайте реалистичный тест: естественно асинхронный WebRequest.GetResponseAsync vs неестественно синхронный WebRequest.GetResponse.

Во-первых, мы расширяем стандартные пределы ThreadPool:

ThreadPool.SetMaxThreads(MAX_REQS * 2, MAX_REQS * 2);
ThreadPool.SetMinThreads(MAX_REQS, MAX_REQS);

Примечание. Я запрашиваю то же количество workerThreads и completionPortThreads. Затем мы выполним MAX_REQS= 200 параллельных запросов на bing.com, используя каждый API.

Код (автономное консольное приложение):

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Linq;
using System.Collections.Generic;
using System.Net;

namespace Console_21690385
{
    class Program
    {
        const int MAX_REQS = 200;

        // implement GetStringAsync
        static async Task<string> GetStringAsync(string url)
        {
            using (var response = await WebRequest.Create(url).GetResponseAsync())
            using (var stream = response.GetResponseStream())
            using (var reader = new System.IO.StreamReader(stream))
            {
                return await reader.ReadToEndAsync();
            }
        }

        // test using GetStringAsync
        static async Task TestWithGetStringAsync()
        {
            var tasks = Enumerable.Range(1, MAX_REQS).Select((i) =>
                GetStringAsync("http://www.bing.com/search?q=item1=" + i));

            Console.WriteLine("Threads before completion: " + Process.GetCurrentProcess().Threads.Count);

            await Task.WhenAll(tasks);

            Console.WriteLine("Threads after completion: " + Process.GetCurrentProcess().Threads.Count);
        }

        // implement GetStringSync
        static string GetStringSync(string url)
        {
            using (var response = WebRequest.Create(url).GetResponse())
            using (var stream = response.GetResponseStream())
            using (var reader = new System.IO.StreamReader(stream))
            {
                return reader.ReadToEnd();
            }
        }

        // test using GetStringSync
        static async Task TestWithGetStringSync()
        {
            var tasks = Enumerable.Range(1, MAX_REQS).Select((i) =>
                Task.Factory.StartNew(
                    () => GetStringSync("http://www.bing.com/search?q=item1=" + i),
                    CancellationToken.None, TaskCreationOptions.PreferFairness, TaskScheduler.Default));

            Console.WriteLine("Threads before completion: " + Process.GetCurrentProcess().Threads.Count);

            await Task.WhenAll(tasks);

            Console.WriteLine("Threads after completion: " + Process.GetCurrentProcess().Threads.Count);
        }

        // run either of the tests
        static void RunTest(Func<Task> runTest)
        {
            Console.WriteLine("Threads at start: " + Process.GetCurrentProcess().Threads.Count);

            var stopWatch = new Stopwatch();
            stopWatch.Start();

            var testTask = runTest();

            while (!testTask.IsCompleted)
            {
                Console.WriteLine("Currently threads: " + Process.GetCurrentProcess().Threads.Count);
                Thread.Sleep(1000);
            }
            Console.WriteLine("Threads at end: " + Process.GetCurrentProcess().Threads.Count + ", time: " + stopWatch.Elapsed);

            testTask.Wait();
        }

        static void Main(string[] args)
        {
            ThreadPool.SetMaxThreads(MAX_REQS * 2, MAX_REQS * 2);
            ThreadPool.SetMinThreads(MAX_REQS, MAX_REQS);

            Console.WriteLine("Testing using GetStringAsync");
            RunTest(TestWithGetStringAsync);
            Console.ReadLine();

            Console.WriteLine("Testing using GetStringSync");
            RunTest(TestWithGetStringSync);
            Console.ReadLine();
        }
    }
}

Вывод:

Testing using GetStringAsync
Threads at start: 3
Threads before completion: 3
Currently threads: 25
Currently threads: 84
Currently threads: 83
Currently threads: 83
Currently threads: 83
Currently threads: 83
Currently threads: 83
Currently threads: 84
Currently threads: 83
Currently threads: 83
Currently threads: 84
Currently threads: 84
Currently threads: 84
Currently threads: 83
Currently threads: 83
Currently threads: 84
Currently threads: 83
Currently threads: 82
Currently threads: 82
Currently threads: 82
Currently threads: 83
Currently threads: 25
Currently threads: 25
Currently threads: 26
Currently threads: 25
Currently threads: 25
Currently threads: 25
Currently threads: 23
Currently threads: 23
Currently threads: 24
Currently threads: 20
Currently threads: 20
Currently threads: 19
Currently threads: 19
Currently threads: 19
Currently threads: 19
Currently threads: 18
Currently threads: 19
Currently threads: 19
Currently threads: 19
Currently threads: 18
Currently threads: 18
Currently threads: 18
Currently threads: 19
Currently threads: 19
Currently threads: 18
Currently threads: 19
Currently threads: 19
Currently threads: 18
Currently threads: 18
Currently threads: 17
Threads after completion: 17
Threads at end: 17, time: 00:00:51.2605879

Testing using GetStringSync
Threads at start: 15
Threads before completion: 15
Currently threads: 55
Currently threads: 213
Currently threads: 213
Currently threads: 213
Currently threads: 213
Currently threads: 213
Currently threads: 213
Currently threads: 213
Currently threads: 213
Currently threads: 212
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 210
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 209
Currently threads: 205
Currently threads: 201
Currently threads: 196
Currently threads: 190
Currently threads: 186
Currently threads: 182
Threads after completion: 178
Threads at end: 173, time: 00:00:47.2603652

Результат:

Оба теста занимают около 50 секунд, но GetStringAsync достигает максимума в 83 потоках, а GetStringSync - в 213. Чем выше значение MAX_REQS, тем больше потоков тратится на блокирующий WebRequest.GetResponse API.

@Арк-кун, надеюсь, вы сейчас видите это.