Перейти к содержимому

Async/Await в Rust

Представь ресторан. На странице Многопоточность ты познакомился с потоками. Это как нанять по официанту на каждый столик. Работает, но официанты стоят дорого, а большую часть времени они просто стоят и ждут кухню.

Async (асинхронность) — это один официант, который принимает много заказов. Он берёт заказ у столика 1, передаёт его на кухню и, пока еда готовится, идёт к столику 2. Никто не стоит без дела. В этом и состоит идея async и .await в Rust: пока одна задача ждёт (сеть, файл, таймер), может работать другая.

Async раскрывается, когда программа много времени проводит в ожидании: веб-серверы, клиенты API, чаты, боты. На этой странице мы используем tokio — самый популярный асинхронный рантайм для Rust.

Стандартная библиотека Rust даёт ключевые слова async и .await, но не «двигатель», который на самом деле выполняет асинхронный код. Этот двигатель называется рантаймом (runtime), и большинство проектов используют tokio. Добавь его в новый проект:

Terminal window
cargo new async_demo
cd async_demo
cargo 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) — приготовится.

До сих пор мы ждали по одной вещи за раз. Настоящая сила в том, чтобы ждать несколько вещей одновременно. 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 мс — время самой медленной футуры. С настоящими сетевыми запросами эта разница быстро накапливается.

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, когда… Используй потоки, когда…
Большую часть времени программа ждёт (сеть, база данных, таймеры) Работа — это тяжёлые вычисления
Нужны тысячи задач одновременно (как в веб-сервере) Параллельно должно работать всего несколько вещей
Ты используешь асинхронные крейты вроде 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.

Напиши три асинхронные функции: 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 мс.

Напиши 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) устроены внутри.