Вторая статья серии про Rust в backend. В первой разбирались, где Rust вообще уместен. Здесь — про то, без чего серьёзный сетевой сервис на Rust не живёт: асинхронный runtime. Разберём, как async устроен на уровне языка, что делает Tokio, чем эта модель отличается от goroutines и virtual threads, и где async помогает, а где только мешает.
Статья для тех, кто приходит из Go/Java-backend и пытается понять, зачем Rust вообще требует явный async runtime, — и для тех, кто уже прошёл ownership и borrow checker, видел async fn, но не до конца понимает, что под ним делают Future, poll, Waker и .await. Полный старт с нуля по языку тут не предполагается.
В статье
- Зачем backend-у async
- Future: poll, Waker, Pin
- Tokio: из чего состоит runtime
- Tokio vs goroutines vs virtual threads
- Практика: параллельные запросы и таймауты
- Подводные камни
- Когда async не нужен
Зачем backend-у async
Классическая модель — поток на соединение (thread-per-connection). Она проста и понятна, но у потока ОС есть цена: память под стек (обычно ~1–8 MB) и расходы планировщика на переключение контекста. На тысяче одновременных соединений это ещё терпимо, на десятках и сотнях тысяч — уже нет: память кончается, планировщик задыхается.
Async решает ту же задачу иначе. Вместо того чтобы блокировать поток на read(), который ждёт данных из сети, задача уступает управление и даёт потоку заняться другой работой. Один поток ОС обслуживает тысячи задач, переключаясь между ними в точках ожидания. Это идеально для I/O-bound нагрузки, где сервис большую часть времени ждёт сеть или диск, а не считает.
Ключевое: выигрыш async — именно в ожидании, а не в вычислениях. Если задача упирается в CPU, async не ускорит её ни на йоту (см. последний раздел).
Future: poll, Waker, Pin
В основе async в Rust лежит трейт Future — «значение, которое появится позже»:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
enum Poll<T> {
Ready(T),
Pending,
}Runtime опрашивает (poll) future. Тот либо возвращает Ready(value) — готов, либо Pending — ещё нет. Принципиальный момент: future, вернувший Pending, обязан сам «разбудить» runtime, когда продвинется. Для этого в Context лежит Waker — future регистрирует его в источнике события (например, в epoll по готовности сокета), и при готовности вызывается waker.wake(), после чего runtime снова опрашивает задачу. Никакого опроса в цикле «а вдруг готово» — только пробуждение по факту.
async fn и блоки async { } — это синтаксический сахар: компилятор разворачивает их в конечный автомат, реализующий Future. Каждое .await — точка, где автомат может вернуть Pending и приостановиться, сохранив состояние.
Отсюда же Pin. Сгенерированный автомат может ссылаться сам на себя (переменная, живущая через .await). Перемещение такого объекта в памяти сломало бы внутренние ссылки, поэтому future «прикалывают» (Pin) — гарантия, что он не переедет. В прикладном коде с Pin напрямую почти не сталкиваешься — это деталь, всплывающая при ручной реализации Future или работе с stream’ами.
в reactor (epoll)"] W -.->|"I/O готово → wake()"| P P -->|"Ready(value)"| D["задача завершена"]
flowchart LR
S["spawn задачи"] --> P["poll"]
P -->|"Pending"| W["регистрирует Waker
в reactor (epoll)"]
W -.->|"I/O готово → wake()"| P
P -->|"Ready(value)"| D["задача завершена"]
Tokio: из чего состоит runtime
Сам по себе язык умеет описывать future, но не исполнять их — нужен runtime. Tokio — самый зрелый. Внутри две главные части:
- Executor (scheduler) — опрашивает задачи (top-level future, созданные
tokio::spawn). В multi-thread режиме это пул потоков с work-stealing: простаивающий поток забирает задачи у загруженного. - Reactor — поверх
mio(epoll/kqueue/IOCP) следит за готовностью I/O и дёргает Waker’ы задач, которые этого ждут.
Два режима:
- multi-thread (по умолчанию для
#[tokio::main]) — пул рабочих потоков (обычно по числу ядер), work-stealing. Задачи вtokio::spawnдолжны бытьSend + 'static, потому что могут мигрировать между потоками. - current-thread — один поток, без work-stealing.
tokio::spawnвсё равно требуетSend;!Send-задачи запускают черезLocalSet/spawn_local. Удобно для CLI, тестов, встраивания.
// Multi-thread (по умолчанию)
#[tokio::main]
async fn main() {
// ...
}
// Current-thread — один поток
#[tokio::main(flavor = "current_thread")]
async fn main() {
// ...
}Tokio vs goroutines vs virtual threads
Async в Rust часто сравнивают с goroutines (Go) и virtual threads (Java, Project Loom). Решают одну задачу — дешёвая конкурентность, — но по-разному:
| Rust / Tokio | Go goroutines | Java virtual threads | |
|---|---|---|---|
| Модель | Stackless-корутины (async/await) |
Stackful green threads | Stackful, планируются JVM |
| Планирование | Кооперативное (yield в .await) |
Преемптивное (с Go 1.14) | JVM; блокирующий вызов освобождает carrier |
| Runtime-цена | Нет GC, минимум | GC, растущие стеки | JVM + GC |
| Блокирующий вызов | Блокирует поток executor → нужен spawn_blocking |
Runtime увезёт горутину, другие работают | Обычно освобождает carrier; кроме pinning (synchronized, native) |
| «Окрашивание» функций | Да (async vs sync) |
Нет | Нет |
| Память на задачу | Очень мало | ~несколько КБ | Мало |
| Готовность | Явные async-библиотеки | Встроено | Стандарт с JDK 21 |
Главная боль Rust здесь — function coloring: async-функцию нельзя вызвать из обычной без runtime, и наоборот, синхронный блокирующий вызов внутри async всё ломает. В Go и Java этого разделения нет — там «просто пишешь код», а runtime разбирается. Цена этого комфорта — GC и менее предсказуемый runtime, ровно тот компромисс, о котором шла речь в первой статье.
Практика: параллельные запросы и таймауты
Самый частый выигрыш async на практике — запустить много I/O одновременно. Вот параллельные HTTP-запросы (через reqwest) с таймаутом на каждый:
use std::time::Duration;
use tokio::time::timeout;
async fn fetch(client: &reqwest::Client, url: &str) -> anyhow::Result<usize> {
// Таймаут на отдельную операцию: future отменяется, если не успел.
let resp = timeout(Duration::from_secs(2), client.get(url).send()).await??;
let body = resp.text().await?;
Ok(body.len())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let client = reqwest::Client::new();
let urls = ["https://example.com", "https://rust-lang.org"];
// join! опрашивает все future конкурентно в одной задаче.
let (a, b) = tokio::join!(fetch(&client, urls[0]), fetch(&client, urls[1]));
println!("{:?} {:?}", a, b);
Ok(())
}Зависимости для примера — чтобы его можно было собрать cargo run:
[dependencies]
# tokio: macros — #[tokio::main]; rt-multi-thread — пул потоков; time — timeout/sleep
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
reqwest = "0.12"
anyhow = "1"tokio::join! гоняет несколько future конкурентно в одной задаче (не создавая потоков) — как и futures::future::join_all для коллекции future. Если же нужна настоящая параллельность по ядрам и независимый запуск задач — их надо явно раскидать по executor’у: tokio::spawn на каждую и join хендлов, либо JoinSet для динамической коллекции.
Собираемый и запускаемый вариант всех сюжетов этой статьи — конкурентные операции с таймаутом, spawn_blocking и backpressure через ограниченный канал — лежит в digital-cookbook, rust/tokio/: оффлайн-демо (cargo run, без сети) плюс сетевой пример на reqwest из врезки выше.
Подводные камни
- Блокировка в async. Тяжёлый синхронный вызов (CPU-работа,
std::fs,std::thread::sleep) внутри async занимает поток executor и тормозит все задачи на нём. Решение —tokio::task::spawn_blockingдля блокирующего кода и асинхронные аналоги (tokio::fs,tokio::time::sleep) вместо синхронных.
let hash = tokio::task::spawn_blocking(move || {
expensive_cpu_hash(&data) // не блокирует async-планировщик
}).await?;-
Cancel safety. Future в Rust отменяется простым drop’ом — например, проигравшая ветка
select!или истёкшийtimeout. Если future бросили посреди.await, частичное состояние теряется. Операция «прочитал половину сообщения» при отмене может оставить буфер в неконсистентном виде — не каждую операцию безопасно отменять в произвольной точке. Это надо держать в голове, особенно сselect!. -
Backpressure. Если producer быстрее consumer’а, неограниченная очередь растёт до OOM. В async это лечится ограниченными каналами:
tokio::sync::mpsc::channel(N)— отправка.await‘ит, когда буфер полон, естественным образом притормаживая producer’а. Неограниченныйunbounded_channel— частый источник утечек памяти под нагрузкой.
Когда async не нужен
Async — не «по умолчанию лучше». Он оправдан на I/O-bound нагрузке с высокой конкурентностью. Не стоит его тащить, когда:
- Задача CPU-bound. Перемалывание данных, кодеки, вычисления — async не ускорит (выигрыш только в ожидании). Здесь — обычные потоки или
rayonдля data-parallelism. - Сервис простой и конкурентность низкая. CLI, утилита, сервис на десяток-другой соединений: синхронный код + пул потоков проще, читается легче и не платит налог function coloring.
- Смешанная нагрузка. Часто правильно совмещать: async для сети +
spawn_blocking/rayonдля тяжёлых кусков.
Практическое правило: берёшь async, когда упираешься в число одновременных ожиданий, а не в скорость вычислений. Если сомневаешься — начни синхронно и померяй; преждевременный async добавляет сложность раньше, чем задача до него дорастает.
В следующей статье серии — веб-фреймворки на Rust: как поверх Tokio строятся Axum и actix-web.
Комментарии