Async/Await в Rust
Представь ресторан. На странице Многопоточность ты познакомился с потоками. Это как нанять по официанту на каждый столик. Работает, но официанты стоят дорого, а большую часть времени они просто стоят и ждут кухню.
Async (асинхронность) — это один официант, который принимает много заказов. Он берёт заказ у столика 1, передаёт его на кухню и, пока еда готовится, идёт к столику 2. Никто не стоит без дела. В этом и состоит идея async и .await в Rust: пока одна задача ждёт (сеть, файл, таймер), может работать другая.
Async раскрывается, когда программа много времени проводит в ожидании: веб-серверы, клиенты API, чаты, боты. На этой странице мы используем tokio — самый популярный асинхронный рантайм для Rust.
Подключаем tokio
Заголовок раздела «Подключаем tokio»Стандартная библиотека Rust даёт ключевые слова async и .await, но не «двигатель», который на самом деле выполняет асинхронный код. Этот двигатель называется рантаймом (runtime), и большинство проектов используют tokio. Добавь его в новый проект:
cargo new async_democd async_democargo add tokio --features fullВ Cargo.toml появится такая строка:
[dependencies]tokio = { version = "1", features = ["full"] }Фича full включает всё сразу (таймеры, задачи, сеть и не только). Пока учишься, это самый простой вариант. Кнопки «Попробуй сам» на этой странице тоже работают — в Rust Playground tokio уже есть.
Твоя первая асинхронная программа
Заголовок раздела «Твоя первая асинхронная программа»use std::time::Duration;
async fn make_coffee() -> String { tokio::time::sleep(Duration::from_millis(20)).await; String::from("кофе")}
#[tokio::main]async fn main() { println!("Заказываем..."); let drink = make_coffee().await; println!("Вот твой {}", drink);}Вывод:
Заказываем...Вот твой кофеЗдесь три новые вещи:
async fnсоздаёт функцию, которая может остановиться посередине и продолжить позже..awaitзначит «подожди, пока это закончится, а пока ждём, пусть работают другие задачи»..awaitможно использовать только внутри функцииasync.#[tokio::main]запускает рантайм tokio и выполняет на нём твоюasync fn main. Без этогоmainне может бытьasync.
tokio::time::sleep — асинхронная версия «подожди немного». Она приостанавливает только эту задачу, а не весь поток.
Футуры ленивые
Заголовок раздела «Футуры ленивые»Вот что удивляет большинство людей. Вызов async fn не запускает её. Он возвращает футуру (future) — значение, которое означает «работа, которая произойдёт позже». Ничего не происходит, пока ты не сделаешь .await:
async fn say_hello() { println!("Привет изнутри футуры!");}
#[tokio::main]async fn main() { let future = say_hello(); // пока ничего не выполняется println!("Футура создана, но не запущена");
future.await; // вот теперь она выполняется println!("Готово");}Вывод:
Футура создана, но не запущенаПривет изнутри футуры!ГотовоПредставь футуру как бланк заказа. Если его заполнить, еда не приготовится. А если отдать на кухню (.await) — приготовится.
Запускаем задачи одновременно с join!
Заголовок раздела «Запускаем задачи одновременно с join!»До сих пор мы ждали по одной вещи за раз. Настоящая сила в том, чтобы ждать несколько вещей одновременно. tokio::join! выполняет несколько футур вместе и ждёт, пока все они закончатся. Результаты он возвращает кортежем:
use std::time::Duration;use tokio::time::sleep;
async fn fetch_user() -> String { sleep(Duration::from_millis(30)).await; // представим, что это сетевой запрос String::from("Аня")}
async fn fetch_order_count() -> u32 { sleep(Duration::from_millis(20)).await; 3}
#[tokio::main]async fn main() { let (user, orders) = tokio::join!(fetch_user(), fetch_order_count()); println!("{}: заказов — {}", user, orders);}Вывод:
Аня: заказов — 3Если бы ты ждал их по очереди, общее ожидание было бы около 30 + 20 = 50 мс. С join! оба ожидания идут одновременно, поэтому всё занимает около 30 мс — время самой медленной футуры. С настоящими сетевыми запросами эта разница быстро накапливается.
Запускаем задачи через tokio::spawn
Заголовок раздела «Запускаем задачи через tokio::spawn»join! отлично подходит, когда число футур заранее известно. Когда у тебя список заданий, используй tokio::spawn. Он передаёт футуру рантайму как отдельную задачу (task), сразу её запускает и возвращает JoinHandle. Позже ты делаешь .await у этого дескриптора и получаешь результат:
use std::time::Duration;use tokio::time::sleep;
async fn download(id: u64) -> String { sleep(Duration::from_millis(50 - id * 10)).await; format!("файл {} загружен", id)}
#[tokio::main]async fn main() { let mut handles = Vec::new();
for id in 1..=3 { let handle = tokio::spawn(download(id)); // начинает выполняться сразу handles.push(handle); }
for handle in handles { let result = handle.await.unwrap(); println!("{}", result); }}Вывод:
файл 1 загруженфайл 2 загруженфайл 3 загруженПрисмотрись: у файла 3 самый короткий сон, так что на самом деле он заканчивается первым. Но мы печатаем результаты в том порядке, в каком ждём дескрипторы, поэтому вывод всегда 1, 2, 3. При этом все три загрузки шли одновременно.
Зачем .unwrap()? .await у JoinHandle даёт Result, потому что задача могла запаниковать. Тогда ты получишь Err, а не падение всей программы. Это очень похоже на thread::spawn и .join() со страницы Многопоточность — и это сделано специально.
Частые ошибки
Заголовок раздела «Частые ошибки»Забыть .await. Футуры ленивые, поэтому без .await работа просто не выполнится. Если ты используешь значение, компилятор тебя остановит:
async fn get_number() -> u32 { 5}
#[tokio::main]async fn main() { let n = get_number(); // забыли .await println!("{}", n + 1);}error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>` --> src/main.rs:8:22 |8 | println!("{}", n + 1); | - ^ - {integer} | | | impl Future<Output = u32>Сообщение говорит, что n — это футура, которая вернёт u32, а не сам u32. Исправление — let n = get_number().await;. Если функция ничего не возвращает и ты её просто вызываешь, как save_file();, будет только предупреждение, так что читай предупреждения:
warning: unused implementer of `Future` that must be used |7 | save_file(); | ^^^^^^^^^^^ | = note: futures do nothing unless you `.await` or poll themПредупреждение (по-английски) напоминает: футуры ничего не делают, пока ты не сделаешь .await или не опросишь их.
Использовать std::thread::sleep внутри асинхронного кода. Это компилируется без проблем, и поэтому коварно:
async fn slow_task() { std::thread::sleep(std::time::Duration::from_secs(1)); // блокирует весь поток!}std::thread::sleep замораживает весь поток. Рантайм не может переключиться на другие задачи, поэтому всё в этом потоке останавливается — как официант, который уснул стоя у одного столика. В асинхронном коде всегда используй tokio::time::sleep(...).await. То же относится к другой медленной блокирующей работе, например к чтению огромных файлов через std::fs: у tokio есть асинхронные версии (tokio::fs), или можно воспользоваться tokio::task::spawn_blocking.
Ждать, что async ускорит работу процессора. Async помогает, когда задачи ждут. Он не помогает, когда задачи заняты — например, меняют размер картинок или перемалывают числа. Один официант не станет готовить быстрее, если примет больше заказов. Для тяжёлой работы процессора используй потоки (или крейт вроде rayon).
Async или потоки?
Заголовок раздела «Async или потоки?»| Используй async, когда… | Используй потоки, когда… |
|---|---|
| Большую часть времени программа ждёт (сеть, база данных, таймеры) | Работа — это тяжёлые вычисления |
| Нужны тысячи задач одновременно (как в веб-сервере) | Параллельно должно работать всего несколько вещей |
| Ты используешь асинхронные крейты вроде reqwest или axum | Не хочется добавлять зависимость от рантайма |
Выбирать что-то одно не обязательно. Многие реальные программы используют async для сети и потоки для тяжёлой работы.
Нужно, чтобы задачи передавали друг другу сообщения? У tokio есть свой канал, tokio::sync::mpsc. Он работает как стандартный со страницы Каналы, только ты пишешь rx.recv().await, и ожидание сообщения не блокирует поток.
Что на этом строят
Заголовок раздела «Что на этом строят»Большая часть асинхронной экосистемы Rust построена на tokio. Два крейта, которые ты будешь встречать повсюду:
- reqwest (на английском) — HTTP-клиент для обращения к веб-API. Чтобы загрузить страницу, пишешь
reqwest::get(url).await. - axum (на английском) — веб-фреймворк для серверов и API от команды tokio.
Оба отлично сочетаются с serde_json для чтения и записи JSON.
Практика
Заголовок раздела «Практика»1. Готовим завтрак одновременно
Заголовок раздела «1. Готовим завтрак одновременно»Напиши три асинхронные функции: make_toast (спит 30 мс), fry_eggs (спит 20 мс) и brew_coffee (спит 10 мс). Каждая возвращает String с названием блюда. Запусти все три одновременно и выведи, что готово. Подсказка: tokio::join! принимает несколько футур и возвращает кортеж их результатов.
Показать решение
use std::time::Duration;use tokio::time::sleep;
async fn make_toast() -> String { sleep(Duration::from_millis(30)).await; String::from("тосты")}
async fn fry_eggs() -> String { sleep(Duration::from_millis(20)).await; String::from("яичница")}
async fn brew_coffee() -> String { sleep(Duration::from_millis(10)).await; String::from("кофе")}
#[tokio::main]async fn main() { let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee()); println!("Завтрак: {}, {} и {}", toast, eggs, coffee);}Вывод:
Завтрак: тосты, яичница и кофеКофе готов первым, но кортеж всегда возвращается в том порядке, в каком ты перечислил футуры. Весь завтрак занимает около 30 мс (время самого медленного блюда), а не 60 мс.
2. Квадраты чисел в фоне
Заголовок раздела «2. Квадраты чисел в фоне»Напиши async fn square_later(n: u64) -> u64, которая спит 10 * n миллисекунд, а потом возвращает n * n. В main запусти по задаче для каждого числа от 1 до 4, потом дождись их всех и выведи сумму. Подсказка: складывай каждый дескриптор tokio::spawn(...) в Vec, потом пройди по дескрипторам циклом и суммируй handle.await.unwrap().
Показать решение
use std::time::Duration;use tokio::time::sleep;
async fn square_later(n: u64) -> u64 { sleep(Duration::from_millis(10 * n)).await; n * n}
#[tokio::main]async fn main() { let mut handles = Vec::new(); for n in 1..=4 { handles.push(tokio::spawn(square_later(n))); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("сумма = {total}");}Вывод:
сумма = 30Все четыре задачи стартуют сразу и спят одновременно, поэтому всё занимает около 40 мс, а не 10 + 20 + 30 + 40 = 100 мс.
- Async позволяет одному потоку обслуживать много задач, переключаясь между ними, пока они ждут.
- Для асинхронного кода Rust нужен рантайм. Добавь tokio командой
cargo add tokio --features fullи пометьmainатрибутом#[tokio::main]. async fnвозвращает футуру. Футуры ленивые: ничего не выполняется, пока ты не сделаешь.await.tokio::join!ждёт несколько футур сразу, аtokio::spawnзапускает задачи и возвращаетJoinHandle.- Никогда не используй
std::thread::sleepв асинхронном коде — используйtokio::time::sleep(...).await. - Async нужен для ожидания, а не для тяжёлой работы процессора. Для неё есть потоки.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Что произойдёт, если вызвать async fn без .await?
2. Что делает #[tokio::main]?
3. Две футуры спят 30 мс и 20 мс. Примерно сколько займёт tokio::join! для обеих?
4. Чем плох std::thread::sleep внутри асинхронного кода?
5. Для какой работы async подходит лучше всего?
Результат: 0 / 5
Что почитать
Заголовок раздела «Что почитать»- Fundamentals of Asynchronous Programming — The Rust Book (на английском): официальная глава о футурах,
async,.awaitи стримах (streams). В русском переводе книги этой главы пока нет. - Учебник tokio (на английском): практическое руководство, в котором строится маленький сервер в духе Redis, с задачами, общим состоянием и каналами.
- tokio на docs.rs (на английском): справочник по
tokio::spawn,tokio::time,tokio::syncи всему остальному. - Asynchronous Programming in Rust (async book) (на английском): более глубокий взгляд на то, как футуры и исполнители (executors) устроены внутри.