Асинхронность в C# 5. Часть 6: насколько асинхронно?
Уже несколько людей задали мне вопрос о том, чем руководствовались разработчики языка, требуя, чтобы в объявлении каждого метода, содержащего выражение “await”, присутствовало контекстное ключевое слово “async”.
Как и в любом решении, здесь есть свои «за» и «против», которые должны приниматься во внимание в контексте множества противоречивых и несовместимых принципов. Здесь нет простого решения, которое бы соответствовало всем критериям и нравилось бы каждому. Мы всегда стремимся к достижимому компромиссу, а не к недостижимому идеалу. И это решение является отличным примером.
Одним из ключевых наших принципов является «избегать ломающих изменений, насколько это возможно». В идеале было бы здорово, если бы любая программа, которая работала в C# 1, 2, 3 и 4, работала бы и в C# 5 (*). Как я уже упоминал несколько сообщений назад, (**) при добавлении префиксного оператора существует несколько вариантов неоднозначности, и мы хотим устранить их все. Мы рассмотрели множество эвристик, которые бы определяли, является ли “await” в данном случае идентификатором или ключевым словом, однако ни одна из них нам не подошла.
Эвристики для ключевых слов “var” и “dynamic” были куда проще, поскольку “var” имеет особый смысл только при объявлении локальной переменной, а “dynamic” – только в контексте, в котором возможно применение типа. Ключевое слово “await” корректно практически в любой точке тела метода, где корректно применение типа или выражения, что существенно увеличивает количество мест, для которых эта эвристика должна быть спроектирована, реализована и протестирована. Эта эвристика оказалась запутанной и сложной. Например, var x = y + await; очевидно, мы должны рассматривать await в качестве идентификатора, однако делает ли var x = await + y, то же самое, или в этом случае мы имеем ожидание (await) унарного оператора +, который применяется к y? var x = await t; должны ли мы здесь трактовать await как ключевое слово; или это тоже самое, что и var x = await(t); или в этом случае – это вызов метода с именем await?
Требование ключевого слова “async” позволит одним махом устранить все проблемы обратной совместимости; любой метод, содержащий выражения await, должен быть «новой конструкцией», а не «старым кодом», поскольку старый код никогда не содержал модификатор async.
Альтернативным решением, которое также избавит нас от всех проблем обратной совместимости, является использование для выражений ожидания ключевых слов, состоящих из двух слов. Так мы поступили, введя “yield return”. Мы рассмотрели множество вариантов и моим любимым был “wait for”. Мы сразу же отбросили варианты типа “yield with”, “yield wait” и тому подобные, поскольку посчитали, что они будут сбивать с толку из-за разницы поведения продолжений и блоков итераторов. Мы здорово научили людей тому, что логически “yield”означает «дай мне значение», а не «поток управления возвращается вызывающему коду», хотя он означает и то и другое! Мы также отбросили варианты, содержащие “return” и “continue”, поскольку их легко спутать с соответствующими операторами управления потоком выполнения. Варианты с “while” также не подходят; начинающий программист станет задавать вопрос, будет ли цикл “while” завершаться после того, как условие станет возвращать false, или будет выполняться все тело цикла.
Конечно, использование ключевого слова “await” также вызывает проблемы. По сути, проблема заключается в том, что у нас есть два типа ожидания. Когда вы ожидаете в больнице, то вы можете уснуть, пока дождетесь доктора. Или вы можете ожидать, читая журнал, подсчитывая баланс в своей чековой книжке, звонить вашей маме, разгадывать кроссворд или заниматься чем-то еще. Суть асинхронности на основе задач заключается в использовании второй модели ожидания: вы хотите, чтобы ожидая завершения задачи, дела продолжали выполняться текущим потоком, вместо того, чтобы текущий поток просто спал. Таким образом, вы начинаете ожидание, запоминаете то, что вы делали и начинаете заниматься чем-то другим, пока вы ждете. Я надеюсь, что обучение пользователей тому, какая модель ожидания используется в данном случае является преодолимой задачей.
В конце концов, будет ли использоваться “await” или нет, разработчики языка хотят, чтобы это было одно ключевое слово. Мы предполагаем, что эта возможность будет использована несколько раз в одном методе. Многие блоки итераторов содержат одно или два выражения yield return, однако метод может содержать десятки ключевых слов await, которые выполняют сложные асинхронные операции. Короткое имя оператора в этом случае очень важно.
Конечно, мы не хотим, чтобы оно было слишком коротким. В языке F# для асинхронных операций используются ключевые слова “do!”, “let!” и т.п. Код! при! этом! выглядит! здорово! однако его тяжелее понять, поскольку синтаксис не очевиден. Когда вы видите ключевые слова “async” и “await” у вас есть хотя бы какой-то шанс понять, что они означают.
Другой принцип заключается в том, что «следует быть последовательным с другими возможностями языка». Здесь мы попадаем в противоречивое положение. С одной стороны, нам не нужно писать «итератор» (iterator) перед именем метода, содержащего блок итераторов. (Если бы нам приходилось это делать то вместо записи “yield return x;” нам достаточно было бы писать просто “yield x;”.) Такое решение кажется непоследовательным по отношению к блоку итераторов. С другой стороны… давайте вернемся к этому позднее.
Другой принцип, которым мы руководствовались, является «принцип наименьшего удивления». Если говорить конкретнее, то это небольшое изменение не должно приводить к неожиданным результатам. Давайте рассмотрим следующий код:
void Frob<X>(Func<X> f) { ... }
...
Frob(()=> {
if (whatever)
{
await something;
return 123;
}
return 345;
} );
Кажется странным и непонятным, что закомментировав строку “await something;” изменится тип, выведенный для обобщенного типа x, с Task<int> на int. Мы не хотим добавлять аннотации к возвращаемым значениям лямбда-выражений. Скорее мы потребуем использовать ключевое слово “async” в лямбда-выражениях, содержащих “await”:
Frob(async ()=> {
if (whatever)
{
await something;
return 123;
}
return 345;
} );
В данном случае тип, выводимый для параметра X, будет Task<int> даже после того, как вы закомментируете эту строку кода.
Это серьезный аргумент, для требования ключевого слова “async” с лямбда-выражениями. А поскольку мы хотим, чтобы возможности языка были последовательными, кажется непоследовательным требовать “async” для анонимных функций и не требовать его для нормальных методов. Это косвенным образом оказывает влияние на использование этого ключевого слова также и с нормальными методами.
Еще один пример небольшого изменения, который приведет к серьезным последствиям:
Task<object> Foo()
{
await blah;
return null;
}
Если ключевое “async” не будет являться обязательным, тогда данный метод с ключевым словом “await” приведет к созданию задачи, не равной null, результат выполнения которой будет равен null. После того, как мы закомментируем код с “await”, например, в тестовых целях, результатом этого метода будет экземпляр задачи, равный null, а ведь это совершенно иной результат. Если мы будем требовать наличие ключевого слова “async”, тогда этот метод в обоих случаях будет возвращать один и тот же результат.
Другим принципом является то, что все, что идет перед телом объявляемой сущности, такой как метод, все это сохраняется в метаданных этой сущности. Имя, тип возвращаемого значения, параметры типа, формальные параметры, атрибуты, модификаторы доступа, является ли метод, статическим/экземплярным/виртуальным/абстрактным/ запечатанным и т.д. все это является частью метаданных метода. Наличие ключевых “async” и “part” – не добавляют информацию в метаданные, что кажется непоследовательным. Однако подойдите с другой стороны: ключевое слово “async” предназначено исключительно для описания деталей реализации метода; оно никак не влияет на то, как этот метод будет использоваться. Вызывающему коду все равно, помечен метод как “async” или нет, так зачем же тогда помещать это ключевое слово туда, где человек, разрабатывающий вызывающий код наверняка его увидит? Это аргумент против ключевого слова “async”.
С другой стороны, другой важный принцип проектирования гласит, что важный код должен привлекать к себе внимание. Ведь код читается гораздо чаще, нежели пишется. Асинхронные методы по сравнению с обычными методами обладают совершенно другим потоком выполнения; так что вполне разумно, поместить информацию об этом в самом начале, чтобы человек, поддерживающий этот код, увидел это сразу же. Блоки итераторов обычно являются короткими; я не помню, чтобы когда-либо писал блок итератора, который не помещается на одну страницу. Поэтому обычно весьма просто глянуть на блок итератора и увидеть все возвращаемые значения. Некоторые предполагают, что асинхронные методы могут быть весьма длинными и ключевые слова “await” могут быть зарыты внутри этого кода и не видны сразу же. Поэтому очень важно, чтобы вы с первого взгляда на заголовок метода понимали, что он ведет себя как сопрограмма.
Еще одним важным принципом проектирования является следующий: «язык должен быть приспособлен для использования с различными инструментами». Предположим, мы требуем наличия ключевого слова “async”. Какие ошибки может допустить пользователь? Пользователь может написать метод с модификатором async, который не содержит ключевых слов await и при этом он может ожидать, что этот метод будет выполняться в другом потоке. Или пользователь может воспользоваться ключевыми словами await, но забыть при этом модификатор async. В обоих случаях мы можем написать анализатор кода, который обнаружит эти проблемы и выведет диагностическое сообщение, которое научит пользователя использовать эту возможность надлежащим образом. В этом сообщении, например, может говориться, что метод с модификатором async но без ключевых слов await не будет выполняться в другом потоке, и может содержать совет о том, как добиться параллелизма в том случае, если он вам требуется. Или в этом сообщении может говориться о том, что метод, возвращающий int и содержащий оператор await должны быть преобразованы (возможно, автоматически!) в асинхронный метод, возвращающий Task<int>. Этот анализатор может также просматривать все места, вызова текущего метода и предлагать, стоит ли и их преобразовать в асинхронные методы, путем добавления в их сигнатуры модификатора async. Если модификатор доступа “async” будет необязательным, тогда разработать такой анализатор для диагностики этих проблем будет весьма сложно.
Существует множество «за» и «против»; после анализа всего этого, и после использования прототипа компилятора, разработчики языка C# решили, что модификатор “async” должен быть обязательным в методах, содержащих оператор “await”. Я думаю, это разумное решение.
Благодарности: Огромная благодарность моему коллеге Люсиану, за его проницательность и за отличное краткое изложение проектных заметок, ставших основой этого эпизода.
В следующий раз: Я хочу поговорить об исключениях и затем сделать на некоторое время перерыв с async/await. Десяток постов по одной и той же теме всего за пару недель, это через чур.
-----------------------------
(*) Мы нарушали этот принцип множество раз, как (1) случайно, так и (2) преднамеренно, когда выгоды от этого были значительными, а вероятность поломки старого кода была относительно невысокой. Знаменитым примером второго случая является следующий код: F(G<A,B>(7)). В C# 1 этот код означал, что метод F принимает два аргумента, оба оператора сравнения. В C# 2 этот код означал, что F принимает один аргумент, а G являлся обобщенным методом с арностью, равной двум.
(**) Когда я писал эту статью, я знал, что мы добавим “await”, как префиксный оператор. Эту статью писать было весьма просто, поскольку совсем недавно мы прошли весь этот нудный процесс работы над спецификацией и поиска всех возможных неоднозначностей. Конечно, я не мог использовать “await” в качестве примера в сентябре, поскольку мы не хотели, чтобы кто-то узнал о новых возможностях языка C# 5, поэтому я выбрал слово “frob” (программка), как совершенно ничего не значащее.