Rust async runtime: Tokio изнутри

Как устроен async runtime в Rust на примере Tokio: futures, waker, event loop. Чем отличается от goroutines в Go и virtual threads в Java. Когда async даёт выигрыш, а когда усложняет без пользы

Вторая статья серии про 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

Классическая модель — поток на соединение (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’ами.

flowchart LR S["spawn задачи"] --> P["poll"] P -->|"Pending"| W["регистрирует Waker
в 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["задача завершена"]
Жизненный цикл задачи: poll → Pending (регистрация Waker) → пробуждение по готовности I/O → повторный poll → Ready

Tokio: из чего состоит runtime

Сам по себе язык умеет описывать future, но не исполнять их — нужен runtime. Tokio — самый зрелый. Внутри две главные части:

  • Executor (scheduler) — опрашивает задачи (top-level future, созданные tokio::spawn). В multi-thread режиме это пул потоков с work-stealing: простаивающий поток забирает задачи у загруженного.
  • Reactor — поверх mio (epoll/kqueue/IOCP) следит за готовностью I/O и дёргает Waker’ы задач, которые этого ждут.

Устройство runtime: executor опрашивает задачи-future в цикле, на .await они уступают потокам-исполнителям с work-stealing, а reactor по готовности 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.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии