что такое таск в программировании
Tasks и Back Stack
Может возникнуть вопрос: а как же фрагменты? Как они сохраняются в стеке? У них всё устроено несколько иначе, чем у активити: фрагмент помещается в back stack, управляемый активити и то, только если был вызван соответствующий метод ( addToBackStack() ) во время транзакции.
В версии Android 7.0 была добавлена поддержка многооконного режима: пользователь может разделить экран и таким образом работать с несколькими приложениями. В таком режиме система управляет task’ами отдельно для каждого окна, т.е. у каждого окна может быть несколько task’ов.
Визуально task’и можно увидеть на экране последних запущенных задач:
Управление task’ами
Некоторые приложения спроектированы таким образом, что есть несколько точек перемещения к одной и той же активити. Несмотря на то, что такая активити уже может находится в стеке, каждый раз будет создаваться её новый экземпляр и также сохраняться в стек. Таким образом, когда пользователь решит переместиться к самой первой активити, он увидит все открытые им, казалось бы одинаковые активити, но в разном состоянии. Подобного эффекта можно избежать при помощи специальных атрибутов манифеста и флагов для Intent.
Обратите внимание, что иногда атрибуты в манифесте и флаги в Intent могут противоречить друг другу. В этом случаи флаги Intent будут более приоритетны.
Атрибуты
launchMode
Данный атрибут можно указать для каждой активити в манифесте. Имеет несколько значений:
Флаги
Виды флагов:
Очистка стека
Если task долгое время находится в фоне, то система сама чистит его стек, оставляя только корневую активити. Подобное поведение объясняется тем, что по прошествии длительного времени пользователь, вероятно, забыл, что он делал в приложении и открыл его повторно уже с иной целью.
У активити существует три атрибута для изменения такого поведения:
Функциональное программирование на TypeScript: задачи (tasks) как альтернатива промисам
Предыдущие статьи цикла:
В предыдущей статье мы рассмотрели типы Option и Either, которые предоставляют функциональную замену nullable-типам и выбрасыванию исключений. В этой статье я хочу поговорить о ленивой функциональной замене промисам — задачам (tasks). Они позволят нам подойти к понятию систем эффектов, которые я подробно рассмотрю в следующих статьях.
Как всегда, я буду иллюстрировать примеры с помощью структур данных из библиотеки fp-ts.
Promise/A+, который мы потеряли заслужили
В далеком 2013 году Брайан МакКенна написал пост о том, что следовало бы изменить в спецификации Promise/A+ для того, чтобы промисы соответствовали монадическому интерфейсу. Эти изменения были незначительные, но очень важные с точки зрения соблюдения теоретико-категорных законов для монады и функтора. Итак, Брайан МакКенна предлагал:
Эти изменения позволили бы получить простое расширяемое API, которое в дальнейшем позволило бы элегантно отделять поведение контекста вычислений от непосредственной бизнес-логики — скажем, так, как это сделано в Haskell с его do-нотацией, или в Scala с for comprehension. К сожалению, так называемые «прагматики» в лице Доменика Дениколы и нескольких других контрибьюторов отвергли эти предложения, поэтому промисы в JS так и остались невнятным энергичным бастардом, которого достаточно проблематично использовать в идиоматичном ФП-коде, предполагающим equational reasoning и соблюдение принципа ссылочной прозрачности. Тем не менее, благодаря достаточно простому трюку можно сделать из промисов законопослушную абстракцию, для которой можно реализовать экземпляры функтора, аппликатива, монады и много чего еще.
Task — ленивый промис
Для Task можно определить экземпляры классов типов Functor, Apply, Applicative, Monad. Обратите внимание, как один из самых простых классов типов — функтор — порождает структуры, обладающие всё более и более сложным поведением.
N.B.: Также оговорюсь, что для простоты реализации код по обработке состояния rejected в промисах, использующихся внутри Task, не пишется — подразумевается, что конструирование экземпляров Task происходит при помощи функций-конструкторов, а не ad hoc.
Функтор позволяет преобразовывать значение, которое будет возвращено задачей, из типа A в тип B при помощи чистой функции:
Apply позволяет применять некую функцию преобразования, получающуюся асинхронно, к данным, которые будут возвращены задачей. Для Task можно написать два экземпляра Apply — один будет вычислять результат и функцию преобразования последовательно, другой — параллельно:
N.B.: так как экземпляр монады для Task может наследоваться от одного из двух экземпляров аппликатива — параллельного или последовательного, — то подставляя нужный экземпляр монады в программы, написанные в стиле Tagless Final, можно получить разное поведение аппликативных операций. Про реализацию стиля Tagless Final на тайпскрипте можно почитать в этом треде #MonadicMondays.
Имея на руках такие выразительные способности, как монада и функтор, можно уже писать простые программы в императивном стиле: делать ветвление, вычислять что-либо рекурсивно. Но для работы над задачами реального мира необходимо уметь выражать ошибочное состояние, и в этом поможет следующая абстракция — TaskEither.
TaskEither — задача, которая может вернуть ошибку
В предыдущей статье мы рассмотрели тип данных Either, который представляет вычисления, которые могут идти по одному из двух путей. Для типа Either можно реализовать экземпляры функтора, монады, альтернативы (Alt + Alternative, позволяет выражать fallback-значения), бифунктора (позволяет модифицировать одновременно как левую, так и правую часть Either) и много чего еще.
bracket позволяет безопасно получить (acquire), использовать (use) и утилизировать (release) какой-либо ресурс — например, соединение с базой данных или файловый дескриптор. При этом функция release вызовется вне зависмости от того, завершилась ли функция use успехом или неудачей:
taskify — функция, которая позволяет превратить коллбэк в стиле Node.js в функцию, возвращающую TaskEither. taskify перегружена для оборачивания функций от 0 до 6 аргументов + коллбэк:
N.B.: Если обратите внимание, то я пишу про функции, возвращающие TaskEither, как про чистые. В прошлых статьях я вскользь затрагивал эту тему: в функциональном подходе многое строится на создании описания вычислений с последующей интерпретацией их по необходимости. Когда я буду рассказывать про свободные монады, эта тема будет раскрыта более полно; сейчас же я просто скажу, что Task/TaskEither/ReaderTaskEither/etc. — это просто значения, а не запущенные вычисления, поэтому с ними можно обращаться более вольготно, чем с промисами. Именно ленивость Task’ов позволяет им быть настолько удобной и мощной абстракцией. Код, написанный с применением TaskEither, проще рефакторить с помощью принципа ссылочной прозрачности: задачи можно спокойно создавать, отменять и передавать в другие функции.
Казалось бы, TaskEither дает хорошие выразительные способности — в типах видно, какой результат и какую ошибку может вернуть функция. Но мы можем пойти еще немного дальше и добавить еще один уровень абстракции — Reader.
Reader — доступ к неизменному вычислительному контексту
Для Reader можно определить экземпляры следующих классов типов:
Reader позволяет реализовать интересный паттерн — доступ к некоторому неизменному окружению. Предположим, мы хотим, чтобы у приложения был доступ к конфигурации со следующим типом:
Для упрощения я сделаю типы БД и express алиасами для строковых литералов — сейчас мне не так важно, какой бизнес-тип будут возвращать функции; важнее продемонстрировать принципы работы с Reader:
Для начала напишем функцию, которая соединяется с нашим фейковым экспрессом:
Функция databaseConnection работает в контексте конфига и возвращает соединение с фейковой БД:
Наконец, объединяя две вышеописанные концепции, мы приходим к последней для данной статьи абстракции — ReaderTaskEither.
ReaderTaskEither — задача, выполняющаяся в контексте окружения
N.B. Про ReaderTaskEither я достаточно много говорил на камеру в пятом эпизоде видеоподкаста «ФП для чайника». Пример, который я там рассматриваю, можно найти здесь.
На этом данную статью я заканчиваю. Абстракция ReaderTaskEither плавно подвела нас к концепции систем эффектов. Но перед тем, как рассмотреть их на примере ZIO-подобной библиотеки Effect-TS, в следующей статье я хочу поговорить о свободных конструкциях на примере свободных и более свободных монад (Free & Freer monads).
Вы можете найти примеры кода из этой статье у меня в Gist на гитхабе.
Async/await в C#: концепция, внутреннее устройство, полезные приемы
Доброго времени суток. В этот раз поговорим на тему, в которой начинал разбираться каждый уважающий себя адепт языка C# — асинхронное программирование с использованием Task или, в простонародье, async/await. Microsoft проделали хорошую работу — ведь для того, чтобы использовать асинхронность в большинстве случаев нужно лишь знание синтаксиса и никаких других подробностей. Но если лезть вглубь, тема довольно объемная и сложная. Ее излагали многие, каждый в своем стиле. Есть очень много классных статей по этой теме, но все равно существует масса заблуждений вокруг нее. Постараемся исправить положение и разжевать материал настолько, насколько это возможно, не жертвуя ни глубиной, ни пониманием.
Рассматриваемые темы/главы:
Концепция асинхронности
Асинхронность сама по себе далеко не нова. Как правило, асинхронность подразумевает выполнение операции в стиле, не подразумевающем блокирование вызвавшего потока, то есть запуск операции без ожидания ее завершения. Блокирование — это не такое зло, как его описывают. Можно встретить утверждения, что заблокированные потоки зря расходуют процессорное время, работают медленнее и вызывают дождь. Последнее кажется маловероятным? На самом деле предыдущие 2 пункта такие же.
На уровне планировщика ОС, когда поток находится в состоянии «блокирован», ему не будет выделяться драгоценное процессорное время. Вызов планировщика, как правило, приходится на операции вызывающие блокировку, прерывания по таймеру и другие прерывания. То есть когда, например, контроллер диска завершит операцию чтения и инициирует соответствующее прерывание, запустится планировщик. Он будет решать, запускать поток, который был блокирован этой операцией, или какой-то другой с более высоким приоритетом.
Медленная работа кажется еще более абсурдной. Ведь по факту работа выполняется одна и та же. Только на выполнение асинхронной операции добавляются еще небольшие накладные расходы.
Вызов дождя — это вообще что-то не из этой области.
Основная проблема блокирования — неразумное потребление ресурсов компьютера. Даже если мы забудем о времени на создание потока и будем работать с пулом потоков, то на каждый заблокированный поток расходуется лишнее место. Ну и есть сценарии, где определенную работу может осуществлять только один поток (например, UI поток). Соответственно, не хотелось бы, чтобы он был занят задачей, которую может выполнить другой поток, жертвуя выполнением эксклюзивных для него операций.
Task-based asynchronous pattern. Синтаксис и условия компиляции
Стандартный асинхронный метод в стиле TAP написать очень просто.
Было упомянуто, что метод должен содержать ключевое слово await. Оно (слово) указывает на необходимость асинхронного ожидания выполнения задачи, которую представляет тот объект задачи, к которому оно применяется.
Объект задачи, также имеет определенные условия, чтобы к нему можно было применить await:
Работа с применением TAP
Сложно идти в дебри не понимая, как что-то должно работать. Рассмотрим TAP с точки зрения поведения программы.
По терминологии: рассматриваемый асинхронный метод, чей код будет рассматриваться, я буду называть асинхронный метод, а вызываемые асинхронные методы внутри него я буду называть асинхронная операция.
Возьмем наипростейший пример, в качестве асинхронной операции возьмем Task.Delay, который осуществляет задержку на указанное время, не блокируя поток.
Выполнение метода с точки зрения поведения происходит так.
Если асинхронная операция к этому моменту завершена, то выполнение продолжается синхронно, в том же потоке.
За кулисами. Машина состояний
На самом деле наш метод преобразовывается компилятором в метод заглушку, в которой происходит инициализация сгенерированного класса — машины состояний. Далее она (машина) запускается, а из метода возвращается объект Тask, используемый на шаге 2.
Особый интерес представляет метод MoveNext машины состояний. Данный метод выполняет то, что было до преобразования в асинхронном методе. Он разбивает код на части между каждым вызовом await. Каждая часть выполняется при определенном состоянии машины. Сам метод MoveNext присоединяется к объекту ожидания в качестве продолжения. Сохранение состояния гарантирует выполнение именно той его части, которая логически следовала за ожиданием.
Как говорится, лучше 1 раз увидеть, чем 100 раз услышать, поэтому настоятельно рекомендую ознакомиться с примером ниже. Я немного переписал код, улучшил именование переменных и щедро закомментировал.
Заостряю внимание на фразе «к этому моменту не выполнился синхронно». Асинхронная операция может пойти и по синхронному пути выполнения. Главное условие для того, чтобы текущий асинхронный метод выполнялся синхронно, то есть не меняя поток — это завершенность асинхронной операции на момент проверки IsCompleted.
Истоки асинхронности. Устройство стандартных асинхронных методов
Мы рассмотрели то, как выглядит метод, использующий async и await и что происходит за кулисами. Данная информация нередка. Но важно понимать и природу асинхронных операций. Потому что как мы видели в машине состояний в коде вызываются асинхронные операции, разве что их результат обрабатывается хитрее. Однако что происходит внутри самих асинхронных операций? Вероятно, то же самое, но это не может происходить до бесконечности.
Важной задачей является понимание природы асинхронности. При попытках понять асинхронность наблюдается чередование состояний «теперь понятно» и «теперь снова непонятно». И данное чередование будет до тех пор, пока не станет понятен исток асинхронности.
При работе с асинхронностью мы оперируем задачами. Это совсем не то же самое, что поток. Одна задача может выполняться многими потоками, а один поток может выполнять много задач.
Как правило, асинхронность начинается с метода, который возвращает Task (например), но не помечен async, соответственно не использует await внутри. Такой метод не терпит никаких компиляторных изменений, выполняется как есть.
Итак, рассмотрим несколько корней асинхронности.
Файлы
Важное замечание — при желании работать с файлами асинхронно требуется указать при создании FileStream useAsync = true.
В файлах все устроено нетривиально и запутанно. Класс FileStream объявлен как partial. И помимо него существует еще 6 partial дополнений, зависящих от платформы. Так, в Unix для асинхронного доступа в произвольный файл, как правило, используется синхронная операция в отдельном потоке. В Windows существуют системные вызовы для асинхронной работы, которые, разумеется, используются. Это приводит к различиям в работе на разных платформах. Исходники.
Стандартное поведение при записи или чтении — производить операцию синхронно, если буфер позволяет и стрим не занят другой операцией:
1. Стрим не занят другой операцией
В классе Filestream есть объект, унаследованный от SemaphoreSlim параметрами (1, 1) — то есть а-ля критическая секция — фрагмент кода, защищенный этим семафором, может выполнятся в одно время только одним потоком. Данный семафор используется как при чтении, так и при записи. То есть невозможно одновременно производить и чтение, и запись. При этом блокировки на семафоре не происходит. На нем вызывается метод this._asyncState.WaitAsync(), который возвращает объект задачи (блокировки или ожидания при этом нет, оно было бы, если бы к результату метода применили ключевое слово await). Если данный объект задачи не завершен — то есть семафор захвачен, то к возвращенному объекту ожидания присоединяется продолжение (Task.ContinueWith), в котором выполняется операция. Если же объект свободен, то нужно проверить следующее
Тут уже поведение зависит от характера операции.
Для записи — проверяется, чтобы размер данных для записи + позиция в файле были меньше, чем размер буфера, который по умолчанию — 4096 байт. То есть мы должны писать 4096 байт с начала, 2048 байт со смещением в 2048 и тд. Если это так, то операция проводится синхронно, в противном случае присоединяется продолжение (Task.ContinueWith). В продолжении используется обычный синхронный системный вызов. При заполнении буфера он синхронно пишется на диск.
Для чтения — проверяется, достаточно ли данных в буфере для того, чтобы вернуть все необходимые данные. Если нет, то, опять же, продолжение (Task.ContinueWith) с синхронным системным вызовом.
Кстати, тут есть интересная деталь. В случае, если одна порция данных займет весь буфер, они будут записаны напрямую в файл, без участия буфера. При этом, есть ситуация, когда данных будет больше, чем размер буфера, но они все пройдут через него. Такое случается, если в буфере уже есть что-то. Тогда наши данные разделятся на 2 порции, одна заполнит буфер до конца и данные запишутся в файл, вторая будет записана в буфер, если влазит в него или напрямую в файл, если не влазит. Так, если мы создадим стрим и запишем в него 4097 байт то они сразу появятся в файле, без вызова Dispose. Если же мы запишем 4095, то в файле ничего не будет.
Под Windows алгоритм использования буфера и записи напрямую очень похож. Но существенное различие наблюдается в непосредственно в асинхронных системных вызовах записи и чтения. Если говорить без углубления в системные вызовы, то существует такая структура Overlapped. В ней есть важное нам поле — HANDLE hEvent. Это событие c ручным сбросом, которое переходит в сигнальное состояние по завершении операции. Возвращаемся к реализации. Запись напрямую, как и запись буфера используют асинхронные системные вызовы, которые используют вышеупомянутую структуру как параметр. При записи создается объект FileStreamCompletionSource — наследник TaskCompletionSource, в котором как раз указан IOCallback. Он вызывается свободным потоком из пула, когда операция завершается. В колбэке структура Overlapped разбирается и соответствующим образом обновляется объект Task. Вот и вся магия.
Сложно описать все, что я увидел разбираясь в исходниках. Мой путь лежал от HttpClient до Socket и до SocketAsyncContext для Unix. Общая схема такая же, как и с файлами. Для Windows используется упомянутая структура Overlapped и операция выполняется асинхронно. В Unix операции с сетью также используют функции обратного вызова.
И небольшое пояснение. Внимательный читатель заметит, что при использовании асинхронных вызовов между вызовом и колбэком некая пустота, которая каким-то образом работает с данными. Здесь стоит дать уточнение для полноты картины. На примере файлов — непосредственно вычислительными операции с диском производит контроллер диска, именно он дает сигналы о перемещении головок на нужный сектор и тд. Процессор же в это время свободен. Общение с диском происходит посредством портов ввода/вывода. В них указывается тип операции, расположение данных на диске и тд. Далее контроллер и диск занимаются выполнением этой операции и по завершению работы они генерируют прерывание. Соответственно, асинхронный системный вызов только вносит информацию в порты ввода/вывода, а синхронный еще и дожидается результатов, переводя поток в состояние блокировки. Данная схема не претендует на абсолютную точность (не об этом статья), но дает концептуальное понимание работы.
Теперь ясна природа процесса. Но у кого-то может возникнуть вопрос, а что делать с асинхронностью? Ведь невозможно вечно писать async над методом.
Во-первых. Приложение может быть сделано как служба. При этом точка входа — Main — пишется с нуля вами. До недавних пор Main не мог быть асинхронным, в 7 версии языка добавили такую возможность. Но ничего коренным образом оно не меняет, просто компилятор генерирует обычный Main, а из асинхронного делается просто статический метод, который вызывается в Main и синхронно ожидается его завершение. Итак, вероятнее всего у вас есть какие-то долгоживущие действия. Почему-то в этот момент многие начинают раздумывать как создавать потоков под это дело: через Task, ThreadPool или вообще Thread вручную, ведь в чем-то разница должна быть. Ответ прост — разумеется Task. Если вы используете подход TAP, то не надо мешать его с созданием потоков вручную. Это сродни использования HttpClient для почти всех запросов, а POST осуществлять самостоятельно через Socket.
Во-вторых. Веб приложения. Каждый поступающий запрос порождает вытягивание нового потока из ThreadPool для обработки. Пул, конечно, большой, но не бесконечный. В случае, когда запросов много, потоков на всех может не хватать, и все новые запросы будут ставиться в очередь на обработку. Такая ситуация называется голоданием. Но в случае использования асинхронных контроллеров, как обсуждалось ранее, поток возвращается в пул и может быть использован для обработки новых запросов. Таким образом существенно увеличивается пропускная способность сервера.
Мы рассмотрели асинхронный процесс с самого начала и до самого конца. И вооружившись пониманием всей этой асинхронности, противоречащей человеческой натуре, рассмотрим некоторые полезные приемы при работе с асинхронным кодом.
Полезные классы и приемы при работе с TAP
Статическое многообразие класса Task.
У класса Task есть несколько полезных статических методов. Ниже будут приведены основные из них.
ConfigureAwait
Естественно, самая популярная «продвинутая» особенность. Данный метод принадлежит классу Task и позволяет указать, необходимо ли нам выполнять продолжение в том же контексте, где была вызвана асинхронная операция. По умолчанию, без использования этого метода, контекст запоминается и продолжение ведется в нем с помощью упомянутого метода Post. Однако, как мы говорили, Post — весьма дорогое удовольствие. Поэтому, если производительность на 1-м месте, а мы видим, что продолжение не будет, скажем, обновлять UI, можно указать на объекте ожидания .ConfigureAwait(false). Это означает, что нам БЕЗРАЗЛИЧНО, где будет выполнено продолжение.
Теперь о проблеме. Как говорится страшно не незнание, а ложное знание.
Как-то довелось наблюдать код веб-приложения, где каждый асинхронный вызов был украшен сие ускорителем. Это не имеет никакого эффекта, кроме визуального отвращения. Стандартное веб-приложение ASP.NET Core не имеет каких-то уникальных контекстов (если вы их сами не напишете, конечно). Таким образом, метод Post там и так не вызывается.
TaskCompletionSource
Класс, позволяющий легко управлять объектом Task. Класс имеет широкие возможности, но наиболее полезен, когда мы хотим обернуть в задачу некоторое действие, конец которого происходит по событию. Вообще, класс был создан для адаптации старых асинхронных методов под TAP, но как мы видели, используется он не только для этого. Небольшой пример работы с данным классом:
Данный класс создает асинхронную обертку для получения имени файла, к которому в текущей папке производился доступ.
CancellationTokenSource
Позволяет отменить асинхронную операцию. Общая схема напоминает использование TaskCompletionSource. Сначала создается var cts = new CancellationTokenSource(), который, кстати, IDisposable, затем в асинхронные операции передается cts.Token. Далее, следуя какой-то вашей логике, при определенных условиях вызывается метод cts.Cancel(). Это также может подписка на событие или что угодно другое.
Использование CancellationToken является хорошей практикой. При написании своего асинхронного метода, который делает некоторую работу в фоне, допустим в бесконечном while, можно просто вставить одну строку в тело цикла: cancellationToken.ThrowIfCancellationRequested(), которая выброит исключение OperationCanceledException. Это исключение трактуется как отмена операции и не сохраняется как исключение внутри объекта задачи. Также Свойство IsCanceled на объекте Task станет true.
LongRunning
Зачастую случаются ситуации, особенно при написании служб, когда вы создаете несколько задач, которые будут работать на протяжении всей жизни службы или просто весьма долго. Как мы помним, использование пула потоков обоснованно накладными расходами на создание потока. Однако если поток создается редко (да даже раз в час), то данные расходы нивелируются и можно смело создать отдельные потоки. Для этого при создании задачи можно указать специальную опцию:
Да и вообще советую посмотреть на все перегрузки Task.Factory.StartNew, там есть много способов гибко настроить выполнение задачи под конкретные нужды.
Исключения
И еще. У планировщика есть событие TaskScheduler.UnobservedTaskException, которое срабатывает, когда выбрасывается UnobservedTaskException. Это исключение выбрасывается при сборке мусора, когда GC пытается собрать объект задачи, в котором имеется необработанное исключение.
IAsyncEnumerable
Как и положено, все выполняется настолько лениво, насколько это возможно.
Ниже представлен пример и вывод, который он дает.
Time after calling: 0
Task run: 1
element: 1
Time: 1033
Task run: 2
element: 2
Time: 3034
Task run: 3
element: 3
Time: 6035
ThreadPool
Данный класс активно используется при программировании с TAP. Поэтому дам минимальные подробности его реализации. Внутри себя ThreadPool имеет массив очередей: по одной на каждый поток + одна глобальная. При добавлении новой работы в пул учитывается поток, который инициировал добавление. В случае, если это поток из пула, работа ставится в собственную очередь этого потока, если это был другой поток — в глобальную. Когда потоку выбирается работа, сначала смотрится его локальная очередь. Если она пуста, поток берет задания из глобальной. Если же и та пуста — начинает красть у остальных. Также никогда не стоит полагаться на порядок выполнения работ, потому что, собственно, порядка то и нет. Количество потоков в пуле по умолчанию зависит от множества факторов, включая размер адресного пространства. Если запросов на выполнение больше, чем количество доступных потоков, запросы ставятся в очередь.
Потоки в пуле потоков — background-потоки (свойство isBackground = true). Данный вид потоков не поддерживает жизнь процесса, если все foreground-потоки завершились.
Системный поток наблюдает за состоянием wait handle. Когда операция ожидания заканчивается, переданный колбэк выполняется потоком из пула (вспоминаем файлы в Windows).
Task-like тип
Упомянутый ранее, данный тип (структура или класс) может быть использован в качесве возвращаемого значения из асинхронного метода. С этим типом должен быть связан тип билдера с помощью атрибута [AsyncMethodBuilder(..)]. Данный тип должен обладать упомянутыми ранее характеристиками для возможности применять к нему ключевое слово await. Может быть непараметризированным для методов не возвращающих значение и параметризированным — для тех, которые возвращают.
Сам билдер — класс или структура, каркас которой показан в примере ниже. Метод SetResult имеет параметр типа T для task-like типа, параметризированного T. Для непараметризированных типов метод не имеет параметров.
Далее будет описан принцип работы с точки зрения пишущего свой Task-like тип. Большинство это уже было описано при разборе кода, сгенерированного компилятором.
Все эти типы компилятор использует для генерации машины состояний. Компилятор знает, какие билдеры использовать для известных ему типов, здесь же мы сами указываем, что будет использовано при кодогенерации. Если машина состояний — структура, то произойдет ее упаковка при вызове SetStateMachine, билдер может закэшировать упакованную копию при необходимости. Билдер должен вызвать stateMachine.MoveNext в методе Start или после его вызова, чтобы начать выполнение и продвинуть машину состояний. После вызова Start, из метода будет возвращено значение свойство Task. Рекомендую вернуться к методу заглушке и просмотреть эти действия.
Если машина состояний успешно отрабатывает, вызывается метод SetResult, иначе SetException. Если машина состояний достигает await, выполняется метод GetAwaiter() task-like типа. Если объект ожидания реализует интерфейс ICriticalNotifyCompletion и IsCompleted = false, машина состояний использует builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine). Метод AwaitUnsafeOnCompleted должен вызвать awaiter.OnCompleted(action), в action должен быть вызов stateMachine.MoveNext когда объект ожидания завершится. Аналогично для интерфейса INotifyCompletion и метода builder.AwaitOnCompleted.
Как использовать это — решать вам. Но советую подумать раз этак 514 прежде чем применить это в продакшене, а не для баловства. Ниже приведен пример использования. Я набросал всего-лишь прокси для стандартного билдера, который выводит на консоль, какой метод был вызван и в какое время. Кстати, асинхронный Main() не хочет поддерживать кастомный тип ожидания (полагаю не один продакшен проект был безнадежно испорчен из-за этого промаха Microsoft). При желании, вы можете модифицировать прокси-логер, использовав нормальный логер и логируя больше информации.
Start
Method: Create; 2019-10-09T17:55:13.7152733+03:00
Method: Start; 2019-10-09T17:55:13.7262226+03:00
Method: AwaitUnsafeOnCompleted; 2019-10-09T17:55:13.7275206+03:00
Property: Task; 2019-10-09T17:55:13.7292005+03:00
Method: SetResult; 2019-10-09T17:55:14.7297967+03:00
Stop