Task run что это
Aсинхронное программирование
Асинхронные методы, async и await
Асинхронность позволяет вынести отдельные задачи из основного потока в специальные асинхронные методы или блоки кода. Особенно это актуально в графических программах, где продолжительные задачи могу блокировать интерфейс пользователя. И чтобы этого не произошло, нужно задействовать асинхронность. Также асинхронность несет выгоды в веб-приложениях при обработке запросов от пользователей, при обращении к базам данных или сетевым ресурсам. При больших запросах к базе данных асинхронный метод просто уснет на время, пока не получит данные от БД, а основной поток сможет продолжить свою работу. В синхронном же приложении, если бы код получения данных находился в основном потоке, этот поток просто бы блокировался на время получения данных.
Асинхонный метод обладает следующими признаками:
В заголовке метода используется модификатор async
Метод содержит одно или несколько выражений await
В качестве возвращаемого типа используется один из следующих:
Рассмотрим пример асинхронного метода:
Выражение await определяет задачу, которая будет выполняться асинхронно. В данном случае подобная задача представляет выполнение функции факториала:
И в методе Main мы вызываем этот асинхронный метод.
Посмотрим, какой у программы будет консольный вывод:
Разберем поэтапно, что здесь происходит:
Запускается метод Main, в котором вызывается асинхронный метод FactorialAsync.
Метод FactorialAsync начинает выполняться синхронно вплоть до выражения await.
Выражение await запускает асинхронную задачу Task.Run(()=>Factorial())
Асинхронный метод ReadWriteAsync() выполняет запись в файл некоторой строки и затем считывает записанный файл. Подобные операции могут занимать продолжительное время, особенно при больших объемах данных, поэтому такие операции лучше делать асинхронными.
Далее в методе Main вызывается асинхронный метод ReadWriteAsync:
И опять же, когда выполнение в методе ReadWriteAsync доходит до первого выражения await, управление возвращается в метод Main, и мы можем продолжать с ним работу. Запись в файл и считывание файла будут производиться параллельно и не будут блокировать работу метода Main.
Определение асинхронной операции
Либо мы сами можем определить асинхронную операцию, используя метод Task.Run() :
Можно определить асинхронную операцию с помощью лямбда-выражения:
Передача параметров в асинхронную операцию
Выше вычислялся факториал 6, но, допустим, мы хотим вычислять факториалы разных чисел:
Получение результата из асинхронной операции
Асинхронная операция может возвращать некоторый результат, получить который мы можем так же, как и при вызове обычного метода:
Правила работы с Tasks API. Часть 1
В этом посте я попытаюсь показать проблему, решение и истоки.
Проблема
Пусть у нас есть код, который выглядит так:
Вопрос знатокам: есть ли в данном примере проблема? Если да, то какая? Код компилируется, возвращаемый тип Task на месте, модификатор async при использовании await — тоже.
Думаете, речь идет о пропущенном ConfigureAwait? Хаха!
NB: вопрос о ConfigureAwait я опущу, ибо о другом статья.
Истоки
До идиомы async/await основным способом использования Tasks API был метод Task.Factory.StartNew() с кучей перегрузок. Так, Task.Run() немного облегчает данный подход, опуская указание планировщика (TaskScheduler) и т.п.
Ничего особенно в примере выше нет, но именно здесь начинаются отличия, и возникает главная проблема — многие начинают думать, что Task.Run() — это облегченный Task.Factory.StartNew().
Чтобы стало нагляднее, рассмотрим пример:
Что? Два await’a? Именно так.
Все дело в перегрузках:
Несмотря на то, что возвращаемый тип у обоих методов — Task<TResult>, входным параметром у Run является Func<Task<TResult>>.
В случае с async () => await inner Task.Run получит уже готовую state-машину (а мы знаем, что await — есть не что иное, как трансформация кода в state-машину), где все оборачивается в Task.
StartNew получит то же самое, но TResult уже будет Task<Task<T>>.
В одной статье, я уже описывал работу dynamic: каждый statement в C# превращается в узел вызова (call-site), который относится ко времени исполнения, а не компиляции. При этом сам компилятор старается побольше метаданных передать рантайму.
Метод Compute() использует и возвращает Task<dynamic>, что заставляет компилятор создавать эти самые узлы вызовов.
Причем, это корректный код — результатом в рантайме будет Task<Task<dynamic>>.
Решение
Оно весьма простое: необходимо использовать метод Unwrap().
В коде без dynamic вместо двух await’ов можно обойтись одним:
Теперь, как и ожидалось, результатом будет Task<dynamic>, где dynamic — именно возвращаемое значение inner’a, но не еще один таск.
Недопонимание про async/await и многопоточность в C#
Тем не менее, мне очень часто приходится сталкиваться с тем, что не только новички, но и матёрые тимлиды не совсем понимают, как правильно пользоваться этим инструментом в разработке.
Моё мнение таково — корень всех зол кроется в мнении о том, что async/await и Task Asynchronous Pattern нужно использовать для написания многопоточной логики.
Начитавшись большого количества информации с различных ресурсов про async/await у разработчиков формируется миф: ожидание происходит в отдельном потоке, или код выполняется в отдельном потоке, или что-то ещё происходит с отдельным потоком. Нет, нет и ещё раз нет. Изначально TAP задумывался не для этого — для многопоточности существует Task Parallel Library (MSDN). Путаница возникает не в последнюю очередь из-за того, что и в TAP, и в TPL используется Task.
Тем не менее, в коде дожлно быть четкое разделение между многопоточными операциями (CPU-bound) и асинхронными операциями.
В моей среде обитания (ASP.NET) многие по долгу службы работают с Javascript. В этом замечательном языке существует простой паттерн для асинхронных операций — callbacks, функции обратного вызова. В объяснение отличий TAP и TPL люблю приводить следующий пример на Javascript с использованием jQuery:
В большинстве случаев при выполнении правильного ajax-запроса в консоли увидим следующее:
Hello world!
Got some data.
Что, в общем-то, и ожидалось. Это — очень яркий пример асинхронного программирования. Здесь нет никакой многопоточности — Javascript строго однопоточен и никаких наворотов вроде WebWorkers этот код не использует.
А теперь рассмотрим похожий по назначению код на C#:
Этот же код можно записать с использованием await:
Почему этот код хороший и правильный? Потому что DownloadStringTaskAsync возвращает Task, который инкапсулирует операцию ввода-вывода — то есть I/O bound операцию. И практически весь ввод-вывод является асинхронным — то есть, для его осуществления, нигде, начиная с самого верхнего уровня вызова метода DownloadStringTaskAsync и заканчивая драйвером сетевой карты, абсолютно нигде не нужен дополнительный поток, который будет «ждать» или «обрабатывать» эту операцию.
Тем не менее, я очень часто сталкиваюсь с тем, что, даже если какой-то legacy-код предоставляет EAP API, при необходимости обернуть его в TAP матёрые программисты поступают просто и в лоб:
В чём проблема? А проблема, собственно, в том, что Task.Run запускает переданную в него лямбду () => client.DownloadString(url) в новом CPU-bound потоке из пула потоков. При том что, в данном случае, никакой необходимости в отдельном потоке нет.
Как «сделать правильно»? Использовать TaskCompletionSource. Продолжая аналогию с Promise API, TaskCompletionSource выполняет те же функции, что и Deferred. Таким образом, можно создать Task, который не будет создавать дополнительных потоков. Это очень удобно, когда нужно обернуть в Task ожидание срабатывания какого-либо события, такой сценарий неплохо описан в примере на MSDN.
Так что же получается, Task Asynchronous Pattern нельзя использовать для многопоточности? Можно. Но, как ни раз упоминалось в статьях, на которые я ссылался в начале, необходимо:
Мириады запущенных задач на C#
Недавно на ресурсе Medium были опубликованы две статьи от одного и того же автора, затрагивающие функциональность C# async/await.
Основными выводами были:
Но главная проблема вышеприведенных публикаций — абсолютное непонимание модели кооперативной многозадачности в C# с вводом читателей в заблуждение. Сами же бенчмарки — бессмысленные, как мы увидим позже.
Далее в статье я попытаюсь раскрыть суть проблемы более подробно с примерами решения.
Stack overflow & async
Перейдем сразу к рассмотрению примера #1:
Вариант реализации tail-call оптимизации здесь не подходит, т.к. мы не собираемся править компилятор, переписывать байт-код и т.п.
Поэтому решение должно подходить для максимально общего случая.
Обычно рекурсивный алгоритм заменяют на итерационный. Но в данном случае нам это не подходит также.
На помощь приходит механизм отложенного выполнения.
Реализуем простой метод Defer :
Перепишем пример, используя новый метод Defer :
Оно не то, чем кажется
Для начала ознакомимся с кодом бенчмарков из этой статьи.
Что брасается сразу в глаза:
Go использует понятие goroutine — легковесных потоков. Соответственно каждая горутина имеет свой стек. На данный момент размер стека равен 2KB. Поэтому при запуске бенчмарков будьте осторожны (более 4GB понадобиться)!
С одной стороны, это может быть полезно CLR JIT’у, а с другой — Go переиспользует уже созданные горутины, что позволяет исключить замеры трат на выделение памяти системой.
Результаты до оптимизации
Ну что ж, у меня получились следующие результаты:
Warmup (s) | Benchmark (s) | |
---|---|---|
Go | 9.3531 | 1.0249 |
C# | — | 1.3568 |
NB: Т.к. пример реализует просто цепочку вызовов, то ни GOMAXPROCS, ни размер канала не влияют на результат (уже проверено опытным путем). В расчет берем наилучшее время. Флуктуации не совсем важны, т.к. разница большая.
Да, действительно: Go опережает C# на
Используй TaskScheduler, Luke!
Мысль реализации проста: запускаем доп. поток, который слушает/обрабатывает задачи по очереди.
По-моему, легче реализовать собственный планировщик, чем контекст синхронизации.
Сам класс TaskScheduler выглядит так:
Внимание! Камера! Мотор!
Перепишем это дело на C# 7, делая его максимально приближенным к Go:
Здесь необходимо сделать пару ремарок:
Внезапно получаем около 600 ms вместо прежних 1300 ms. Not bad!
Go, напомню, отрабатывал на уровне 1000 ms. Но меня не покидает чувство неуместности использования каналов как средство кооперативной многозадачности в исходных примерах.
Использование асинхронного шаблона, основанного на задачах
При работе асинхронными операциями с использованием асинхронного шаблона, основанного на задачах, можно использовать обратные вызовы для реализации неблокирующего ожидания. Для задач это достигается с помощью таких методов, как Task.ContinueWith. Поддержка асинхронных операций на основе языка скрывает обратные вызовы, разрешая асинхронным операциям находиться в режиме ожидания в нормальном потоке управления, а код, созданный компилятором, предоставляет поддержку на том же уровне API.
Приостановление выполнения с помощью Await
Если контекст синхронизации (объект SynchronizationContext) связан с потоком, который во время приостановки выполнял асинхронный метод (например, если свойство SynchronizationContext.Current имеет значение, отличное от null ), асинхронный метод возобновляется в том же контексте синхронизации, для чего вызывается метод Post этого контекста. В противном случае он полагается на планировщик задач (объект TaskScheduler), который использовался в момент приостановки. Обычно это планировщик по умолчанию (TaskScheduler.Default), который нацелен на пул потоков. Этот планировщик задач определяет, следует ли возобновить приостановленную асинхронную операцию в тот момент, в который она была завершена, или следует ли запланировать возобновление. Планировщик по умолчанию обычно разрешает продолжение выполнения в потоке, который был завершен операцией.
Существует несколько важных вариантов такого поведения. Для повышения производительности, если к моменту ожидания задачи оказывается, что задача уже завершена, то управление не освобождается и функция продолжает выполнение. Кроме того, возврат к исходному контексту не всегда желателен, и такое поведение можно изменить. Подробное описание приведено в следующем разделе.
Настройка приостановки и возобновления с помощью Yield и ConfigureAwait
Существуют методы, которые позволяют получить больший контроль над выполнением асинхронного метода. Например, вы можете использовать метод Task.Yield для внедрения точки приостановки в асинхронный метод:
Это аналогично асинхронному размещению или планированию возврата в текущий контекст.
Также можно использовать метод Task.ConfigureAwait для более точного контроля над приостановкой и возобновлением в асинхронном методе. Как упоминалось ранее, по умолчанию текущий контекст записывается в момент приостановки асинхронного метода и используется для вызова продолжения асинхронного метода при возобновлении. Во многих случаях это именно то поведение, к которому вы стремитесь. В других случаях можно не заботиться о контексте продолжения. Для повышения производительности нужно избегать подобного размещения обратно в исходный контекст. Для этого воспользуйтесь методом Task.ConfigureAwait, чтобы сообщить операции await о том, что перехватывать и возобновлять контекст не нужно, и вместо этого необходимо продолжить выполнение в той точке, в которой завершилась ожидаемая асинхронная операция.
Отмена асинхронной операции
Маркер отмены создается с помощью источника маркеров отмены (объект CancellationTokenSource). Свойство Token источника возвращает маркер отмены, который будет передаваться при вызове метода Cancel источника. Например, если вы хотите скачать одну веб-страницу и при этом иметь возможность отменить операцию, создайте объект CancellationTokenSource, передайте его маркер методу TAP и вызовите метод источника Cancel, когда нужно будет отменить операцию.
Чтобы отменить несколько асинхронных вызовов, можно передать один и тот же маркер всем вызовам.
Также можно передать один и тот же маркер выбранному подмножеству операций.
Запрос на отмену может быть запущен из любого потока.
У такого подхода к отмене есть несколько преимуществ.
Один и тот же маркер отмены можно передать в любое количество асинхронных и синхронных операций.
Один и тот же запрос отмены можно распространить на любое количество прослушивателей.
Разработчик асинхронного интерфейса API имеет полный контроль над тем, можно ли разрешить запрос отмены и когда ее можно применить.
Код, который использует этот интерфейс API, может выборочно определять асинхронные вызовы, на которые будут распространены запросы отмены.
Наблюдение за ходом выполнения
Некоторые асинхронные методы предоставляют сведения о ходе выполнения с помощью интерфейса хода выполнения, который передается в асинхронный метод. Например, рассмотрим функцию, которая асинхронно скачивает строку текста, одновременно обновляя сведения о ходе скачивания, которые включают долю уже скачанной части строки в процентах ко всей строке. Этот метод можно использовать в приложении WPF следующим образом.
Использование внутренних блоков объединения задач
В пространстве имен System.Threading.Tasks предусмотрено несколько способов объединять задачи и работать с ними.
Task.Run
Класс Task содержит несколько методов Run, которые позволяют легко разгрузить задачи в формате Task или Task в пул потоков, например так:
Некоторые из этих методов Run, например перегрузка Task.Run(Func ), являются ссылкой на метод TaskFactory.StartNew. Другие перегрузки, например Task.Run(Func ), позволяют использовать await в разгруженных задачах, например так:
Эти перегрузки логически эквивалентны вызову метода TaskFactory.StartNew в сочетании с методом расширения Unwrapиз библиотеки параллельных задач.
Task.FromResult
Используйте метод FromResult в ситуациях, когда данные уже могут быть доступны и их достаточно возвратить в Task из метода, возвращающего задачу.
Task.WhenAll
Используйте метод WhenAll для асинхронного ожидания нескольких асинхронных операций, которые представлены в виде задач. У этого метода есть несколько перегрузок, которые поддерживают набор неуниверсальных задач или неоднородный набор универсальных задач (например, асинхронное ожидание нескольких операций, возвращающих void, или асинхронное ожидание несколько методов, возвращающих значение (при этом эти значения могут быть разных типов)), а также поддерживают единый набор универсальных задач (например, асинхронное ожидание нескольких методов, которые возвращают TResult ).
Предположим, что вы хотите отправить сообщения по электронной почте нескольким клиентам. Отправку сообщений можно перекрывать, чтобы не ожидать завершения отправки одного сообщения перед отправкой следующего. Также можно узнать, были ли выполнены операции отправки и возникли ли ошибки.
Этот код не обрабатывает возможные исключения явным образом, но позволяет им распространяться из метода await на задачу, полученную от WhenAll. Для обработки исключений можно использовать следующий код.
В этом случае при сбое любой асинхронной операции все исключения объединяются в одно исключение AggregateException, которое сохраняется в Task, возвращаемом из метода WhenAll. Однако с помощью ключевого слова await распространяется только одно из этих исключений. Если вы хотите изучить все исключения, можно переписать предыдущий код следующим образом.
Рассмотрим в качестве примера асинхронную загрузку нескольких файлов из Интернета. В этом случае все асинхронные операции имеют результаты одного типа, и к этим результатам легко получить доступ.
Можно использовать те же способы обработки исключений, которые были рассмотрены в предыдущем сценарии с возвратом void.
Task.WhenAny
Используйте метод WhenAny для асинхронного ожидания завершения одной из нескольких асинхронных операций, которые представлены в виде задач. Этот метод допускает четыре основных варианта использования.
Избыточность: многократный запуск одной операции и выбор первой завершенной операции (например, обращение к нескольким веб-сервисам котировок акций с целью получить один результат и выбор операции, которая завершилась первой).
Чередование: запуск и ожидание завершения нескольких операций, но обработка операций по мере выполнения.
Регулирование: добавление новых операций по мере завершения предыдущих. Это расширение сценария с чередованием.
Ранняя остановка: например, операция, представленная задачей t1, может сгруппироваться в задачу WhenAny с другой задачей t2, после чего можно ожидать задачу WhenAny. Например, задача t2 может представлять завершение ожидания, отмену или другой сигнал, требующий завершения задачи WhenAny до завершения задачи t1.
Избыточность
Рассмотрим случай, когда вам требуется принять решение о необходимости покупки акций. Существует несколько стандартных веб-служб с рекомендациями по покупке акций, которым вы доверяете, но в зависимости от ежедневной нагрузки каждая из этих служб иногда может работать медленно. Для получения уведомлений о завершении любой операции можно использовать метод WhenAny:
В отличие от WhenAll, который возвращает распакованные результаты всех успешно выполненных задач, WhenAny возвращает завершенную задачу. Если задача завершилась сбоем, важно знать, что она завершилась сбоем, а если она завершилась успешно, важно знать, с какой задачей связано возвращаемое значение. Поэтому необходимо получить доступ к результату, возвращаемому задачей, или продолжить ожидание, как показано в данном примере.
Как и в случае с WhenAll, необходимо поддерживать исключения. Так как вы получаете управление от завершенной задачи, вы можете подождать, пока не будут распространены ошибки для возвращенной задачи и try/catch их соответствующим образом.
Кроме того, даже если первая задача завершается успешно, следующие задачи могут завершиться сбоем. В этом случае есть несколько вариантов обработки исключений: можно ждать, пока не завершатся все задачи, используя метод WhenAll, или решить, что все исключения важны и должны быть записаны в журнал. В этом случае используется продолжение для получения уведомлений об успешном завершении задач.
Наконец, вы можете отменить все остальные операции.
Чередование
Рассмотрим ситуацию, в которой вы загружаете изображения из Интернета и обрабатываете каждое изображение (например, добавляете изображение в элемент управления пользовательского интерфейса). Обработка изображений выполняется последовательно в потоке пользовательского интерфейса, но скачивать их следует по возможности параллельно. Кроме того, для добавления изображений в пользовательский интерфейс не стоит ждать, пока все они будут скачаны. Вместо этого лучше добавлять каждое изображение после его скачивания.
Также вы можете применить чередование к сценарию, который подразумевает интенсивную вычислительную обработку пула загруженных изображений ThreadPool, например так:
Регулирование
Рассмотрим пример с чередованием с тем исключением, что на этот раз пользователь загружает так много изображений, что загрузку необходимо регулировать; например, вы можете ограничить максимальное количество параллельных загрузок. Для этого можно запустить подмножество асинхронных операций. По завершении операций можно запускать дополнительные операции, которые займут их место.
Ранняя остановка
Рассмотрим, что вы асинхронно ожидаете завершения операции и одновременно отвечаете на запрос отмены пользователя (например, если пользователь нажал кнопку «Отмена»). Этот сценарий иллюстрируется в следующем коде.
В этой реализации сразу же после отмены загрузки отображается пользовательский интерфейс, но базовые асинхронные операции не отменяются. В качестве альтернативы можно отменить ожидающие операции после отмены скачивания, но не отображать пользовательский интерфейс, пока операции на самом деле не завершатся (возможно, из-за раннего завершения, вызванного запросом отмены).
Еще один пример ранней остановки предполагает использование метода WhenAny в сочетании с методом Delay, как описано в следующем разделе.
Task.Delay
Метод Task.Delay позволяет приостановить выполнение асинхронного метода. Это удобно для реализации различных функций, включая создание циклов опроса и задержку обработки ввода пользователя на заданный период времени. Метод Task.Delay также можно использовать в сочетании с Task.WhenAny для ограничения времени ожидания await.
Если на выполнение задачи, которая является частью большой асинхронной операции (например, веб-служба ASP.NET), требуется слишком много времени, то это может негативно сказаться на всей операции, особенно если это приведет к неудачному завершению операции. Поэтому важно иметь возможность задавать время ожидания для асинхронных операций. Синхронные методы Task.Wait, Task.WaitAll и Task.WaitAny принимают значения времени ожидания, а соответствующий метод TaskFactory.ContinueWhenAll/Task.WhenAny и ранее упомянутый Task.WhenAll/Task.WhenAny — нет. Вместо этого вы можете совместно использовать Task.Delay и Task.WhenAny для ограничения времени ожидания.
Например, предположим, что вы хотите загрузить приложение и отключить пользовательский интерфейс на время загрузки. Однако если загрузка занимает слишком много времени, вы можете отменить загрузку и вернуться в пользовательский интерфейс.
Это же применимо и скачиванию нескольких файлов, так как WhenAll возвращает задачу:
Создание блоков объединения на основе задач
RetryOnFault
Во многих ситуациях может потребоваться повторить операцию, если предыдущая попытка завершилась неудачно. Для решения этой задачи в синхронном коде можно использовать вспомогательный метод, например RetryOnFault в следующем примере.
Вы можете создать почти такой же вспомогательный метод для асинхронных операций, которые реализованы в TAP и таким образом вернуть задачи.
Этот блок объединения также можно использовать для анализа повторных попыток в логике приложения.
Чтобы подождать одну секунду перед повтором операции, эту функцию можно использовать следующим образом.
NeedOnlyOne
В некоторых случаях для повышения задержки и вероятности успешного завершения операции можно воспользоваться преимуществами избыточности. Рассмотрим несколько веб-служб, которые предоставляют котировки акций в разное время дня, и каждая служба обеспечивает различный уровень качества и время отклика. Чтобы справиться с неравномерным характером поступления данных, вы можете отправлять запросы ко всем веб-службам и при получении ответа от одной из веб-служб отменять оставшиеся запросы. Вы можете реализовать вспомогательную функцию для более удобной реализации этого распространенного шаблона с запуском нескольких операций, ожидания завершения любой операции и последующей отмены остальных. Функция NeedOnlyOne в следующем примере иллюстрирует этот сценарий.
Затем можно использовать эту функцию следующим образом.
Операции с чередованием
При использовании метода WhenAny для поддержки сценария чередования при работе с большими наборами задач существует потенциальная проблема производительности. Каждый вызов WhenAny приводит к регистрации продолжения в каждой задаче. Для N задач это приводит к созданию O(N 2 ) продолжений в течение времени существования операции чередования. При работе с большим набором задач можно использовать комбинатор ( Interleaved в следующем примере), чтобы решить проблему производительности:
Затем с помощью блоков объединения можно объединять результаты задач по мере их завершения.
WhenAllOrFirstException
Создание структур данных на основе задач
AsyncCache
Например, можно создать кэш для загруженных веб-страниц.
Затем можно использовать этот кэш в асинхронных методах каждый раз, когда вам потребуется содержимое какой-либо веб-страницы. Класс AsyncCache гарантирует, что будет скачано минимальное число страниц, и кэширует результаты.
AsyncProducerConsumerCollection
Задачи также можно использовать для создания структур данных для координации асинхронных действий. Рассмотрим один из классических шаблонов параллельных систем: производитель/потребитель. В этой схеме производители создают данные, которые используются потребителями и производители и потребители могут работать параллельно. Например, потребитель обрабатывает элемент 1, который ранее был создан производителем, который в это же время создает элемент 2. Для схемы «производитель/потребитель» в любом случае потребуется некоторая структура данных, в которой будут храниться объекты, создаваемые производителем. Эта структура необходима для того, чтобы потребитель мог узнать о новых данных и получить их, когда они будут доступны.
Вот простая структура данных на основе задач, которая позволяет использовать асинхронные методы в качестве производителей и потребителей.
С этой структурой данных на месте можно написать следующий код.