[Перевод] Жизнь – боль: как одновременно поддерживать в Rust синхронный и асинхронный код
Введение
Присаживайтесь поудобнее и послушайте стариковскую байку: что случилось, когда я попросил у Rust слишком многого.
Допустим, вы хотите написать на Rust новую библиотеку. Всё, что для этого требуется — обернуть её в публичный API, через который будет предоставляться доступ к какому-то другому продукту, например, в Spotify API или, может быть, в API базы данных, скажем, ArangoDB. Не так это и тяжело: в конце концов, вы не изобретаете ничего нового, вам не приходится иметь дело со сложными алгоритмами. Поэтому вы полагаете, что задача решается относительно прямолинейно.
Вы решаете реализовать библиотеку с применением async. Работа, которая будет выполняться с помощью вашей библиотеки, заключается в основном в выполнении HTTP-запросов, обслуживающих ввод/вывод, поэтому применять здесь async действительно целесообразно (кстати, это одна из тех фишек, благодаря которым сегодня так востребован Rust). Вы садитесь писать код — и вот, через несколько дней у вас готова версия v0.1.0. «Приятно», — думаете вы, как только cargo publish заканчивается успешно и загружает вашу работу на crates.io.
Проходит несколько дней, и вам прилетает новое уведомление с GitHub. Оказывается, кто-то открыл тему:
А как использовать эту библиотеку синхронно?
В моём проекте функция async не используется, поскольку она слишком сложна для того, что мне требуется. Я хотел попробовать вашу новую библиотеку, но не вполне понимаю, как при этом не усложнять. Пожалуй, не решусь повсюду заполнять мой код block_on(endpoint())
. Я видел крейты вроде reqwest, экспортирующие блокирующий модуль ровно с тем же функционалом. Возможно, и вам так стоит поступить?
В низкоуровневом контексте такая задача кажется очень сложной. Можно ли предусмотреть общий интерфейс как для обычного синхронного кода, так и для асинхронного — которому требуется среда исполнения вроде tokio, ожидающие футуры, закрепление, т.д.? Я имею в виду, меня вежливо попросили, так что я решил попробовать. В конце концов, вся разница будет в том, что в коде кое-где будут попадаться ключевые слова async и await, никаких изысков тут не делается.
Что ж, более или менее именно это происходило с крейтом rspotify, который я когда-то поддерживал вместе с Ramsay, его создателем. Если кто не знает — это обёртка для Spotify Web API. Для понимания: в конце концов я добился, чтобы этот код работал, хотя, он получился и не столь чистым, как я надеялся.
Первые подходы
Чтобы дать более широкий контекст, покажу, как в общих чертах выглядит клиент Rspotify:
struct Spotify { /* ... */ }
impl Spotify {
async fn some_endpoint(&self, param: String) -> SpotifyResult {
let mut params = HashMap::new();
params.insert("param", param);
self.http.get("/some-endpoint", params).await
}
В сущности, нам требовалось бы предоставить доступ к некой конечной точке some_endpoint
для пользователей, работающих как в асинхронном, так и в блокирующем режиме. В данном случае важно ответить на вопрос: как действовать, если у вас несколько десятков конечных точек? И как максимально облегчить для пользователя переключение между синхронным и асинхронным кодом?
Старая добрая копипаста
Первым делом был реализован следующий вариант. Он был довольно прост и работал. Требуется скопировать обычный клиентский код в новый модуль blocking в Rspotify. Здесь reqwest (наш HTTP-клиент) и reqwest: blocking совместно используют один и тот же интерфейс, так что мы можем вручную удалять ключевые слова, например, async или .await и импортировать в новом модуле reqwest: blocking вместо reqwest.
Затем пользователь Rspotify может просто взять rspotify::blocking::Client
вместо rspotify::Client
— и вуаля! Код стал блокирующим. В результате клиенты, работающие исключительно с async, получат сильно увеличенный двоичный файл, поэтому мы можем просто поставить здесь переключатель фич, выдавать удобную версию этого файла под названием blocking — и дело сделано.
Позже проблема значительно прояснилась. Оказалось, что половина кода в крейте дублируется. При необходимости добавить новую конечную точку или модифицировать имеющуюся, то всё потребовалось бы писать или удалять дважды.
В эквивалентности двух реализаций невозможно убедиться, если досконально не протестировать обе. Вообще, это неплохая идея, но вдруг вы просто неправильно скопировали и вставили все тесты! Вы об этом не думали? Несчастному рецензенту придётся дважды построчно прочитать код, чтобы убедиться, что он с обеих сторон выглядит нормально — и тут возникает огромное поле для человеческих ошибок.
По нашему опыту, приобретённому при разработке Rspotify, процесс при этом действительно сильно замедляется, и в особенности для новичков, не привыкших к таким мытарствам. На правах новоиспечённого специалиста по поддержке Rspotify, я с энтузиазмом принялся исследовать, какие ещё решения возможны.
Вызов block_on
Второй подход — в том, чтобы всё реализовать на стороне асинхронного кода. После этого нужно сделать обёртки для блокирующего интерфейса, внутрисистемно вызывающие block_on. block_on будет выполнять футуру до завершения, в результате она фактически станет синхронной. Вам всё равно потребуется скопировать определения методов, но реализация в таком случае пишется всего один раз:
mod blocking {
struct Spotify(super::Spotify);
impl Spotify {
fn endpoint(&self, param: String) -> SpotifyResult {
runtime.block_on(async move {
self.0.endpoint(param).await
})
}
}
Обратите внимание: чтобы можно было вызвать block_on
, сначала нужно создать в методе конечной точки некоторую среду выполнения. Например, это можно сделать при помощи tokio :
let mut runtime = tokio::runtime::Builder::new()
.basic_scheduler()
.enable_all()
.build()
.unwrap();
Возникает вопрос: следует ли инициализировать среду исполнения при каждом вызове к конечной точке, либо здесь можно организовать совместное использование? Можно сделать её глобальной (ewwww) или, что даже лучше, сохранить среду исполнения в структуре Spotify. Но, поскольку в таком случае в среду исполнения привносится изменяемая ссылка, её приходится обернуть в Arc
, тем самым полностью погубив конкурентность у вас на клиенте. Это следует делать при помощи Handle из Tokio, который выглядит так:
use tokio::runtime::Runtime;
lazy_static! { // Также можно воспользоваться `once_cell`
static ref RT: Runtime = Runtime::new().unwrap();
}
fn endpoint(&self, param: String) -> SpotifyResult {
RT.handle().block_on(async move {
self.0.endpoint(param).await
})
Притом, что с этим дескриптором наш блокирующий клиент начинает работать быстрее [1], здесь возможно и более производительное решение. Для него нужен reqwest, если вам интересно. Если коротко, он порождает поток, который, в свою очередь, вызывает block_on, ожидающий у канала с заданиями [2] [3].
К сожалению, такое решение сопряжено со значительными издержками. Вы подтягиваете большие зависимости, такие futures или tokio, и включаете их в ваш двоичный файл. Всё это ради того, чтобы… всё равно в итоге писать блокирующий код. За это приходится расплачиваться не только во время исполнения, но и во время компиляции. Мне кажется, это просто неправильно.
При этом у вас в проекте по-прежнему дублируется значительная часть кода. Пусть это и всего лишь определения, но они имеют свойство накапливаться. reqwest — огромный проект, и там, пожалуй, можно себе такое позволить в случае с модулем blocking. Но в менее популярном крейте, таком как rspotify, это вытянуть сложнее.
Дублирование крейта
Судя по документации, ещё один способ справиться с этой проблемой — создавать отдельные крейты. У нас есть rspotify-sync и rspotify-async, а пользователи сами выбирали бы, какой из них они хотели бы подключить в качестве зависимости — даже два, если потребуется. Но сохраняется всё та же проблема: как именно нам сгенерировать обе версии крейта? Мне этого не удалось иначе, кроме как скопипастить весь крейт целиком, даже ценой различных ухищрений с Cargo: например, создать два файла Cargo.toml, по одному на каждый крейт (в любом случае, это весьма неудобно).
Вооружившись такой идеей, не получится воспользоваться даже процедурными макросами, потому что нельзя просто так взять и создать новый крейт внутри макроса. Можно было бы определить такой формат файла, который позволял бы писать шаблоны с кодом на Rust, которыми можно было бы заменять такие элементы как async/.await. Но, кажется, эта тема полностью выходит за рамки статьи.
Вариант, который всё-таки сработал: крейт maybe_async
Наша третья попытка базировалась на крейте под названием maybe_async. Помню, как только обнаружил это решение — по глупости счёл его идеальным.
В общем, идея этого крейта в том, чтобы автоматически удалять из кода все включения async
и .await
при помощи процедурного макроса. Так мы, фактически, автоматизируем копипаст. Например:
#[maybe_async::maybe_async]
async fn endpoint() { /* материал */ }
Генерирует следующий код:
#[cfg(not(feature = "is_sync"))]
async fn endpoint() { /* материал */ }
#[cfg(feature = "is_sync")]
fn endpoint() { /* удалили материал с `.await` */ }
Можно заранее сконфигурировать, какой код вам нужен — асинхронный или блокирующий. Это делается простым переключением фичи maybe_async/is_sync при компилировании крейта. Данный макрос работает с функциями, типажами и блоками impl. Если какое-то преобразование не сводится просто к удалению async и .await, можно задать собственную реализацию при помощи процедурных макросов async_impl и sync_impl. Эта операция проходит отлично, и мы уже применяем её в Rspotify некоторое время.
На самом деле, она оказалась настолько годной, что я организовал работу Rspotify без учёта http-клиента. Такой подход даже более гибок, чем работа без учёта async/sync. Так у нас получается поддерживать сразу множество HTTP-клиентов, например, reqwest и ureq, независимо от того, синхронным или асинхронным является конкретный клиент.
Работу без учёта http-клиента не так сложно реализовать, если у вас под рукой есть maybe_async. Требуется просто определить типаж для HTTP-клиента, а затем реализовать его для каждого из клиентов, которые вы хотите поддерживать:
Небольшой листинг стоит тысячи слов. (мы выложили на Github полный исходный код для двух клиентов Rspotify: здесь для reqwest, а здесь для ureq)
#[maybe_async]
trait HttpClient {
async fn get(&self) -> String;
}
#[sync_impl]
impl HttpClient for UreqClient {
fn get(&self) -> String { ureq::get(/* ... */) }
}
#[async_impl]
impl HttpClient for ReqwestClient {
async fn get(&self) -> String { reqwest::get(/* ... */).await }
}
struct SpotifyClient {
http: Http
}
#[maybe_async]
impl SpotifyClient {
async fn endpoint(&self) { self.http.get(/* ... */) }
}
Далее этот код можно расширить таким образом, чтобы для любого используемого вами клиента в файле Cargo.toml можно было бы активировать переключение фич при помощи флагов. Например, если включён client-ureq
, то будет действовать maybe_async/is_sync
, поскольку ureq
— синхронный. В то же время, здесь удалялись бы блоки async/.await
и #[async_impl]
, а клиент Rspotify внутрисистемно полагался бы на реализацию для ureq.
Такое решение лишено каких-либо недостатков, которые я указывал выше при описании предыдущих попыток:
Код вообще не дублируется
Никаких издержек, ни во время исполнения, ни во время компиляции. Если пользователю нужен блокирующий клиент, то можно пользоваться ureq, для работы с которым не требуется подтягивать tokio сотоварищи
Для пользователя всё также вполне понятно: нужно просто сконфигурировать флаг в файле Cargo.toml
Но здесь оторвитесь от чтения на пару минут и попытайтесь придумать, а почему бы не остановиться на этом варианте. На самом деле, могу дать вам 9 месяцев — именно столько мне потребовалось для ответа на этот вопрос…
Проблема
Суть в том, что все фичи в Rust обязательно должны быть аддитивными: «если мы включаем фичу, то из-за этого не должна выключаться никакая другая функциональность. Как правило, в программе предусматривается возможность активировать любую комбинацию фич — и это должно быть безопасно. В Cargo должна быть возможность объединять возможности из некоторого крейта, чтобы не приходилось раз за разом компилировать один и тот же крейт. Если вы хотите детальнее разобраться в этой проблеме, вот статья, в которой она объяснена весьма хорошо.
Из-за такой оптимизации взаимоисключающие фичи могут сломать дерево зависимостей. В нашем случае maybe_async/is_sync
— это переключаемая фича, активируемая через client-ureq
. Поэтому, если попытаетесь скомпилировать её при включённой client-reqwest
, программа откажет, так как maybe_async
конфигурируется с расчётом на генерацию сигнатур синхронных функций. Невозможно создать такой крейт, который бы прямо или косвенно зависел одновременно от синхронной и асинхронной версии Rspotify. Вообще, если верить справке по Cargo, вся концепция maybe_async сейчас является неверной.
Определитель доступных фич v2
Распространено заблуждение, будто эта проблема снимается благодаря «определителю доступных фич v2» (feature resolver), что также очень хорошо объяснено в справочной статье. Начиная с версии 2021 года эта функция включена по умолчанию, но в более ранних версиях вы могли самостоятельно задать её в файле Cargo.toml. Среди прочего, в этой новой версии удаётся обойтись без унификации фич в некоторых специальных случаях, но не в нашем:
Игнорируются фичи, активированные с применением платформо-специфичных зависимостей для тех целей, которые в данный момент ещё не собраны.
Сборочные зависимости и процедурные макросы не могут использовать какие-либо фичи совместно с нормальными зависимостями.
Зависимости, действующие на этапе разработки, не активируют никаких фич, если только они не являются необходимыми для собираемой в данный момент цели (как, скажем, тесты или примеры).
Кстати, я попытался самостоятельно воспроизвести этот случай — и всё сработало как было задумано. В этом репозитории приведён пример такого конфликта фич, из-за которого нарушается работа любого определителя.
Другие отказы
Нашлось ещё несколько крейтов, в которых также наблюдалась эта проблема:
arangors и aragog: обёртки для ArangoDB. Оба используют maybe_async для переключения между асинхронным и синхронным режимом [5] [6].
inkwell : обёртка для LLVM. Она поддерживает множество версий LLVM, не совместимых друг с другом [7].
k8s-openapi : обёртка для Kubernetes, с ней возникает та же проблема, что и с inkwell [8].
Исправляем maybe_async
Как только этот крейт начал набирать популярность, эта проблема была обозначена в maybe_async: там объясняется ситуация и демонстрируется, как её исправить:
async и sync в одной и той же программе fMeow/maybe-async-rs#6
Теперь у maybe_async
будут флаги для двух фич: is_sync
и is_async
. В обоих случаях крейт генерировал бы функции одинаково, но с суффиксом _sync
или _async
у идентификатора — так исключается конфликт. Например:
#[maybe_async::maybe_async]
async fn endpoint() { /* материал */ }
Генерирует следующий код:
#[cfg(feature = "is_async")]
async fn endpoint_async() { /* материал */ }
#[cfg(feature = "is_sync")]
fn endpoint_sync() { /* удалили материал с `.await` */ }
Правда, эти суффиксы вносят путаницу, поэтому я задумался, можно ли решить эту задачу эргономичнее. Я сделал форк maybe_async и попытался — о том, что получилось, можете подробнее почитать в этой ветке комментариев. Короче говоря, всё оказалось слишком сложно, и я в конце концов отступился.
Единственный путь к исправлению этого пограничного случая вынуждал нас ухудшить юзабилити Rspotify для всех. Но я считаю маловероятным, что кому-то придётся зависеть одновременно от синхронного и асинхронного кода — по крайней мере, до сих пор никто не жаловался. rspotify, в отличие от reqwest — это «высокоуровневая» библиотека, поэтому сложно вообразить, чтобы она вообще фигурировала в дереве зависимостей более одного раза.
Возможно, стоило бы обратиться за помощью к разработчикам Cargo?
Официальная поддержка
Мы в Rspotify далеко не первыми столкнулись с этой проблемой, поэтому, возможно, вам будет интересно почитать другие дискуссии на эту тему, случавшиеся ранее:
К настоящему времени уже закрытый RFC для компилятора Rust, в котором предполагалось добавить конфигурационный предикат oneof (вспомните #[cfg (any (…))] и подобные) для поддержки исключающих фич. Так только проще допустить конфликтующие фичи в случаях, когда выбора нет, но фичи всё равно должны оставаться строго аддитивными.
По поводу вышеупомянутого RFC завязалась некоторая дискуссия по поводу, не разрешить ли исключающие фичи в Cargo как таковом. Хотя, там и есть кое-что интересное на почитать, далеко эта дискуссия не ушла.
В этом обсуждении на сайте Cargo объясняется подобный случай с Windows API. В этой дискуссии содержится ещё много примеров и идей, но ничто из этого пока не пробилось в Cargo.
В ещё одном обсуждении на Cargo речь идёт о том, есть ли способ легко работать на этапе тестирования и сборки, комбинируя флаги. Если фичи строго аддитивны, то cargo test --all-features закроет всё. Но, если не закроет, то пользователю придётся выполнять команду, подбирая множество комбинаций флагов, что довольно обременительно. Неофициально это уже возможно, благодаря cargo-hack.
Совершенно иной подход на инициативе Keyword Generics. По-видимому, это самая свежая попытка решить данную проблему, но пока он на этапе «исследования» и на момент подготовки оригинала этой статьи ещё нет RFC, которые бы описывали эти случаи.
Согласно этому старому комментарию, команда Rust пока не отказалась от проработки этой темы; дискуссия продолжается.
Пусть и неофициально, но существует ещё один интересный подход, который будет и далее исследоваться в Rust — он называется «Sans I/O». Это протокол Python абстрагирует использование таких сетевых протоколов как HTTP, в нашем случае это позволяет довести до максимума возможности переиспользования. На Rust существует пример такого рода, он называется tame-oidc.
Заключение
Вот из каких вариантов сейчас приходится выбирать:
Игнорировать справку Cargo. Можно предположить, что при работе с Rspotify никто не будет одновременно использовать синхронный и асинхронный подход.
Исправить maybe_async и добавить суффиксы
_async
и_sync
на каждую конечную точку в нашей библиотеке.Отменить поддержку как для асинхронного, так и для синхронного кода. В таком случае возникнет путаница, в которой просто некому разбираться, и которая повлияет на другие элементы Rspotify. Проблема в том, что от rspotify зависят некоторые блокирующие крейты, например, ncspot или spotifyd, а другие крейты, например, spotify-tui, используют асинхронность. Не вполне понимаю, что в данном случае делать.
Знаю, что я сам перед собой поставил эту проблему. Можно было бы просто сказать: «Нет, мы поддерживаем только асинхронный вариант» или «Нет. Мы поддерживаем только синхронный вариант». Притом, что есть пользователи, заинтересованные в одновременном применении обоих, в некоторых случаях нужно просто уметь говорить «нет». Если с подобной фичей становится настолько сложно обращаться, что вся база кода превращается в кашу, а у вас просто нет кадров, чтобы навести в ней порядок, то вам не остаётся иного выбора. Если бы кто-то об этом позаботился, можно было бы просто сделать форк и преобразовать его в синхронный для своих собственных нужд.
В конце концов, большинство API-обёрток и подобных сущностей поддерживают или асинхронный, или блокирующий код. Так, serenity (Discord API), sqlx (инструментарий SQL) и teloxide (API Telegram) строго асинхронны и при этом очень популярны.
Пусть временами это и очень удручает, я не раскаиваюсь, что потратил столько времени, раз за разом пытаясь добиться одновременной работы синхронного и асинхронного кода. Я участвовал в разработке Rspotify прежде всего ради того, чтобы учиться. У меня не было ни дедлайнов, ни стресса, я просто хотел в свободное время в меру сил улучшить библиотеку Rust. И я многое выучил; надеюсь, и вы тоже, когда прочитали эту статью.
Пожалуй, мораль статьи такова: необходимо помнить, что Rust — это, прежде всего, низкоуровневый язык, и некоторые вещи в нём просто невозможно реализовать, не прибегая к чрезмерному усложнению. В любом случае, с интересом наблюдаю, как же команда Rust собирается решать эти проблемы.
Источники
[1] Cleaning up the blocking module ramsayleung/rspotify#112 (комментарии)
[2] reqwest/src/blocking/client.rs @ line 757 — GitHub
[3] Cleaning up the blocking module ramsayleung/rspotify#112 (комментарии)
[4] Cargo«s Documentation, «Feature unification»
[5] Proposal: Move sync and async features into seperate modules fMeow/arangors#37
[6] aragog/src/lib.rs @ line 488 — GitLab
[7] inkwell/src/lib.rs @ line 107 — GitHub
[8] k8s-openapi/build.rs @ line 31 — GitHub
Habrahabr.ru прочитано 11775 раз