Зачем нужна асинхронность?
Мы все любим Rust за то, что он позволяет нам писать быстрое и безопасное программное обеспечение. Но как асинхронное программирование вписывается в эту картину?
Асинхронное программирование (сокращённо async) — это модель параллельного программирования,
которая поддерживается всё большим количеством языков программирования.
Оно позволяет запускать большое количество параллельных
задач на небольшом количестве потоков операционной системы, сохраняя при этом большую часть
внешнего вида и удобства обычного синхронного программирования благодаря синтаксису
async/await.
Async против других моделей параллелизма
Параллельное программирование менее зрелое и «стандартизированное», чем обычное, последовательное программирование. Как следствие, мы выражаем параллелизм по-разному в зависимости от того, какую модель параллельного программирования поддерживает язык. Краткий обзор самых популярных моделей параллелизма может помочь вам понять, как асинхронное программирование вписывается в более широкую область параллельного программирования:
- Потоки ОС не требуют изменений в модели программирования, что позволяет очень легко выражать параллелизм. Однако синхронизация между потоками может быть сложной, а накладные расходы на производительность велики. Пулы потоков могут смягчить некоторые из этих затрат, но недостаточно, чтобы поддерживать огромные нагрузки, ограниченные вводом-выводом (IO-bound).
- Событийно-ориентированное программирование в сочетании с обратными вызовами (callback) может быть очень производительным, но часто приводит к многословному, "нелинейному" потоку управления. Поток данных и распространение ошибок часто трудно отслеживать.
- Корутины, как и потоки, не требуют изменений в модели программирования, что делает их простыми в использовании. Как и async, они также могут поддерживать большое количество задач. Однако они абстрагируют низкоуровневые детали, которые важны для системного программирования и создателей пользовательских сред выполнения (runtime).
- Модель акторов разделяет все параллельные вычисления на единицы, называемые акторами, которые общаются через ненадёжную передачу сообщений, во многом как в распределённых системах. Модель акторов может быть эффективно реализована, но она оставляет без ответа многие практические вопросы, такие как управление потоком (flow control) и логика повторных попыток.
В итоге, асинхронное программирование позволяет создавать высокопроизводительные реализации, которые подходят для низкоуровневых языков, таких как Rust, одновременно предоставляя большинство эргономических преимуществ потоков и корутин.
Async в Rust против других языков
Хотя асинхронное программирование поддерживается во многих языках, некоторые детали различаются в разных реализациях. Реализация async в Rust отличается от большинства языков несколькими способами:
- Фьючерсы (Futures) в Rust инертны и прогрессируют только при опросе (polled). Удаление (Dropping) фьючерса останавливает его дальнейшее выполнение.
- Async в Rust бесплатен (zero-cost), что означает, что вы платите только за то, что используете. В частности, вы можете использовать async без выделения памяти в куче (heap allocations) и динамической диспетчеризации (dynamic dispatch), что отлично для производительности! Это также позволяет использовать async в ограниченных средах, таких как встраиваемые системы (embedded systems).
- В Rust нет встроенной среды выполнения (runtime). Вместо этого среды выполнения предоставляются крейтами, поддерживаемыми сообществом.
- В Rust доступны как однопоточные, так и многопоточные среды выполнения, которые имеют разные сильные и слабые стороны.
Async против потоков в Rust
Основная альтернатива async в Rust — использование потоков ОС, либо
напрямую через std::thread,
либо косвенно через пул потоков.
Переход с потоков на async или наоборот
обычно требует значительной работы по рефакторингу, как с точки зрения реализации, так и
(если вы создаёте библиотеку) любых открытых публичных интерфейсов. Как следствие,
выбор модели, которая подходит для ваших нужд на раннем этапе, может сэкономить много времени на разработку.
Потоки ОС подходят для небольшого количества задач, поскольку потоки несут накладные расходы на ЦП и память. Создание и переключение между потоками довольно дорого, так как даже бездействующие потоки потребляют системные ресурсы. Библиотека пула потоков может помочь смягчить некоторые из этих затрат, но не все. Однако потоки позволяют повторно использовать существующий синхронный код без значительных изменений кода — не требуется какая-либо конкретная модель программирования. В некоторых операционных системах вы также можете изменить приоритет потока, что полезно для драйверов и других приложений, чувствительных к задержкам.
Async обеспечивает значительное снижение нагрузки на ЦП и память, особенно для рабочих нагрузок с большим количеством задач, ограниченных вводом-выводом (IO-bound), таких как серверы и базы данных. При прочих равных условиях, у вас может быть на порядки больше задач, чем потоков ОС, потому что асинхронная среда выполнения использует небольшое количество (дорогих) потоков для обработки большого количества (дешёвых) задач. Однако асинхронный Rust приводит к увеличению размера двоичных файлов из-за генерируемых из асинхронных функций автоматов состояний (state machines) и из-за того, что каждый исполняемый файл включает в себя асинхронную среду выполнения.
В заключение отметим, что асинхронное программирование не лучше потоков, а просто другое. Если вам не нужна async по соображениям производительности, потоки часто могут быть более простой альтернативой.
Пример: Concurrent downloading
В этом примере наша цель — загрузить две веб-страницы параллельно. В типичном многопоточном приложении нам нужно создавать потоки для достижения параллелизма:
fn get_two_sites() {
// Spawn two threads to do work.
let thread_one = thread::spawn(|| download("https://www.foo.com"));
let thread_two = thread::spawn(|| download("https://www.bar.com"));
// Wait for both threads to complete.
thread_one.join().expect("thread one panicked");
thread_two.join().expect("thread two panicked");
}
Однако загрузка веб-страницы — это небольшая задача; создание потока для такого небольшого объёма работы довольно расточительно. Для более крупного приложения это может легко стать узким местом. В асинхронном Rust мы можем запускать эти задачи параллельно без дополнительных потоков:
async fn get_two_sites_async() {
// Create two different "futures" which, when run to completion,
// will asynchronously download the webpages.
let future_one = download_async("https://www.foo.com");
let future_two = download_async("https://www.bar.com");
// Run both futures to completion at the same time.
join!(future_one, future_two);
}
Здесь не создаётся дополнительных потоков. Более того, все вызовы функций являются статически диспетчеризируемыми (statically dispatched), и нет выделений памяти в куче (heap allocations)! Однако изначально нам нужно написать код асинхронным, чего вы достигнете с помощью этой книги.
Пользовательские модели параллелизма в Rust
В заключение отметим, что Rust не заставляет вас выбирать между потоками и async. Вы можете использовать обе модели в одном приложении, что может быть полезно, когда у вас есть смешанные зависимости (одни используют потоки, другие — async). На самом деле, вы даже можете использовать совершенно другую модель параллелизма, такую как событийно-ориентированное программирование, при условии, что вы найдёте библиотеку, которая её реализует.