Как устроены асинхронные вычисления в Rust? Чем отличаются async/await от стандартных многозадачных подходов?
Асинхронные вычисления в Rust реализуются через механизм async/await, который позволяет писать неблокирующий, эффективный и безопасный код, особенно полезный при работе с вводом-выводом (I/O), сетевыми запросами, взаимодействием с базами данных и другими длительными операциями. При этом важно понимать, что async в Rust — это не "магия", а компиляция кода в конечный набор state-machine-конструкций, управляемых планировщиком (executor).
Асинхронность в Rust: как это работает
Rust не имеет встроенного рантайма, как, например, JavaScript или Python. Асинхронность реализуется без потерь производительности за счёт "zero-cost abstractions" — компилятор преобразует async fn в state machine (машину состояний), которая реализует трейты Future и Unpin.
Когда вы пишете:
async fn example() -> i32 {
5
}
Это превращается во что-то, что реализует:
impl Future for ExampleFuture {
type Output = i32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'\_>) -> Poll<Self::Output> {
Poll::Ready(5)
}
}
Таким образом, async fn не запускается сразу — он возвращает Future, который нужно явно "драйвить" (poll), чтобы получить результат.
Что такое Future
Future — это объект, представляющий собой отложенное значение, которое будет доступно в будущем. Он реализует трейты из std::future, и его поведение зависит от планировщика (executor), который вызывает метод poll.
Метод poll либо возвращает:
-
Poll::Pending — если операция ещё не завершена;
-
Poll::Ready(value) — если значение готово.
Пока Poll::Pending, Future регистрирует "waker" в Context, чтобы executor знал, когда попробовать вызвать poll снова.
async/await в действии
Функция с async возвращает Future, а await означает "остановись и дождись, когда это завершится":
let result = do_something_async().await;
Здесь выполнение будет приостановлено, пока do_something_async() не завершится. Но приостановка не блокирует поток — она освобождает его для других задач.
Executor: кто запускает Future
Компилятор Rust не поставляет собственный планировщик (executor) по умолчанию. Обычно используются внешние библиотеки:
-
tokio — полнофункциональный async-рантайм с таймерами, TCP/UDP, каналами и прочим.
-
async-std — альтернатива с API, похожим на стандартную библиотеку.
-
smol — минималистичный легковесный рантайм.
Вы должны либо использовать #[tokio::main], либо создать свой executor, например:
use tokio;
#\[tokio::main\]
async fn main() {
let data = fetch_data().await;
println!("{data}");
}
async vs std::thread
Сравним с классической многопоточностью:
Потоки (std::thread)
-
Каждая задача — отдельный поток ОС.
-
Контекстный переключатель требует системных ресурсов.
-
Может быть блокировка при join или sleep.
-
Отлично подходит для CPU-bound задач.
async/await
-
Все задачи живут в одном или нескольких потоках, как кооперативная многозадачность.
-
Задачи переключаются в пользовательском пространстве (без вызовов ядра).
-
Идеальны для I/O-bound операций: чтения из сети, файлов, баз данных.
-
Безопасны по памяти, как и весь остальной Rust-код.
Пример асинхронного кода
use tokio::time::{sleep, Duration};
async fn do_work() {
println!("Начинаю работу");
sleep(Duration::from_secs(2)).await;
println!("Работа завершена");
}
#\[tokio::main\]
async fn main() {
let h1 = tokio::spawn(do_work());
let h2 = tokio::spawn(do_work());
h1.await.unwrap();
h2.await.unwrap();
}
Здесь обе задачи будут выполняться параллельно, но в рамках одного потока, если не указано иначе. В отличие от thread::spawn, tokio::spawn не создаёт новый поток ОС, а просто добавляет задачу в планировщик.
Особенности и нюансы
-
Нельзя блокировать поток в async-функции. Вызов std::thread::sleep или std::fs::read_to_string — это блокирующие операции. Надо использовать async-аналоги (tokio::fs, tokio::time::sleep и др.).
-
Send и Sync по-прежнему важны. Не каждый Future можно передать между потоками. Растовский компилятор строго проверяет это.
-
Состояния Future не сохраняются в куче — компилятор знает все переходы, и state-machine может лежать в стеке. Это делает async в Rust очень быстрым и эффективным.
Проблема "async all the way"
Если вы начали использовать async, часто вам приходится передавать async вверх по стеку вызовов. Это связано с тем, что async fn возвращает impl Future, который нельзя "ждать" в обычной синхронной функции без executor. Поэтому большинство проектов либо делают main асинхронным, либо разделяют async/синхронный код чётко.
Сравнение с другими языками
-
В JavaScript все async-операции работают через event loop и single-threaded модель.
-
В Python (через asyncio) есть похожая кооперативная модель, но с GIL и сложной интеграцией с библиотеками.
-
В C++20 co_await и coroutines похожи по сути, но слабее по безопасности и стандартизации.
Rust выделяется тем, что:
-
не требует рантайма;
-
обеспечивает безопасность на этапе компиляции;
-
даёт полный контроль над исполнением задач;
-
и, несмотря на async-преобразование, абстракции остаются zero-cost.
Таким образом, async/await в Rust — это мощный, но строго управляемый механизм асинхронности, основанный на трейтах Future, без рантайма и с максимальной безопасностью типов и владения. Он даёт производительность, сопоставимую с системным кодом, и удобство, сравнимое с высокоуровневыми языками.