Следует ли делать асинхронную обёртку для синхронного метода?

перевод

В последнее время меня несколько раз спрашивали похожие вопросы, которые можно объединить как «асинхронная обёртка поверх синхронного кода»:

В моей библиотеке есть метод public T Foo(). Я рассматриваю возможность предоставить асинхронный метод, который будет просто оборачивать синхронный, то есть public Task<T> FooAsync() { return Task.Run(() => Foo()); }. Вы бы порекомендовали такой подход?

Если отвечать коротко — нет. Но тогда не получилось бы хорошей заметки в блоге. Так что ниже мой более развёрнутый ответ…

Зачем асинхронный?

Я вижу два основных преимущества у асинхронного кода: масштабируемость — scalability и разгрузка — offloading (то есть отзывчивость, параллелизм). Тип приложения, которое вы пишете, обычно диктует, какая из этих причин важнее для вас. Большинство клиентских приложений прибегают к асинхронному коду для разгрузки, например для поддержки отзывчивости потока пользовательского интерфейса. Тем не менее, существуют сценарии, когда масштабируемость важна и для клиентских приложений. Для большинства серверных приложений асинхронный код важен по причине масштабируемости, но встречаются сценарии, когда разгрузка важна и тут.

Масштабируемость

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

В качестве примера посмотрите на синхронный метод Sleep, который ничего не делает в течение N миллисекунд:

public void Sleep(int millisecondsTimeout)
{
    Thread.Sleep(millisecondsTimeout);
}

Теперь, нам необходимо создать асинхронную версию этого, то есть такую, чтобы возвращенный Task не завершался в течение N миллисекунд. Вот одна из возможных реализаций, мы просто обернём Task.Run вокруг Sleep и создадим SleepAsync:

public Task SleepAsync(int millisecondsTimeout)
{
    return Task.Run(() => Sleep(millisecondsTimeout));
}

а вот другой, который не использует Sleep, и в итоге расходует меньше ресурсов:

public Task SleepAsync(int millisecondsTimeout)
{
    TaskCompletionSource<bool> tcs = null;
    var t = new Timer(delegate { tcs.TrySetResult(true); }, null, –1, -1);
    tcs = new TaskCompletionSource<bool>(t);
    t.Change(millisecondsTimeout, -1);
    return tcs.Task;
}

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

Разгрузка

Возможность вызвать синхронный код асинхронно может быть очень полезна для отзывчивости, так как вы переносите выполнение длительных операций на другие потоки. Тут речь идёт не о том, как много ресурсов вы потребляете, скорее о том, что это за ресурсы. Например, в UI приложениях специальный поток занимается обработкой сообщений пользовательского интерфейса и поэтому он более ценен для пользовательского опыта, чем другие потоки, например из пула. Следовательно, асинхронно вызывая метод из потока интерфейса в потоке из пула позволяет нам тратить меньше ценного ресурса. Такой тип вызовов не требует модификации реализации той операции, так как можно достаточно эффективно использовать обёртку.

Возможность вызвать синхронный метод асинхронно может быть очень полезна не только для смены потока, но и в более общем смысле для выхода из текущего контекста. Например, иногда нам необходимо вызвать некоторый код, предоставленный пользователем, но мы не в подходящем для этого месте (или не уверены в этом). Может быть выше по стеку была захвачена блокировка, и мы не хотим держать её всё время выполнения пользовательского метода. Может быть, мы подозреваем, что наш код был вызван каким-то пользовательским кодом, который не ожидает, что мы потратим много времени. Вместо того, чтобы вызывать операцию синхронно, а значит — как часть вышележащего стека вызовов — можно вызвать функциональность асинхронно.

Возможность вызвать синхронный метод асинхронно также очень важна для параллелизма. Параллельное программирование целиком о том, как взять одну задачу и разбить её на несколько под-задач, которые можно обрабатывать конкурентно. Если бы вы разбили задачу на под-задачи, а затем обрабатывали бы их одну за одной последовательно, не получилось бы никакого параллелизма, так как вся задача выполнялась бы в единственном потоке. Если же вы, наоборот, разнесли выполнение под-задач по разным асинхронным вызовам, вы бы исполняли под-задачи конкурентно. Как и в случае с отзывчивостью, такой тип разгрузки не требует модификации реализации операции, так как можно использовать обёртку.

При чём здесь мой вопрос?

Давайте вернёмся к исходному вопросу: следует ли нам предоставлять асинхронную точку вызова для метода, который в действительности синхронный? Позиция, которую мы заняли в Task-based Async Pattern, — твёрдое «нет».

Заметьте, что в моих предыдущих рассуждениях о масштабируемости и разгрузке, я сказал, что путь увеличения масштабируемости — это изменение реализации, тогда как разгрузку можно выполнить с помощью обёртки, не требующей изменения реализации. Это — ключ. Оборачивание синхронного метода в простой асинхронный фасад не улучшает масштабируемость, и в этом случае, предоставляя клиентам только синхронный метод вы получаете некоторые преимущества:

  • Внешняя поверхность вашей библиотеки уменьшается. Для вас это означает уменьшение стоимости (разработки, тестирования, поддержки, документирования и т.д.). Также это значит, что пользователь не стоит перед сложностями выбора. В то время как некоторый выбор обычно это хорошая вещь, слишком много выбора ведёт к потере продуктивности. Если бы я как пользователь постоянно сталкивался и с синхронным и с асинхронным методом для каждой операции, я постоянно вынужден был бы оценивать, какой из двух в каждом случае следует выбрать.
  • Ваши пользователи будут знать, где именно можно получить преимущества от масштабируемости, потому что тогда только масштабируемые API будут предоставлять асинхронные вызовы.
  • Выбор какой вызов сделать: синхронный или асинхронный остаётся за разработчиком. Асинхронная обёртка над синхронными методами имеет накладные расходы (создание объектов чтобы представлять операцию, переключения контекста, синхронизация очередей и т.д.). Если, к примеру, ваш клиент создаёт высоко-нагруженное серверное приложение, ему не нужны накладные расходы, которые не приносят выгоды, так что они будут просто использовать синхронный метод. Если же и синхронный и асинхронный метод будут представлены в API, разработчик задумается, стоит ли ему использовать асинхронную версию из соображений масштабируемости, но в действительности, будет расплачиваться пропускной способностью системы, тратя ресурсы на накладные расходы и не получая преимуществ масштабируемости.

Если разработчику нужно достичь лучшей масштабируемости, он может использовать любой API, который предоставляет асинхронные вызовы, не тратясь на вызовы поддельных асинхронных API. Если разработчику нужна отзывчивость или параллелизм с синхронным API, он может просто обернуть вызов к примеру в Task.Run.

Идея предоставлять асинхронный API для синхронного — это очень скользкая дорожка, в пределе, её применение может вылиться в то, что у каждого метода будет и синхронная и асинхронная форма. Много народу, кто спрашивает меня об этой практике, имеют в виду предоставление асинхронной обёртки для длительных CPU-операций. Желание похвально: помочь с отзывчивостью. Но как уже говорилось, отзывчивость может быть легко обеспечена клиентом API, причём клиент сможет сделать это на нужном уровне разбиения, а не для каждой операции. Более того, определение того, какая операция может быть действительно длительной — на удивление сложная задача. Временные сложности множества методов часто значительно отличаются.

Представьте, к примеру, простой метод вроде Dictionary<TKey, TValue>.Add(TKey, TValue). Это ведь в действительности быстрый метод, верно? Обычно, да, но вспомните, как работает словарь: ему надо вычислить хэш от ключа, чтобы найти позицию, а также проверить на эквивалентность с другими сущностями, находящимися в этой же позиции. Вычисление хэша и проверка эквивалентности могут привести в вызову пользовательского кода и кто знает, какие там будут операции и сколько времени займёт их выполнение. Должен ли каждый метод словаря предоставлять асинхронную обёртку? Это очевидно преувеличенный пример, но есть и попроще, например регулярные выражения. Сложность шаблонов регулярных выражений а также размер и природа входной строки могут существенно повлиять на время выполнения сопоставления, настолько, что сейчас Regex поддерживает опциональный таймаут… должен ли каждый метод класса Regex иметь асинхронный эквивалент? Я надеюсь нет.

Руководство

Всё это был витиеватый способ сказать, что я верю, что в API должны быть только те асинхронные методы, которые дают преимущества при масштабировании (против их синхронных аналогов). Не следует добавлять асинхронные методы в API только из соображений разгрузки: эти преимущества легко достигаются клиентом синхронных методов с помощью функций специально предназначенных для того, чтобы исполнять синхронный код асинхронно, то есть Task.Run.

Конечно, есть исключения из этого, и вы можете обнаружить несколько даже в .NET Framework 4.5.

Например, абстрактный базовый класс Stream предоставляет методы ReadAsync и WriteAsync. В большинстве случаев, производные классы работают с источниками данных не в памяти, а значит включают дисковые или сетевые операции ввода-вывода. Поэтому очень вероятно, что производный класс сможет предоставить реализацию ReadAsync и WriteAsync, которые будут использовать асинхронный ввод-вывод вместо того, чтобы блокировать поток и пользоваться синхронным. Кроме того, нам важна возможность пользоваться этими методами полиморфно, не смотря на реальный тип потока, поэтому мы хотим видеть эти методы в базовом классе. Кроме того базовый класс не знает того, как реализовать базовую реализацию асинхронного ввода-вывода, так что лучшее, что он может сделать, это предоставить асинхронные обёртки для синхронных методов Read и Write (в действительности, ReadAsync и WriteAsync оборачивают вызовы BeginRead/EndRead и BeginWrite/EndWrite соответственно, которые не являются переопределяемыми и которые, в свою очередь оборачивают синхронные Read и Write в эквивалент Task.Run).

Другой пример в том же духе — это TextReader, предоставляющий метод вроде ReadToEndAsync, который в базовом классе просто вызывает Task, чтобы обернуть вызов TextReader.ReadToEnd. Ожидается, тем не менее, что разработчик производного класса переопределит ReadToEndAsync чтобы предоставить более масштабируемую реализацию, например, как у StreamReader-а, который использует Stream.ReadAsync.

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

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.