Вообразите, каким бы стал мир, если бы люди работали так же, как компьютерные программы:
void ServeBreakfast(Customer diner)
{
var order = ObtainOrder(diner);
var ingredients = ObtainIngredients(order);
var recipe = ObtainRecipe(order);
var meal = recipe.Prepare(ingredients);
diner.Give(meal);
}
Каждую подпрограмму можно, конечно, разбить на более мелкие процедуры; приготовление еды могло бы включать прогрев сковородок, жарку омлетов и тостов. Если бы люди выполняли подобные задачи так же, как и типичные компьютерные программы, мы досконально описывали бы все в виде последовательностей иерархических задач и с одержимостью проверяли бы, что каждая задача выполнена перед тем, как переходить к другой.
Подход на основе подпрограмм вроде бы логичен — нельзя жарить яйца, пока не получен соответствующий заказ, — но на деле получается, что вы зря тратите время и делаете приложение неотзывчивым. Время тратится потому, что жарить хлеб надо тогда, когда готовятся яйца, а не после того, как они приготовлены и остыли. А неотзывчивым приложение кажется вот почему. Если текущий заказ еще готовится и в этот момент приходит новый клиент, то вы наверняка захотите принять его заказ, не оставив ждать в дверях, пока не обслужат текущего клиента. Увы, официант в маленьком кафе, послушно следующий списку задач, не имеет никакой возможности своевременно откликаться на неожиданные события.
Решение 1: нанимаем больше работников, создавая больше потоков
Приготовление завтрака — пример весьма надуманный, но на деле может быть все, что угодно. Всякий раз, когда вы передаете управление длительно выполняемой подпрограмме в UI-потоке, UI полностью перестает отвечать до завершения этой подпрограммы. Да и как может быть иначе? Приложения реагируют на события в UI, выполняя код в UI-потоке, а этот поток занят чем-то другим. Только когда все задачи в его списке закончены, он начнет обрабатывать стоящие в очереди команды от растерянного пользователя. Стандартное решение этой проблемы — использовать параллельную обработку для выполнения двух или более операций «одновременно». (Если два потока работают на двух независимых процессорах, они могли бы выполняться по-настоящему одновременно. В случаях, когда потоков больше, чем процессоров, ОС будет имитировать одновременное выполнение, периодически выделяя каждому потоку квант процессорного времени.)
Одним из решений могло бы стать создание пула потоков и закрепление каждого нового клиента за конкретным потоком для обработки его запросов. В нашей аналогии вы могли бы нанять группу официантов. Когда заходит новый посетитель, за ним закрепляется один из свободных официантов. После этого каждый официант независимо делает свою работу — принимает заказ, ищет ингредиенты, готовит еду и обслуживает посетителя.
Сложность с этим подходом в том, что события UI, как правило, прибывают в тот же поток и должны обрабатываться полностью в нем. Большинство UI-компонентов создают запросы к UI-потоку и предполагает взаимодействие с ними только в этом потоке. Поэтому выделение нового потока каждой задаче, связанной с UI, вряд ли будет хорошо работать.
Чтобы решить эту проблему, можно было бы в UI-потоке слушать события UI и не делать ничего, кроме «приема заказов» и передачи их в один или более фоновых рабочих потоков. В этой аналогии всех посетителей обслуживает только один официант, а на кухне полно поваров, которые и выполняют запрошенную работу. Далее UI-поток и рабочие потоки координируют свое взаимодействие друг с другом. Повара никогда не общаются напрямую с посетителями, но клиенты так или иначе обслуживаются.
По умолчанию поток расходует миллионы байтов виртуальной памяти в своем стеке.
Это определенно решает проблему своевременной реакции на события UI, но не устраняет неэффективность; код, выполняемый в рабочем потоке, по-прежнему синхронно ждет, когда закончится готовка яиц, прежде чем переходить к обжариванию хлеба в тостере. Эту проблему можно было бы решить, добавив еще больше параллельной обработки: над заказом могли бы трудиться два повара, например один готовит яйца, другой — тосты. Но это увеличивает издержки. А сколько поваров может понадобиться в вашем случае и что будет, когда им всем придется координировать свою работу?
Параллельная обработка такого рода вызывает много хорошо известных трудностей. Во-первых, создание потока влечет за собой серьезные издержки; по умолчанию поток расходует миллионы байтов виртуальной памяти в своем стеке и тратит множество других системных ресурсов. Во-вторых, UI-объекты зачастую «привязаны» к UI-потоку, и их нельзя вызывать из рабочих потоков; UI- и рабочий потоки должны образовать весьма сложную систему, где UI-поток может посылать рабочему потоку необходимую информацию от UI-элементов, а тот — отправлять обновления обратно UI-потоку, а не напрямую UI-элементам. Такую систему трудно выразить в коде, и она подвержена конкуренции потоков, взаимоблокировкам и другим проблемам, характерным для многопоточности. В-третьих, многие приятные вещи, на которые мы полагаемся в однопоточном мире (например предсказуемое и согласованное чтение/запись памяти), в многопоточном мире не являются надежными. А это вызывает худшие виды трудновоспроизводимых ошибок.
Использовать параллельную обработку на основе потоков в простых программах только для того, чтобы они сохраняли отзывчивость на события, — то же самое, что использовать микроскоп вместо кувалды. Ведь люди же как-то умудряются решать сложные задачи и в то же время реагировать на внешние события? В реальном мире вам и в голову не придет закреплять по официанту за каждым столиком или выделять двух поваров на каждый заказ, чтобы обслужить десяток одновременных клиентских запросов. Решение проблемы на основе потоков требует слишком много поваров. Должно быть решение получше, без чрезмерного параллелизма.
Решение 2: развитие синдрома дефицита внимания с помощью DoEvents
Универсальное «непараллельное» решение проблемы с неотзывчивостью UI в ходе длительно выполняемых операций — умышленное разбрызгивание волшебных слов Application.DoEvents по всей программе до тех пор, пока проблема не исчезнет. Хотя такое решение определенно прагматическое, оно не очень-то хорошо продуманное:
void ServeBreakfast(Customer diner)
{
var order = ObtainOrder(diner);
Application.DoEvents();
var ingredients = ObtainIngredients(order);
Application.DoEvents();
var recipe = ObtainRecipe(order);
Application.DoEvents();
var meal = recipe.Prepare(ingredients);
Application.DoEvents();
diner.Give(meal);
}
Применение DoEvents в основном означает: «Посмотри, не случилось ли чего интересного, пока я был занят той последней задачей. Если случилось нечто, на что я должен отреагировать, запомни, что я делаю прямо сейчас, а я переключусь на новую задачу. И проследи, чтобы я потом вернулся туда, где прервался». Это заставляет вашу программу вести себя так, будто у нее синдром дефицита внимания: все новое моментально отвлекает ее внимание. На первый взгляд такое решение вполне приемлемо для улучшения отзывчивости — и даже иногда работает, — но этот подход создает кучу проблем.
Во-первых, DoEvents лучше всего работает, когда задержка вызывается циклом, который должен выполняться много раз, но выполнение каждой его итерации отнимает минимум времени. Проверяя наличие необработанных событий каждые несколько итераций цикла, вы можете поддерживать отзывчивость программы, даже если весь цикл выполняется долго. Однако такой шаблон обычно не является причиной проблемы с отзывчивостью. Гораздо чаще проблема вызывается одной из длительно выполняемых операций, отнимающей много времени, такой как попытка синхронного доступа к файлу через сеть с большими задержками. Возможно, в нашем примере длительно выполняемой задачей является само приготовление еды, и здесь нет места, где вставка DoEvents дала бы хоть какую-то пользу. А если даже такое место есть, оно окажется в каком-нибудь методе, исходным кодом которого вы не располагаете.
Во-вторых, вызов DoEvents заставляет программу пытаться полностью обслужить все недавние события, не закончив работу, связанную с более ранними событиями. Представьте себе, что никто не получит свой заказ до тех пор, пока не будут готовы заказы всех посетителей! А если они будут постоянно прибывать, первый клиент может вообще никогда не получить заказ и остаться голодным. Фактически возможно и такое, что никто из клиентов не получит свои блюда. Окончание работы, связанной с более ранними событиями, может произвольно откладываться, если все время поступают новые события и прерывают обработку предыдущих.
В-третьих, DoEvents создает очень высокий риск неожиданного повторного вхождения (reentrancy). То есть, обслуживая одного клиента, вы проверяете, не было ли любых недавних интересных событий в UI, и случайно начинаете заново обслуживать того же клиента, хотя заказ от него уже был принят. Большинство разработчиков не проектирует свой код так, чтобы обнаруживать эту разновидность реентерабельности, а значит, программа может впадать в крайне странные состояния, если алгоритм не был рассчитан на неожиданный рекурсивный вызов самого себя через DoEvents.
Итак, DoEvents следует применять только для устранения проблемы отзывчивости в самых тривиальных случаях; это плохое решение для управления отзывчивостью UI в сложных программах.
Решение 3: выверните наизнанку список задач с помощью обратных вызовов
Непараллельная природа методики DoEvents привлекательна, но это явно неправильное решение для сложной программы. Идея получше — разбивать элементы в списке на серии коротких задач, каждую из которых можно выполнять достаточно быстро, чтобы приложение выглядело очень отзывчивым.
Эта идея не нова; разбиение сложной задачи на малые части — главная причина, по которой вообще появились подпрограммы. Интересная особенность заключается в следующем. Вместо прямолинейного прохода по списку операций для определения того, что уже сделано и что нужно сделать следующим, и возврата управления вызвавшему только по окончании всей работы каждой новой задаче назначается список работы, которая должна быть выполнена после этой задачи. И такая работа называется продолжением (continuation) задачи.
По окончании задача может найти продолжение и немедленно выполнить его или запланировать на выполнение позже. Если продолжение требует данных, вычисляемых предыдущей задачей, последняя может передавать эти данные как аргумент вызова, запускающий продолжение.
При таком подходе общий массив работы фактически разбивается на малых части, каждую из которых можно выполнить очень быстро. Система кажется отзывчивой, потому что отложенные события могут быть обнаружены и обработаны между выполнением любых двух частей работы. Но, поскольку любые операции, связанные с этими новыми событиями, тоже можно разбивать на малые части и ставить в очередь на более позднее выполнение, мы избавляемся от проблемы «голодания», где новые задачи не дают завершить выполнение старых. Новые длительно выполняемые задачи не обрабатываются немедленно, а ставятся в очередь и обрабатываются постепенно.
Идея великолепная, но как реализовать это решение, не ясно. Основная трудность — определить, как сообщать каждой малой единице работы о ее продолжении, т. е. какая работа должна быть проделана следующей.
В традиционном асинхронном коде для этого обычно регистрируется функция обратного вызова. Допустим, у нас есть асинхронная версия Prepare, принимающая функцию обратного вызова, которая сообщает, что делать дальше, например принести готовые блюда клиенту:
void ServeBreakfast(Diner diner)
{
var order = ObtainOrder(diner);
var ingredients = ObtainIngredients(order);
var recipe = ObtainRecipe(order);
recipe.PrepareAsync(ingredients, meal =>
{
diner.Give(meal);
});
}
Теперь ServeBreakfast немедленно возвращает управление после PrepareAsync; какой бы код ни вызвал ServeBreakfast она потом свободна в обслуживании других произошедших событий. PrepareAsync сама не выполняет никакой «реальной» работы; вместо этого она быстро делает все необходимое для того, чтобы еда гарантированно была приготовлена в будущем. Более того, PrepareAsync также гарантирует, что в некий момент по окончании задачи готовки еды будет запущен метод обратного вызова с готовой едой в качестве аргумента. Таким образом, посетитель в конечном счете будет обслужен, хотя, возможно, ему придется некоторое время подождать, если между окончанием готовки и моментом обслуживания клиента возникнет какое-то событие, требующее внимания.
Заметьте, что ничего из этого не требует обязательного второго потока. PrepareAsync может вызывать готовку пищи в отдельном потоке, а может ставить серию коротких задач, связанных с этим процессом, в очередь в UI-потоке, чтобы они были выполнены позже. На самом деле это не имеет значения; все, что нам известно, — PrepareAsync каким-то образом гарантирует две вещи: еда будет приготовлена так, чтобы не блокировать UI-поток операцией с большими задержками, а по окончании готовки будет запущен обратный вызов.
Разбиение сложной задачи на малые части — главная причина, по которой вообще появились подпрограммы.
Но предположим, что любой из методов получения заказа, поиска ингредиентов, получения рецепта или готовки еды может быть таким, который замедляет работу UI. Мы могли бы справиться с этой более крупной задачей, если бы располагали асинхронной версией каждого из этих методов. Как выглядела бы тогда программа? Вспомните, что каждому методу нужно назначить обратный вызов, сообщающий, что делать, когда завершается единица работы:
void ServeBreakfast(Diner diner)
{
ObtainOrderAsync(diner, order =>
{
ObtainIngredientsAsync(order, ingredients =>
{
ObtainRecipeAsync(order, recipe =>
{
recipe.PrepareAsync(ingredients, meal =>
{
diner.Give(meal);
})})})});
}
Это код может показаться жуткой мешаниной, но это ничто по сравнению с тем, насколько плохи получаются реальные программы, когда их переписывают с применением асинхронности на основе обратных вызовов. Задумайтесь о том, как сделать цикл асинхронным или что делать с исключениями, блоками try-finally или другими нетривиальными формами потока управления. В конечном счете вы просто вывернули бы свою программу наизнанку: ее код теперь выражал бы то, как связаны между собой все обратные вызовы, а не логический рабочий процесс.
Решение 4: переложите на компилятор решение проблемы с помощью асинхронности на основе задач
Асинхронность на основе обратных вызовов действительно поддерживает отзывчивость UI-потока и минимизирует время, которое тратится на синхронное ожидание длительно выполняемых операций. Но лекарство оказывается страшнее болезни. Цена, которую придется заплатить за отзывчивость UI и производительность, — код, выражающий, как работают механизмы асинхронности, и затуманивающий смысл и цели программы.
Вместо этого в предстоящих версиях C# и Visual Basic вы сможете писать код, выражающий смысл и цели программы, в то же время давая компилятору достаточно подсказок для формирования необходимых механизмов «за кулисами» — скрытно от вас. Решение состоит из двух частей: одна находится в системе типов, а другая — в самом языке.
В CLR 4 был определен тип Task<T> — рабочая лошадка Task Parallel Library (TPL), — представляющий концепцию «некоей работы, которая в будущем даст результат типа T». Концепция «работы, которая завершится в будущем, но не вернет никаких результатов» представлена необобщенным типом Task.
Как именно будет получен результат типа T — деталь реализации конкретной задачи; работа может быть полностью передана другой машине, другому процессу на той же машине, другому потоку, или же вся работа заключается в простом чтении ранее кешированного результата, к которому можно без издержек обратиться из текущего потока. TPL-задачи, как правило, передаются рабочим потокам из пула в текущем процессе, но эта деталь реализации не является фундаментальной для типа Task<T> — скорее, он может представлять любую операцию с высокими задержками, которая дает результат типа T.
Язык половины решения основан на новом ключевом слове await. Обычный вызов метода подразумевает «запомнить, что я делаю, выполнять этот метод, пока он полностью не завершится, а затем продолжить с того места, где я прервался, теперь зная результат данного метода». Выражение await, в противоположность этому, означает «оценить это выражение, чтобы получить объект, представляющий работу, которая в будущем приведет к результату. Зарегистрировать остаток текущего метода как обратный вызов, сопоставленный с продолжением этой задачи. После формирования задачи и регистрации обратного вызова немедленно вернуть управление вызвавшему коду».
Наш небольшой пример, переписанный в новом стиле, читается гораздо легче:
async void ServeBreakfast(Diner diner)
{
var order = await ObtainOrderAsync(diner);
var ingredients = await ObtainIngredientsAsync(order);
var recipe = await ObtainRecipeAsync(order);
var meal = await recipe.PrepareAsync(ingredients);
diner.Give(meal);
}
В этом фрагменте каждая асинхронная версия возвращает Task<Order>, Task<List<Ingredient>> и т. д. Всякий раз, когда встречается await, текущий исполняемый метод регистрирует свою оставшуюся часть как нечто, что нужно будет выполнить по завершении текущей задачи, а затем немедленно вернуть управление. Так или иначе, каждая задача сама завершает себя — она либо планируется для обработки как событие в текущем потоке, либо использует поток завершения ввода-вывода или рабочий поток, — а затем заставляет свое продолжение «возобновить работу с того места, где она была прервана» и тем самым выполнить оставшуюся часть метода.
Заметьте, что метод теперь помечен новым ключевым словом async; он просто указывает компилятору, что в контексте данного метода ключевое слово await следует интерпретировать как точку, в которой рабочий процесс возвращает управление вызвавшему коду и откуда он возобновляется по окончании сопоставленной задачи. Также обратите внимание на то, что в примерах, показанных в этой статье, я использовал C#; в Visual Basic появятся аналогичные средства со схожим синтаксисом. Архитектура этих средств в C# и Visual Basic в очень большой степени опирается на концепцию асинхронных рабочих процессов, уже некоторое время существующую в F#.
Где узнать больше
В этом кратком введении я просто изложил мотивы введения нового средства поддержки асинхронности в C# и Visual Basic и в нескольких словах обрисовал суть этой функциональности. Более подробные объяснения того, как все это работает на внутреннем уровне, и описание факторов, влияющих на производительность асинхронного кода, читайте в следующих двух статьях моих коллег в этом номере.
Если вы хотите пощупать руками предварительную версию этой функциональности вместе с примерами и спецификациями, пообщаться на эту тему на соответствующем форуме, а также высказать свои замечания и предложения, пожалуйста, зайдите на сайт msdn.com/async. Эти языковые средства и поддерживающие их библиотеки все еще находятся на стадии разработки, поэтому группа разработчиков была бы рада максимально полной обратной связи.