async в Rust и Tokio

Материал из llmdocs — справочник разработчика
Портал: Go и Rust
async в Rust и Tokio
async/awaitстабилизированы в Rust 1.39 (ноябрь 2019)
Модельленивые футуры с опросом (poll), без встроенной среды выполнения
Tokioс 2016, версия 1.0 — декабрь 2020
Автор TokioКарл Лерхе и сообщество
Лицензия TokioMIT
Экосистема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 среда выполнения выбирается и подключается явно.

См. также

Источники

  1. Asynchronous Programming in Rust (async book)
  2. Tokio — Tutorial
  3. Rust Blog — Async-await on stable Rust! (1.39)
  4. Tokio Blog — Announcing Tokio 1.0
  5. Rust std — Trait std::future::Future
  6. axum — docs.rs