Файл: Асинхронноепрограммирование.pdf

ВУЗ: Не указан

Категория: Не указан

Дисциплина: Не указана

Добавлен: 10.11.2023

Просмотров: 143

Скачиваний: 3

ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
ГЛАВА 5.
Что в действительности
делает await
Воспринимать механизм async в C# 5.0 и, в частности, ключевое сло- во await можно двумя способами:
• как языковое средство с четко определенным поведением, ко- торое можно изучить и применять;
• как преобразование программы на этапе компиляции, то есть
синтаксическую глазурь, скрывающую более сложный код на
C#, в котором ключевое слово async не используется.
Обе точки зрения верны, это всего лишь две стороны одной медали.
В этой главе нас будет интересовать первый взгляд на async. В главе 14 мы взглянем на этот механизм под другим углом зрения; это сложнее, зато мы познакомимся с деталями, которые позволят с открытыми глазами заниматься отладкой и повышением производительности.
Приостановка и возобновление
метода
Когда поток исполнения программы доходит до оператора await
, должны произойти две вещи.
• Текущий поток должен быть освобожден, чтобы поведение про- граммы было асинхронным. С привычной, синхронной, точки зрения это означает, что метод должен вернуть управление.
• Когда задача
Task
, погруженная в оператор await
, завершит- ся, ваш метод должен продолжить выполнение с того места, где перед этим вернул управление, как будто этого возврата никогда не было.
Чтобы добиться такого поведения, метод должен приостановить выполнение, дойдя до await
, и возобновить его впоследствии.

42
Глава 5. Что в действительности делает await
Я рассматриваю эту последовательность событий как аналог режи- ма гибернации компьютера (режим S4), только в более мелком мас- штабе. Текущее состояние метода сохраняется, и метод возвращает управление. При переходе в режим гибернации динамическое состо- яние всех программ записывается на диск и компьютер выключается.
Отключение питания не нанесет компьютеру, находящемуся в режи- ме гибернации, никакого вреда. Точно так же, ожидающий метод не потребляет никаких ресурсов, кроме разве что крохотного объема па- мяти, поскольку запустивший его поток освобожден.
Если продолжить аналогию, то блокирующий метод больше все- го напоминает спящий режим компьютера (режим S3). В нем компьютер потребляет меньше ресурсов, но по существу про- должает работать.
В идеале программист не должен замечать, что имела место гибер- нация. Несмотря на то, что приостановка метода в середине и после- дующее возобновление – довольно сложная операция, C# гарантиру- ет, что ваша программа продолжит выполнение, как будто ничего не случилось.
Состояние метода
Чтобы стало яснее, сколько работы должен выполнить компилятор
C#, встретив в программе оператор await
, я перечислю, какие имен- но аспекты состояния метода необходимо сохранить.
Во-первых, запоминаются все локальные переменные метода, в том числе:
• параметры метода;
• все переменные, определенные в текущей области видимос- ти;
• все прочие переменные, например счетчики циклов;
• переменную this
, если метод не статический. В результате после возобновления метода окажутся доступны все перемен- ные-члены класса.
Всё это сохраняется в виде объекта в куче .NET, обслуживаемой сборщиком мусора. Таким образом, встретив await
, компилятор вы- деляет память для объекта, то есть расходует ресурсы, но в большинс- тве случае это не приводит к потере производительности.
C# также запоминает место, где встретился оператор await
. Эту информацию можно представить в виде числа, равного порядковому


43
Контекст номеру достигнутого оператора await среди всех прочих встречаю- щихся в методе.
На способ использования выражений await не накладывается ни- каких ограничений. Например, это может быть часть объемлющего выражения, возможно, содержащего несколько await
:
int myNum = await AlexsMethodAsync(await myTask, await StuffAsync());
Поэтому на время ожидания завершения операции требуется также запомнить состояние остальной части выражения. В примере выше результат выражения await myTask необходимо сохранить на время исполнения await StuffAsync()
. В промежуточном языке
.NET (IL) такие подвыражения сохраняются в стеке, поэтому, встре- тив оператор await
, компилятор должен запомнить еще и этот стек.
Далее, когда программа доходит до первого await в методе, этот метод возвращает управление. Если метод не объявлен как async void
, то в этот момент возвращается объект
Task
, чтобы вызываю- щая программа могла дождаться завершения. C# должен также за- помнить этот объект, чтобы по завершении метода его можно было заполнить и продолжить выполнение цепочки асинхронных методов.
Как именно это делается, мы рассмотрим в главе 14.
Контекст
Пытаясь сделать процесс ожидания максимально прозрачным, C# запоминает различные аспекты контекста в точке, где встретился await
, а затем, при возобновлении метода, восстанавливает их.
Наиболее важным из всех является контекст синхронизации, ко- торый среди прочего позволяет возобновить выполнение метода в конкретном потоке. Это особенно важно для приложений с графи- ческим интерфейсом, которым можно манипулировать только из од- ного, вполне определенного потока. Контекст синхронизации – это сложная тема, к которой мы еще вернемся в главе 8.
Есть и другие виды контекста вызывающего потока, которые так- же необходимо запомнить. Все они собираются в соответствующих классах, наиболее важные из которых перечислены ниже.
ExecutionContext
Это родительский контекст, включающий все остальные кон- тексты. Он не имеет собственного поведения, а служит только для запоминания и передачи контекста и используется такими компонентами .NET, как класс
Task

44
Глава 5. Что в действительности делает await
SecurityContext
Здесь хранится информация о безопасности, обычно действу- ющая только в пределах текущего потока. Если код должен выполняться от имени конкретного пользователя, то, вероят- но, ваша программа олицетворяет этого пользователя, или за вас это делает ASP.NET. В таком случае сведения об олицетво- рении хранятся в объекте
SecurityContext
CallContext
Позволяет программисту сохранить определенные им самим данные, которые должны быть доступны на протяжении всего времени жизни логического потока. В большинстве случаев ис- пользование этой возможности не поощряется, но таким обра- зом можно избежать передачи большого числа параметров ме- тодам в пределах одной программы, поместив их вместо этого в контекст вызова. Класс
LogicalCallContext предназначен для той же цели, но работает через границы доменов приложений.
Следует отметить, что поточно-локальная память, предназна- ченная для той же цели, что
CallContext, для асинхронных про- грамм не годится, потому что на время выполнения длительной операции поток освобождается и может быть использован для других целей. Ваш метод может быть возобновлен совершенно в другом потоке.
Перед возобновлением метода C# восстанавливает все эти контек- сты. С восстановлением сопряжены определенные накладные расхо- ды. Например, асинхронная программа, в которой используется оли- цетворение, может работать существенно медленнее. Я рекомендую не пользоваться средствами .NET, создающими контексты, если это не является насущной необходимостью.
Когда нельзя использовать await
Оператор await можно использовать почти в любом месте метода, помеченного ключевым словом async
. Однако есть несколько случа- ев, когда использование await запрещено. Ниже я объясню, почему это так.
Блоки catch и finally
Оператор await может встречаться внутри блока try
, но не внутри блоков catch или fi nally
. В блоке catch часто, а в блоке fi nally всег-


45
Когда нельзя использовать await да, исключение еще находится в фазе раскрутки стека и позже может быть возбуждено в блоке повторно. Если использовать в этой точке await
, то стек окажется другим, и определить в этой ситуации пове- дение повторного возбуждения исключения было бы очень сложно.
Напомню, что await всегда можно поставить не внутри блока catch
, а после него, для чего следует либо воспользоваться предло- жением return
, либо завести булевскую переменную, в которой за- помнить, возбуждала ли исходная операция исключение. Например, вместо такого некорректного в C# кода:
try
{
page = await webClient.DownloadStringTaskAsync(“http://oreilly.com”);
}
catch (WebException)
{
page =
await webClient.DownloadStringTaskAsync(“http://oreillymirror.com”);
}
можно было бы написать:
bool failed = false;
try
{
page = await webClient.DownloadStringTaskAsync(“http://oreilly.com”);
}
catch (WebException)
{
failed = true;
}
if (failed)
{
page =
await webClient.DownloadStringTaskAsync(“http://oreillymirror.com”);
}
Блоки lock
Ключевое слово lock позволяет запретить другим потокам доступ к объектам, с которыми в данный момент работает текущий поток.
Поскольку асинхронный метод обычно освобождает поток, в котором начал асинхронную операцию, и через неопределенно долгое время может быть возобновлен в другом потоке, то удерживать блокировку во время выполнения await не имеет смысла.

46
Глава 5. Что в действительности делает await
В некоторых случаях важно защитить объект от одновременного доступа, но разрешить другим потокам обращаться к нему во время await
. Тогда можно написать чуть более длинный код, в котором синх ронизация явно производится дважды:
lock (sync)
{
// Подготовиться к асинхронной операции
}
int myNum = await AlexsMethodAsync();
lock (sync)
{
// Использовать результат асинхронной операции
}
Можно вместо этого воспользоваться библиотекой, реализующей управление одновременным доступом, например NAct (см. главу 10).
Если вы считаете, что программе все же необходимо удерживать ту или иную блокировку на протяжении асинхронной операции, попы- тайтесь переосмыслить ее дизайн, потому что в общем случае чрезвы- чайно трудно реализовать блокировку ресурсов на время асинхронно- го вызова, избежав при этом состояний гонки и взаимоблокировок.
Выражения LINQ-запросов
В C# имеется синтаксис для упрощения записи декларативных за- просов на фильтрацию, трансформацию, упорядочение и группиров- ку данных. Впоследствии запрос может быть применен к коллекции
.NET или преобразован в форму, пригодную для применения к базе данных или другому источнику данных.
IEnumerable transformed = from x in alexsInts where x != 9
select x + 2;
В большинстве мест внутри выражения запроса употребление await недопустимо. Объясняется это тем, что эти места компилятор преобразует в лямбда-выражения, и, значит, такое лямбда-выражение следовало бы пометить ключевым словом async
. Однако синтаксиса, позволяющего пометить эти неявные лямбда-выражения как async
, просто не существует, и попытка ввести его только привела бы к недоразумениям.


47
Запоминание исключений
При необходимости всегда можно написать эквивалентное вы- ражение с помощью методов расширения, как это делает сам LINQ.
Тогда лямбда-выражения станут явными и их можно будет пометить как async
, чтобы использовать внутри await
IEnumerable> tasks = alexsInts
.Where(x => x != 9)
.Select(async x => await DoSomthingAsync(x) +
await DoSomthingElseAsync(x));
IEnumerable transformed = await Task.WhenAll(tasks);
Для сбора результатов я воспользовался методом
Task.WhenAll
, предназначенным для работы с коллекциями объектов
Task
. Подроб- но мы рассмотрим его в главе 7.
Небезопасный код
Код, помеченный ключевым словом unsafe
, не может содержать await
. Необходимость в небезопасном коде возникает очень редко, и обычно он содержит автономные методы, не требующие асинхрон- ности. Да и в всё равно преобразования, выполняемые компилятором при обработке await
, в большинстве случаев сделали бы небезопас- ный код неработоспособным.
Запоминание исключений
По идее, исключения в асинхронных методах должны работать прак- тически так же, как в синхронных. Но из-за дополнительных сложнос- тей механизма async существуют некоторые тонкие различия. В этом разделе я расскажу о том, как async упрощает обработку исключений, а некоторые подводные камни опишу более подробно в главе 9.
По завершении операции в объекте
Task сохраняется информация о том, завершилась ли она успешно или с ошибкой. Получить к ней доступ проще всего с помощью свойства
IsFaulted
, которое равно true
, если во время выполнения операции произошло исключение.
Оператор await знает об этом и повторно возбуждает исключение, хранящееся в
Task
У читателя, знакомого с системой исключений в .NET, может воз- никнуть вопрос, корректно ли сохраняется первоначальная трассировка стека исключения при его повторном возбуждении.
Раньше это было невозможно; каждое исключение могло быть

48
Глава 5. Что в действительности делает await возбуждено только один раз. Однако в .NET 4.5 это ограничение снято благо- даря новому классу
ExceptionDispatchInfo, который взаимодействует с классом
Exception с целью запоминания трассировки стека и воспроизве- дения ее при повторном возбуждении.
Async-методы также знают об исключениях. Любое исключение, возбужденное, но не перехваченное в async-методе, помещается в объ- ект
Task
, возвращаемый вызывающей программе. Если в этот момент вызывающая программа уже ждет объекта
Task
, то исключение будет возбуждено в точке ожидания. Таким образом, исключение передает- ся вызывающей программе вместе со сформированной виртуальной трассировкой стека – точно так же, как в синхронном коде.
Я называю это виртуальной трассировкой стека, потому что стек – вообще-то принадлежность потока, а в асинхронной про- грамме реальный стек текущего потока может не иметь ничего общего с трассировкой стека в момент исключения. В исключе- нии запоминается трассировка стека, отражающая намерение программиста, в ней представлены те методы, который про- граммист вызывал сам, а не детали того, как C# исполнял части этих методов в действительности.
Асинхронные методы до поры
исполняются синхронно
Выше я уже отмечал, что async-метод становится асинхронным, толь- ко встретив вызов асинхронного метода внутри оператора await
. До этого момента он работает в том потоке, в котором вызван, как обыч- ный синхронный метод. Иногда это приводит к вполне ощутимым последствиям, особенно если есть шанс, что вся цепочка async-мето- дов исполняется в синхронном режиме.
Напомню, что async-метод приостанавливается, лишь дойдя до первого await
. Но даже в этом случае бывает, что приостановка не нужна, потому что иногда задача
Task
, переданная оператору await
, уже завершена. Так может случиться в следующих ситуациях.
• Она была завершена уже в момент создания методом
Task.
FromResult
, который мы рассмотрим подробнее в главе 7.
• Она была возвращена async-методом, который так ни разу и не дошел до await
• Действительно была выполнена асинхронная операция, но она уже завершилась (быть может потому, что до момента вызова await текущий поток делал что-то еще).


49
• Она была возвращена async-методом, который дошел до await
, но задача
Task
, завершения которой тот ждал, уже за- вершилась.
Именно последняя возможность – когда глубоко внутри цепочки async-методов произошло ожидание уже завершившейся задачи – дает интересный эффект. Выполнение всей цепочки вполне может оказаться синхронным. Объясняется это тем, что в цепочке async- методов первым всегда вызывается await с самым глубоким уров- нем вложенности. До остальных дело доходит только после того, как у самого глубоко вложенного метода был шанс завершиться синх- ронно.
Возможно, вам непонятно, зачем вообще использовать async в пер- вом или втором из вышеупомянутых случаев. Если бы можно было гарантировать, что метод всегда исполняется синхронно, то да, было бы эффективнее сразу написать синхронный код, чем async-метод без await
. Но дело в том, что бывают ситуации, когда метод возвращает управление синхронно лишь в некоторых случаях. Например, если метод кэширует результаты в памяти, то он может завершиться син- хронно, когда результат уже находится в кэше, а в противном случае будет асинхронно обратиться к сети. Кроме того, иногда имеет смысл возвращать из метода
Task или
Task
с прицелом на будущее, если известно, что впоследствии он будет переписан и сделан асинхрон- ным.
Асинхронные методы до поры исполняются...

ГЛАВА 6.
Паттерн TAP
Паттерн Task-based Asynchronous Pattern (TAP) – это предлагаемый
Microsoft набор рекомендаций по написанию асинхронных API в
.NET с помощью класса
Task
. В документе
1
, написанном Стивеном
Тоубом (Stephen Toub) из группы параллельного программирования в Microsoft приводятся содержательные примеры; с ним, безусловно, стоит ознакомиться.
Следование этому паттерну позволяет строить API, допускающие использование внутри await
, и, хотя добавление ключевого слова async порождает методы, согласованные с TAP, иногда полезно рабо- тать непосредственно с классом
Task
. В этой главе я объясню, в чем смысл этого паттерна, и продемонстрирую технику работы с ним.
Что специфицировано в TAP?
Я буду предполагать, что вам уже известно, как проектировать хоро- шую сигнатуру синхронного метода в C#:
• метод должен иметь нуль или более параметров, причем пара- метров со спецификаторами ref и out по возможности следу- ет избегать;
• в тех случаях, когда это необходимо, для метода должен быть задан тип возвращаемого значения, которое действительно со- держит результат работы метода, а не просто является индика- тором успеха, как иногда бывает в коде на C++;
• у метода должно быть имя, объясняющее его назначение и не содержащее какой-либо дополнительной нотации;
• типичные или ожидаемые ошибки должны отражаться в типе возвращаемого значения, тогда как неожиданные ошибки должны приводить к возбуждению исключения.
1 http://www.microsoft.com/en-gb/download/details.aspx?id=19957