async в Rust и Tokio
| async/await | стабилизированы в Rust 1.39 (ноябрь 2019) |
|---|---|
| Модель | ленивые футуры с опросом (poll), без встроенной среды выполнения |
| Tokio | с 2016, версия 1.0 — декабрь 2020 |
| Автор Tokio | Карл Лерхе и сообщество |
| Лицензия Tokio | MIT |
| Экосистема | hyper, axum, reqwest, tonic, sqlx |
| Альтернативы | smol, embassy (встраиваемые системы) |
Асинхронное программирование в Rust позволяет одному потоку обслуживать тысячи одновременных операций ввода-вывода — сетевых соединений, запросов к базе данных, таймеров, — не создавая отдельный поток операционной системы на каждую. Функция, объявленная как async fn, возвращает футуру (future) — значение, представляющее ещё не завершённое вычисление, а .await ждёт её результата, не блокируя поток. В отличие от многих языков, в Rust нет встроенной среды выполнения: футуры исполняет подключаемая библиотека, и самая распространённая из них — Tokio.
На асинхронном Rust построены веб-серверы, прокси, базы данных, брокеры сообщений и клиентские SDK облачных сервисов.
История
Синтаксис async/await стабилизировали в Rust 1.39 в ноябре 2019 года после нескольких лет проектирования.[3] Ключевое решение — футуры с нулевой стоимостью: компилятор превращает асинхронную функцию в конечный автомат, размер и поведение которого известны при компиляции, без выделения памяти в куче на каждый шаг. Tokio начали развивать в 2016 году; версия 1.0 с гарантией стабильности API вышла в декабре 2020 года.[4] В Rust 1.75 (2023) появились асинхронные методы в трейтах (см. Трейты (Rust)).
Как работают футуры
Футура реализует трейт Future с методом poll. Среда выполнения опрашивает футуру: та либо возвращает Ready(результат), либо Pending и регистрирует пробуждатель (waker), который сообщит среде, когда стоит опросить её снова — например, когда в сокет пришли данные.[5]
Главное отличие от JavaScript и asyncio в Python: футуры в Rust ленивые. Вызов async fn ничего не выполняет, пока футуру не начнут ожидать через .await или не передадут исполнителю. Забытая без .await футура просто не выполнится — компилятор предупредит об этом.
Пример с Tokio
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = "0.12"
use std::time::Duration;
use tokio::time::sleep;
async fn fetch(url: &str) -> Result<usize, reqwest::Error> {
let body = reqwest::get(url).await?.text().await?;
Ok(body.len())
}
#[tokio::main] // запускает многопоточную среду выполнения
async fn main() {
let urls = ["https://example.com", "https://www.rust-lang.org"];
// конкурентно: обе загрузки идут одновременно
let handles: Vec<_> = urls.iter()
.map(|u| tokio::spawn(fetch(u))) // отдельная задача на каждый URL
.collect();
for h in handles {
println!("{:?}", h.await.unwrap());
}
// таймаут и выбор первого завершившегося
tokio::select! {
r = fetch("https://example.com") => println!("ответ: {r:?}"),
_ = sleep(Duration::from_secs(2)) => println!("таймаут"),
}
}
Что даёт Tokio
| Компонент | Назначение |
|---|---|
| Планировщик | многопоточный исполнитель с перехватом задач (work stealing) |
| Ввод-вывод | асинхронные TCP, UDP, Unix-сокеты, файлы, процессы |
| Время | sleep, timeout, interval |
| Синхронизация | асинхронные Mutex, RwLock, Semaphore, каналы mpsc, oneshot, broadcast, watch |
| Задачи | tokio::spawn, JoinSet, отмена через удаление футуры |
Подробное введение — в официальном руководстве Tokio, где по шагам строится мини-версия Redis.[2]
Экосистема
- hyper — низкоуровневая реализация HTTP/1 и HTTP/2; axum — популярный веб-фреймворк поверх hyper и Tokio.[6]
- reqwest — HTTP-клиент, tonic — gRPC, sqlx — асинхронный доступ к PostgreSQL, MySQL и SQLite.
- tower — промежуточные слои: повторы, таймауты, ограничение нагрузки.
- tracing — структурированное логирование и трассировка асинхронного кода.
Подводные камни
- Блокирующий код в асинхронной задаче. Тяжёлые вычисления или синхронный ввод-вывод внутри
async fnостанавливают поток исполнителя и все задачи на нём. Для них естьtokio::task::spawn_blocking. - Требование
Send. Задачи многопоточного исполнителя могут переезжать между потоками, поэтому всё, что живёт через.await, должно бытьSend. Типичная ошибка — держатьstd::sync::MutexGuardилиRcчерез точку ожидания. - «Цветные» функции. Асинхронную функцию нельзя просто вызвать из синхронной — нужно запустить среду выполнения. Библиотеки нередко предлагают обе версии API.
- Отмена. Удаление футуры отменяет её в ближайшей точке
.await, поэтому код между точками ожидания должен оставлять данные в согласованном состоянии. - Сложность. Ошибки компиляции с
Pin, временами жизни иSend— самая трудная часть изучения асинхронного Rust; подробности — в официальной книге по асинхронному программированию.[1]
Когда нужен async
Асинхронность оправдана для программ с большим числом одновременных операций ввода-вывода: серверов, прокси, краулеров (см. Конкурентность и параллелизм). Для вычислений лучше подходят потоки и библиотеки вроде rayon, а для простых утилит командной строки — обычный синхронный код. Сравнение с моделью горутин Go: там планировщик встроен в язык, а в Rust среда выполнения выбирается и подключается явно.