Асинхронное программирование в C# используя async/await ч.2

В предыдущем топике было рассказано об использовании ключевых слов async/await и о том, как они упрощают асинхронное программирование. Сейчас мы обсудим дополнительные особенности и подводные камни, при использовании async/await.

Aync Methods Return Types

Метод отмеченный как async должен быть типа Task/Task<T> или void. К типу void вернемся позже.

При компиляции async-метода компилятор автоматически создает Task/Task<T> объект, который представляет асинхронную операцию, выполняемую методом. Этот объект позволяет вызывающей стороне зарегистрировать метод, вызываемый при завершении асинхронной операции.

Если ваш метод должен возвращать какое-либо значение, используйте Task<T>, иначе юзайте Task. Это своего рода void таска.

Рассмотрим следующий пример:
image

Эти метода обеспечивают механизм асинхронных отправки и получения строк с удаленного сервиса.

Метод Send ничего не должен возвращать, потому он типа Task.
Обратите внимание: метод имеет определенный тип Task, но в теле нет ни одного return. (выглядит диковато). Об этом заботится компилятор, автоматически возвращая созданный объект Task. Более того - после await можно написать пустой return, благодаря async метод считается void-методом.

Метод Receive должен вернуть строку, полученную от удаленного сервиса, потому он типа Task<string>. Обратите внимание: метод возвращает тип string. Компилятор автоматически разместит результат в созданном объекте Task<string>.

Ок, сейчас мы понимаем методы типов Task и Task<T>, теперь обсудим void

 

Void значит Избегать (Void Means Avoid)

Ну... если сможете...

Давайте рассмотрим почему мы должны воздерживаться от использования void-методов в качестве асинхронных, а затем рассмотрим случаи, когда у нас нет выбора.

Как написано выше - когда метод компилируется, компилятор создает Task-объект. Однако, если возвращаемый тип void, компилятор не может вернуть Task вызывающему методу, который, следовательно, не сможет использовать await. Мы часто указываем await прямо перед вызовом метода, так что легко забыть тот факт, что await не работает с методом, но возвращает Task, котрый вернулся из метода.
Рассмотрим следующий код:

image

Мы видим два эквивалентных пути вызова асинхронной операции. Первый путь - вызов метода с предшествующим await. Второй - сохраняет Task в переменную и после этого применяется к ней await. Как мы можем увидеть - await требует Task, это одна из причин, почему мы не можем применять await к acync-методу типа void.

В моем предыдущем топике я показал, как легко вызывающая сторона может отлавливать исключения, проброшенные в acync методах. Это возможно, благодаря Task-объекту, передающему исключения вызывающему методу. Потому, когда вызывается async-метод типа void, вызывающий метод не может использовать простой try/catch для удобной обработки исключений во время асинхронного выполнения. В таких случаях ошибки могут быть не замечены (заметьте: есть возможность подписаться на подобные исключения через TaskScheduler.UnobservedTaskException)

Что же, теперь ясно, почему async и void не хорошая комбинация, но вопрос - почему они разрешены? Ответ: потому что иногда нет другого выбора. К примеру, если мы хотим использовать асинхронность, но метод навязан нам интерфейсом, базовым классом или делегатом.

В UI-приложениях много действий происходит из-за действий пользователя, что значит, что мы часто пишем код реагирующий на события. Рассмотрим пример кода из предыдущего топика:

image

В этом примере, когда пользователь кликает по кнопке мы асинхронно загружаем изображение и показываем его. Как вы можете видеть, метод типа void. Если мы изменим его, код не скомпилируется, потому что сигнатура обработчика требует от нас другой тип. Однако, в этом случае это не так плохо, потому что даже если мы бы вернули Task, вызывающий метод не применил бы к нему await.

Давайте рассмотрим другой случай, где мы не имеем управления над возвращаемым типом

image

В коде выше мы имеем интерфейс описывающий функциональность логгера. Представьте, что мы реализуем логгер, замедляющий наше приложение и мы бы хотели исправить это за счет асинхронности. Часто мы не можем изменить определение интерфейса, потому приходится оставлять метод типа void. 

В таких случаях я предлагаю вам рассматривать ограничения и решить, являются ли они приемлемыми. В примере с логером вызывающий метод не должен ждать завершения метода логгера и ничего не может сделать, в случае неуспеха ). Так что в этом случае мы не можем воздержаться от async void метода, т.к. ограничения ясны и они приемлемы.

Просто запомните:

async + void = avoid (когда возможно).

 

Threading Models

Различные типы приложений имеют разные потоковые модели. Пользовательский интерфейс, основанный на WPF, Windows Forms, Silverlight требуют только один поток для манипуляций с UI. Любая попытка доступа к UI из параллельных потоков приведет к исключению.

Когда мы используем await с асинхронным методом задача обычно выполняется в отдельном потоке. При завершении UI-метод восстанавливается. Но какой поток возобновляет выполнение? Поток, который первоначально вызывал асинхронный метод, или поток, исполнявший асинхронную часть?

В UI-приложениях гарантируется, что когда UI-поток ждет задачу, он также будет выполнять восстановление метода. Благодаря этому мы можем безопасно манипулировать UI-ем, без риска исключений.

Но и тут есть проблема - если быть неосторожным, то это может привести к блокировкам. Иллюстрация проблемы:
image

В примере мы разделили скачивание изображения на два метода. Метод GetImage делает основную работу - он скачивает данные асинхронно, создает изображение и возвращает его. Пока все хорошо. Но в методе button_Click допущена ошибка. Вместо ожидания завершения задачи от GetImage он пытается получить доступ к изображению, используя свойство Result. Доступ к свойству Result является причиной блокировки потока до завершения задачи (т.е. до тех пор, пока свойство Result  не станет доступно). Однако, в GetImage мы ожидаем завершения скачивания, и код следующий за await по завершении загрузки должен выполняться в UI-потоке!! Вот вам и мертвая блокировка (deadlock). Метод GetImage не может завершиться до тех пор пока UI-поток не освободится, а UI-поток не освободится, пока не завершится GetImage, чтобы вернуть таки результат. Oh my...

В этом случае мы легко можем пофиксить проблему заюзав await. В качестве общей рекомендации - вы должны знать, что блокировка потока до завершения Task может быть плохой идеей, т.к. таска может нуждаться в UI-потоке для завершения. при работе с Task-объектом следует помнить, что некоторые свойства, такие как Result, могут блокировать использующий метод до завершения задачи.

Давайте рассмотрим другой сценарий, гарантирующий восстановление выполнения в UI-потоке, но нежелательный. Представьте, что вы пишите библиотеку, которая может быть использована UI-приложением. Обычно ваша библиотека не собирается манипулировать UI напрямую, так что не нуждается в переключении UI-потока для каждого await используемого внутри. Переключение в UI-поток занимает больше времени, чем только продолжение выполнения в текущем потоке, так что избегание стандартного поведения может быть значительной оптимизацией. Сделать это можно при помощи вызова метода ConfigureAwait класса Task, с аргументом false. Рассмотрим следующий код:
image

 

Параллелизм

Давайте еще раз посмотрим пример кода для скачивания изображения и отображения его в UI. Но теперь поднимем планку и скачаем/отобразим сразу три изображения. Мы напишем что-то вроде этого:

image 

Работа сделана, но загрузка изображений пойдет поочередно. Если на каждое изображение, допустим, уходит 5 секунд, то все изображения загрузятся за 15. Мы можем это улучшить так:

image

В этом примере мы сначала запускаем все три загрузки путем вызова GetImage без ожидания.Если в пуле достаточно потоков, то все три изображения будут загружаться параллельно. Так что, если загрузка каждого изображения занимает 5 секунд, то все три изображения загрузятся за 5 секунд. Это гораздо лучше, чем было.

Вышеприведенный код использует await три раза, так что выполнение метода будет восстановлено три раза, хотя в реальности нам нужно это сделать лишь раз – когда все задачи выполнятся. К счастью, класс Task имеет статический метод WhenAll. Это метод принимает множество Task-объектов,  возвращает один Task-объект, который завершается при завершении всех переданный Task-объектов.
Рассмотрим следующий код:

image

Здесь также выполняется вызов трех GetImage, но await используется лишь однажды, и для получения  изображения используется свойство Result. Заметьте, что в данном случае свойство Result можно использовать без опасений блокировок (deadlock), потому что бы уверены в завершении всех операций.

Это выгладит незначительной оптимизацией (и в этом случае, возможно так и есть), но представьте, что мы параллельно загружаем произвольное количество изображений. Как мы можем это сделать:

image

И было бы упущением, если бы я не упомянул метод Task.WhenAny. Этот статический метод принимает несколько задач и возвращает одиночный Task, который завершается, когда любая из переданных задач завершается.

Оригинал

Комментариев нет:

Отправить комментарий

Можете оставить свой комментарий