[Перевод] Жизнь – боль: как одновременно поддерживать в Rust синхронный и асинхронный код

0c008a8cc09bffd0b537744824da8b3e

Введение

Присаживайтесь поудобнее и послушайте стариковскую байку: что случилось, когда я попросил у 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