Зачем нужен асинхронный Rust

2022-09-26 Yoshua Wuyts Источник

  • Иерархия языков
  • Асинхронность под капотом
  • Возможности асинхронного Rust
    • Ad-hoc отмена
    • Ad-hoc конкурентность
    • Комбинирование отмены и конкурентности
  • Производительность: рабочие нагрузки
  • Производительность: оптимизации
  • Экосистема
  • Заключение

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

Я часто замечаю, что асинхронный Rust неправильно понимают. Разговоры о «зачем нужна асинхронность» часто сосредотачиваются на производительности1 — теме, которая сильно зависит от рабочих нагрузок и приводит к тому, что люди полностью говорят мимо друг друга. Хотя производительность — не плохая причина для выбора асинхронного Rust, часто мы замечаем производительность только тогда, когда испытываем ее недостаток. Поэтому я хочу вместо этого сосредоточиться на том, какие возможности предоставляет асинхронный Rust, которых нет в неасинхронном Rust. Хотя мы тоже немного поговорим о производительности в конце этого поста.

Иерархия языков

Нередко можно услышать, как Rust и другие языки описывают как «N языков в одном пальто». В Rust у нас есть управляющие конструкции Rust, у нас есть метаязык decl-макросов, у нас есть система трейтов (которая тьюринг-полна), у нас есть язык аннотаций cfg — и список продолжается. Но если мы рассматриваем Rust таким, каким он предоставлен нам из коробки, как «базовый Rust», то есть некоторые очевидные модификаторы для него:

  • unsafe Rust: для использования сырых указателей и FFI
  • const Rust: для вычисления значений во время компиляции
  • async Rust: для включения неблокирующих вычислений

Все эти «модифицирующие ключевые слова» языка Rust предоставляют новые возможности, которых нет в «базовом Rust». Но они также иногда могут забирать возможности. То, как я начал думать и говорить о функциях языка, — в терминах «подмножество языка» или «надмножество языка». С этой классификацией мы можем снова посмотреть на модифицирующие ключевые слова и сделать следующую категоризацию:

  • unsafe Rust: надмножество
  • const Rust: подмножество
  • async Rust: надмножество

unsafe Rust только добавляет возможность использовать сырые указатели. async только добавляет возможность .await значений. Но const добавляет возможность вычислять значения во время компиляции, но убирает возможность использовать статики и получать доступ к таким вещам, как сеть или файловая система.

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

Дизайн функций языка как под-/надмножеств «базового» Rust важен: он гарантирует, что язык продолжает ощущаться целостным. И больше, чем размер или объем, именно единообразие приводит к ощущению простоты.

Асинхронность под капотом

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

#![allow(unused)]
fn main() {
/// Эта функция возвращает строку
fn read_to_string(path: Path) -> String { .. }

/// Эта функция возвращает тип, который в конечном итоге возвращает строку
async fn read_to_string(path: Path) -> String { .. }

/// Вместо использования `async fn` мы также можем написать это так.
/// `impl Future` здесь — это тип-стирающая структура
fn read_to_string(path: Path) -> impl Future<Output = String> { .. }
}

Future — это просто тип с методом на нем (fn poll). Если мы вызываем метод в правильное время и правильным образом, то в конечном итоге он даст нам эквивалент Option<T>, где T — значение, которое мы хотели.

У «Future — это просто тип с методом, который мы можем вызывать» есть несколько следствий. Во-первых, так же, как мы можем выбрать вызов метода, мы можем выбрать и не вызывать метод. Если мы не будем вызывать метод вообще, то future не выполнит никакой работы3. Или мы можем выбрать вызов его, а затем прекратить вызывать его на некоторое время. Может быть, мы просто вызовем метод снова позже. Мы даже можем выбрать отбрасывание структуры в любой момент, и тогда не будет больше метода для вызова и больше не будет значения, которое можно получить.

Когда мы говорим о «представлении вычисления в типе», мы на самом деле говорим о компиляции async fn и всех ее точек .await в машину состояний, которая знает, как приостанавливаться и возобновляться из различных точек .await. Эти машины состояний — это просто структуры с некоторыми полями в них, и у них есть автоматически сгенерированная реализация Future::poll, которая знает, как правильно переходить между различными состояниями. Чтобы узнать больше о том, как работают эти машины состояний, я рекомендую посмотреть «Life of an async fn» от tmandry.

Синтаксис .await предоставляет способ гарантировать, что ни одна из деталей лежащего в основе poll не проявляется в пользовательском синтаксисе. Большая часть использования async/.await выглядит так же, как неасинхронный Rust, но с добавленными сверху аннотациями async/.await.

Возможности асинхронного Rust

Основная функция, которую предоставляет async/.await в Rust, — это контроль над выполнением. Вместо того чтобы контракт был:

«вызов функции» -> «вывод»

Мы вместо этого получаем доступ к промежуточному шагу:

«вызов функции» -> «вычисление» -> «вывод»

Вычисление — это уже не просто что-то, что скрыто от нас. С async/.await мы получаем возможность управлять самим вычислением. Это приводит к нескольким ключевым возможностям:

  • Возможность приостанавливать/отменять/ставить на паузу/возобновлять вычисление (ad-hoc отмена)
  • Возможность выполнять вычисления конкурентно (ad-hoc конкурентность)
  • Возможность комбинировать контроль над выполнением, отменой и конкурентностью

Ad-hoc отмена

Возможность приостанавливать/отменять/ставить на паузу/возобновлять любое вычисление невероятно полезна. Из трех способность отменять выполнение, вероятно, наиболее полезна. Как в синхронном, так и в асинхронном коде желательно останавливать выполнение до его завершения. Но что уникально для асинхронного Rust, так это то, что любое вычисление может быть остановлено единообразным способом. Каждый future может быть отменен, и все future должны это учитывать4.

Ad-hoc конкурентность

Возможность выполнять вычисления конкурентно — еще одна характерная возможность асинхронного Rust. Любое количество async fn может быть запущено конкурентно5 и ожидаться (.await) вместе. В неасинхронном Rust конкурентность обычно привязана к параллелизму: многие вычисления могут быть запланированы конкурентно с использованием thread::spawn и разделения таким образом. Но асинхронный Rust отделяет конкурентность от параллелизма, предоставляя больше контроля6. В неасинхронном Rust конкурентность и параллелизм взаимосвязаны, что, среди прочего, имеет последствия для производительности. Мы поговорим больше о различиях позже в этом посте.

Комбинирование отмены и конкурентности

Теперь, наконец: что происходит, когда вы комбинируете отмену и конкурентность? Это позволяет нам делать некоторые интересные вещи! В моем посте «Async Time III: Cancellation and Signals» я подробно рассказываю о некоторых вещах, которые можно делать с этим. Но канонический пример здесь: таймауты. Таймаут — это конкурентное выполнение какого-то future и future-таймера, отображенное в Result:

  • Если future завершается до таймера, мы отменяем таймер и возвращаем Ok
  • Если таймер завершается до future, мы отменяем future и возвращаем Err

Это отмена + конкурентность, объединенные для предоставления нового третьего типа операции. Чтобы понять, почему возможность устанавливать таймаут для любого вычисления является полезным свойством, я очень рекомендую прочитать «Crash-Only Software» от Candea и Fox7. Но на таймаутах все не останавливается: если мы комбинируем любые из возможностей приостановки/отмены/паузы/возобновления с конкурентностью, мы открываем myriad новых возможных операций.

Это возможности, которые включает асинхронный Rust. В неасинхронном Rust конкурентность, отмена и приостановки часто требуют вызова нижележащей операционной системы — и это не всегда поддерживается. Например: в Rust нет встроенного способа отменять потоки. Способ сделать это обычно — передать канал в поток и периодически проверять его, чтобы увидеть, не было ли передано какое-то сообщение «отмена».

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

Производительность: рабочие нагрузки

Когда что-то заявлено как работающее лучше чего-то другого, всегда стоит спрашивать: «при каких обстоятельствах?» Производительность всегда зависит от рабочей нагрузки. В тестах графических карт вы часто видите различия между видеокартами в зависимости от того, какие игры запускаются. В тестах процессоров сильно важно, является ли рабочая нагрузка в основном однопоточной или многопоточной. И когда мы говорим о функциях программного обеспечения, «производительность» — это тоже не бинарная величина, а сильно зависящая от рабочей нагрузки. Говоря о параллельной обработке, мы можем различать две общие категории рабочих нагрузок:

  • Ориентированные на пропускную способность (throughput-oriented)
  • Ориентированные на задержку (latency-oriented)

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

Пример программного обеспечения, разработанного с учетом пропускной способности, — это hadoop. Он создан для «оффлайн» пакетной обработки рабочих нагрузок; где самая важная цель проектирования — минимизировать общее время ЦП, затраченное на обработку данных. Когда данные помещаются в систему, их обработка может часто занимать минуты или даже часы. И это нормально. Нам не важно, когда мы получим результаты (в разумных пределах, конечно), мы в первую очередь заботимся об использовании как можно меньшего количества ресурсов для получения результатов.

Сравните это с общедоступным HTTP-сервером. Сети обычно ориентированы на задержку. Нас часто меньше волнует, сколько запросов мы можем обработать, чем то, как быстро мы можем на них ответить. Когда приходит запрос, мы не хотим тратить минуты или часы на генерацию ответа. Мы хотим, чтобы циклы запрос-ответ измерялись в миллисекундах максимум. И такие вещи, как p99 хвостовые задержки (tail-latencies), часто используются как ключевые показатели производительности.

Асинхронный Rust обычно считается более ориентированным на задержку, чем на пропускную способность. Среды выполнения, такие как async-std и tokio, в первую очередь заботятся о том, чтобы общая задержка оставалась низкой, и предотвращают внезапные скачки задержки.

Понимание того, какой тип рабочей нагрузки обсуждается, часто является первым шагом в обсуждении производительности. Ключевое преимущество асинхронного Rust заключается в том, что большинство систем, которые его используют, были сильно настроены для обеспечения хорошей производительности для рабочих нагрузок, ориентированных на задержку, — примером которых являются сети. Если вы хотите обрабатывать более ориентированные на пропускную способность рабочие нагрузки, неасинхронные крейты, такие как rayon, часто лучше подходят.

Производительность: оптимизации

Асинхронный Rust отделяет конкурентность и параллелизм друг от друга. Иногда эти два понятия путают друг с другом, но на самом деле они разные:

  • Параллелизм — это ресурс, конкурентность — это способ планирования вычислений

Лучше всего думать о «параллелизме» как о максимуме. Например, если ваш компьютер имеет два ядра, максимальное количество параллелизма, которое у вас может быть, может быть два8. Но параллелизм отличается от конкурентности: вычисления могут быть перемежаемы на одном ядре, поэтому, пока мы ждем, пока сеть выполнит работу, мы можем запускать некоторые другие вычисления, пока не получим ответ. Даже на однопоточных машинах вычисления могут быть перемежаемыми и конкурентными. И наоборот: только потому, что мы запланировали вещи параллельно, не означает, что вычисления перемежаются. Независимо от того, на скольких ядрах мы работаем, если потоки по очереди ждут на одной общей блокировке, то логическое выполнение может на самом деле все еще происходить последовательно.

Давайте взглянем на пример конкурентной рабочей нагрузки в асинхронном и неасинхронном Rust. В неасинхронном Rust наиболее распространено использование потоков для достижения конкурентного выполнения9. Но поскольку потоки также являются абстракцией для достижения параллельного выполнения, это означает, что в неасинхронном Rust конкурентность и параллелизм часто тесно взаимосвязаны.

В асинхронном Rust мы можем отделить конкурентность от параллелизма. Если рабочая нагрузка конкурентна, это не подразумевает, что она также может быть распараллелена. Это обеспечивает более тонкий контроль над выполнением, что является ключевой силой асинхронного Rust. Давайте сравним конкурентность в неасинхронном и асинхронном Rust:

#![allow(unused)]
fn main() {
// конкурентное вычисление на основе потоков
let x = thread::spawn(|| 1 + 1);
let y = thread::spawn(|| 2 + 2);
let (x, y) = (x.join(), y.join()); // ждем, пока оба потока вернут результат

// конкурентное вычисление на основе async
let x = async { 1 + 1 };
let y = async { 2 + 2 };
let (x, y) = (x, y).await;  // разрешаем оба future конкурентно
}

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

#![allow(unused)]
fn main() {
// оптимизированное компилятором конкурентное вычисление на основе async
let (x, y) = (2, 4);
}

В отличие от этого, лучшее, что компилятор, вероятно, может сделать для варианта на основе потоков, это:

#![allow(unused)]
fn main() {
// конкурентное вычисление на основе потоков
let x = thread::spawn(|| 2);
let y = thread::spawn(|| 4);
let (x, y) = (x.join(), y.join()); // ждем, пока оба потока вернут результат
}

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

Экосистема

Прежде чем мы закончим, мы должны указать еще одну последнюю причину, по которой люди могут выбирать асинхронный Rust: размер экосистемы. Без keyword generics может быть много работы для авторов библиотек публиковать и поддерживать библиотеки, которые работают как в асинхронном, так и в неасинхронном Rust. Часто проще всего просто опубликовать либо асинхронную, либо неасинхронную библиотеку и не учитывать другой вариант использования. Многие сетевые библиотеки на crates.io используют асинхронный Rust, что означает, что библиотеки, строящиеся поверх этого, также будут использовать асинхронный Rust. И, в свою очередь, люди, желающие создавать веб-сайты без переписывания всего с нуля, часто будут иметь большую экосистему для выбора при использовании асинхронного Rust.

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

Заключение

Иногда возникают разговоры об асинхронном Rust с предложениями вроде: «Что, если бы мы полностью запретили отмену?»11 Видеть это всегда сбивает меня с толку, потому что кажется, что это несет фундальное непонимание того, что предоставляет асинхронный Rust и почему его следует использовать. Если фокус исключительно на производительности, такие функции, как отмена или аннотации .await, могут казаться простой неприятностью.

Но если фокус больше на возможностях, которые включает асинхронный Rust, такие вещи, как отмена и таймауты, быстро поднимаются от неприятности до ключевых причин для принятия асинхронного Rust. Асинхронный Rust предоставляет нам возможность контролировать выполнение таким образом, который просто невозможен в неасинхронном Rust. И, откровенно говоря, даже невозможен во многих других языках программирования, имеющих async/.await. Тот факт, что async fn компилируется в ленивую машину состояний вместо eager управляемой задачи, — это crucial различие. И это означает, что мы можем создавать примитивы конкурентности полностью в библиотечном коде, вместо того чтобы нужно было встраивать их в компилятор или среду выполнения.

В асинхронном Rust функции, которые он включает, строятся друг на друге. Вот краткое описание того, как они связаны:

        Иерархия возможностей
        асинхронного Rust от Yosh
    ┌───────────────────────────────┐
3.  │ Таймауты, Таймеры, Сигналы    │  …которые затем могут быть скомпонованы в…
    ├───────────────┬───────────────┤
2.  │   Отмена      │ Конкурентность│  …которые, в свою очередь, включают…
    ├───────────────┴───────────────┤
1.  │    Контроль над выполнением   │  Основные future включают…
    └───────────────────────────────┘

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

Спасибо Ирине Шестак за вычитку этого поста и предоставление полезных отзывов по пути.

Примечания


  1. Введение в книгу по асинхронному Rust обобщает преимущества асинхронного Rust следующим образом: «В итоге асинхронное программирование позволяет реализовать высокопроизводительные реализации, которые подходят для низкоуровневых языков, таких как Rust, обеспечивая при этом большую часть эргономических преимуществ потоков и сопрограмм.»

  2. Конечно; «монада» 🙃

  3. Да, это только случай для async fn / async {}-future. Если вы -> impl Future, вы можете выполнять работу до конструирования future и возврата его. Но это не считается хорошим паттерном, и на практике это довольно редко.

  4. Да, я полностью осведомлен о концепции «безопасности отмены» (cancellation-safety), и у меня скоро выйдет пост, обсуждающий ее более подробно. Кратко: концепция «безопасности отмены» недостаточно определена, но важно: «безопасность отмены» актуальна только при использовании select! {}, который является чем-то, что не следует использовать. Правильная обработка отмены — это то, что вручную созданные future все еще должны делать, и это может быть сложно сделать без «async Drop». Но это отличается от «безопасности отмены» или идеи, что future вообще не должны учитывать возможность быть отмененными.

  5. За исключением любых сбоев среды выполнения, таких как взаимоблокировки (deadlocks), которые могут возникнуть, если два вычисления выполняются конкурентно, но разделяют ресурс.

  6. Не все библиотеки используют разделение между «конкурентностью» и «параллелизмом» though. Мы все еще очень much разбираемся в том, что такое асинхронный Rust вообще, но многие из библиотек в общем использовании сегодня не обязательно проявляют это понимание. Я знаю, что многие мои собственные старые библиотеки точно нет.

  7. Крикнуть Eric Holk за то, что познакомил меня с этой статьей!

  8. Это упрощенный пример; для более длинного объяснения см. документацию std::thread::available_parallelism.

  9. Если только вы не начнете вручную писать циклы epoll(7) и вручную создавать машины состояний. В какой-то момент вы можете начать думать о создании абстракций, чтобы эти машины состояний лучше сочетались друг с другом, в какой момент вы basically снова пришли к future. Нативно, чтобы достичь: «Я хочу, чтобы этот код выполнялся конкурентно», потоки — самая простая, наиболее удобная абстракция, доступная в неасинхронном Rust.

  10. Обратите внимание, что этот точный пример еще не работает в оптимизаторе, но я не верю, что есть какая-то веская причина, почему он не мог бы. Это все локальные рассуждения, без необходимости межпоточной синхронизации. Ближайший пример, который у меня есть для оптимизаций такого рода, — этот пример версии block_on, написанной вручную, которая компилируется абсолютно в ничто. Это немного читерство, удаляющее не использование Arc на основе атомиков, так что я не уверен, насколько это реалистично. Но это определенно то, к чему стоит стремиться, и я оптимистичен, что по мере того, как асинхронный Rust будет видеть больше использования, мы увидим больше асинхронно-специфичных оптимизаций.

  11. Цель этого часто — иметь линейные future или «future, которые гарантированно завершаются». Способ, которым отмена была бы запрещена, — только вид «остановить опрос» (stop polling). Вы все равно должны быть able передавать каналы для отмены вещей, по крайней мере, такова теория. Хотя я верю, что могут быть последствия и для конкурентности, что сделало бы это действительно трудным.