В большинстве языков асинхронность встроена в платформу. В Go это горутины, в C# Task и планировщик из среды, в JavaScript цикл событий приходит вместе с браузером или Node.js. В Rust сделано иначе: синтаксис async и await живёт в языке, тип Future в стандартной библиотеке, а сам исполнитель задач, то есть runtime, в стандартную поставку не входит. Его приносят крейты, и к 2025 году выбор фактически сросся с Tokio, хотя альтернативы живы и иногда незаменимы. Ниже разбор того, что именно даёт стандартная библиотека, как устроен Tokio внутри, где его конкуренты и по каким правилам выбирать исполнителя под конкретный проект.

Что даёт стандартная библиотека Rust для асинхронности

Синтаксис async fn и оператор .await стабилизированы в Rust 1.39 в ноябре 2019 года. Вызов async fn немедленно возвращает Future, но не выполняет ни строчки тела: футуры в Rust ленивы, они двигаются только тогда, когда исполнитель их опрашивает или код доходит до .await. Функция без .await просто создаст значение и выбросит его при выходе из области видимости, и это первая ловушка новичка.

Стандартная библиотека даёт типаж Future, типы Poll и Context, механизм Waker и мелкие помощники вроде std::future::ready и pending. Исполнителя в std нет. Ни планировщика, ни реактора на сетевые события, ни асинхронных таймеров, ни файлового ввода-вывода. Причина не лень авторов, а разница задач: сервис на сервере, утилита на WASM и прошивка микроконтроллера хотят принципиально разных исполнителей, и язык отказывается навязывать один. Самый маленький рабочий вариант собирается из крейта futures:

[dependencies]
futures = "0.3"
fn main() {
    let fut = async { 40 + 2 };
    let answer = futures::executor::block_on(fut);
    println!("{}", answer);
}

Для утилиты этого хватает. Для сервиса с тысячами соединений нужен планировщик, реактор и таймеры, и вот тут начинается экосистема.

Как устроен Future внутри от poll и Pin до машины состояний

Future это типаж с одним методом poll, который получает закреплённое значение и контекст, а возвращает либо Poll::Ready с результатом, либо Poll::Pending с обещанием разбудить задачу через Waker. Компилятор превращает async fn в машину состояний: каждая точка .await становится состоянием, между запусками локальные переменные хранятся внутри структуры задачи. Отсюда Pin: машина состояний держит ссылки на собственные поля, перемещать его по памяти после начала опроса нельзя, и система закрепления гарантирует этого не делать. Ручной пример выглядит так:

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct Countdown {
    n: u32,
}

impl Future for Countdown {
    type Output = u32;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<u32> {
        if self.n == 0 {
            Poll::Ready(0)
        } else {
            self.n -= 1;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

#[tokio::main]
async fn main() {
    let result = Countdown { n: 3 }.await;
    println!("{}", result);
}

Ключевое отличие от потоков: .await не блокирует поток. Задача отдаёт управление исполнителю, тот переключается на другие футуры, один поток обслуживает тысячи задач, а переключение это возврат из poll, а не смена контекста операционной системы. Плата за экономию видна сразу: тело async fn компилируется в машину состояний, размеры растут, а отладчик показывает машину состояний вместо привычных кадров стека. Крупные машины состояний убирают в кучу через Box::pin, ведь машина с десятком локальных переменных на стеке невыгодна. Сотрудничество обязательно в обе стороны: poll должен возвращаться быстро, а Tokio выдал каждой задаче бюджет в 128 операций на один опрос, чтобы жадная футура не съела поток целиком.

Runtime Tokio и его планировщик с вытягиванием задач

Tokio самый популярный исполнитель, ветка 1.x идёт с конца 2020 года и хранит совместимость внутри мажорной линии. Многопоточный планировщик создаёт рабочие потоки по числу логических ядер, задачи складываются в локальные очереди, простаивающий поток вытягивает работу у соседей, то есть использует work stealing. Для тяжёлых и блокирующих операций вроде файлового ввода-вывода или криптографии существует отдельный пул блокирующих потоков с пределом по умолчанию 512. Подключение выглядит скромно:

[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_millis(200)).await;
        "фон готов"
    });
    let result = handle.await.unwrap();
    println!("{}", result);
}

Макрос #[tokio::main] разворачивается в построение runtime и блокирующий запуск main. То же самое вручную, без макроса:

fn main() {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap();
    rt.block_on(async {
        println!("runtime собран вручную");
    });
}

Tokio::spawn требует от футуры типаж Send, ведь задача может переехать между потоками. Задачам без Send служит LocalSet, он запускает всё в одном потоке, где требование Send снимается:

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let local = tokio::task::LocalSet::new();
    local.run_until(async {
        tokio::task::spawn_local(async {
            println!("задача без требования Send");
        }).await.unwrap();
    }).await;
}

Проблемный сосед это блокирующий вызов внутри async функции: обычный std::thread::sleep или тяжёлый цикл замораживает рабочего потока целиком, а захват std::sync::Mutex через точку .await часто ломает сборку, потому что охранник не пересекает await. Для честной работы с блокировками и долгими вычислениями предназначен spawn_blocking, который уводит замыкание в отдельный пул:

#[tokio::main]
async fn main() {
    let hash = tokio::task::spawn_blocking(|| {
        let mut h: u64 = 0xcbf29ce484222325;
        for byte in b"payload" {
            h = (h ^ *byte as u64).wrapping_mul(0x100000001b3);
        }
        h
    }).await.unwrap();
    println!("{}", hash);
}

Альтернативные исполнители от smol и glommio до embassy

Мир не сводится к Tokio. Лёгкий smol ставит несколько маленьких крейтов вместо одного большого и годится для утилит с малым числом зависимостей. Проект async-std, громко стартовавший в 2019 году, развитие фактически заморозил и ушёл в архив, и это живой урок того, как экосистема отбирает одного победителя. Для Linux с его io_uring сделаны glommio от Datadog и monoio: оба строятся по модели поток на ядро, где каждая задача привязана к потоку, локальные очереди не пересекаются и футуры не обязаны быть Send. На микроконтроллерах работает embassy: исполнитель без аллокатора и операционной системы, с собственными таймерами и драйверами для STM32, nRF и ESP32, там где Tokio просто не выживет. Задачи в embassy помечаются атрибутом #[embassy_executor::task], получают приоритеты планирования прямо в атрибуте и обязаны быть статическими, аллокаций в рантайме нет вовсе. Файл сборки встраиваемого проекта отличается от серверного только строкой зависимостей, сама модель async fn и .await не меняется. На WASM исполнители дают платформа и связка wasm-bindgen-futures, потому что цикл событий уже есть снаружи.

Зрелость экосистемы вокруг Tokio каналы таймеры и отладка

Аргумент за Tokio не в маркетинге, а в обвязке. Веб-фреймворк axum и HTTP-библиотека hyper, gRPC-инструмент tonic и HTTP-клиент reqwest построены поверх Tokio, виртуализация Firecracker из Amazon использует его для управления микровиртуальными машинами. Минимальный HTTP-сервис умещается в один экран:

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(|| async { "работает" }));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Набор инструментов закрывает типовые нужды: каналы mpsc, oneshot, broadcast и watch в модуле tokio::sync, таймеры и timeout в tokio::time, отмена через CancellationToken из крейта tokio-util, потоки значений через tokio_stream, инспекция подвисших задач в tokio-console. Пример канала и отмены по таймауту:

use tokio::sync::mpsc;
use tokio::time::{timeout, Duration};

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(16);
    tokio::spawn(async move {
        for i in 0..5 {
            tx.send(i).await.unwrap();
        }
    });
    while let Some(msg) = rx.recv().await {
        println!("получено {}", msg);
    }
    let slow = async {
        tokio::time::sleep(Duration::from_secs(5)).await;
        42
    };
    match timeout(Duration::from_secs(1), slow).await {
        Ok(v) => println!("{}", v),
        Err(_) => println!("не дождались"),
    }
}

Параллельный запуск нескольких футур собирается в один вызов, результаты ждутся вместе:

use tokio::join;

async fn fetch(id: u32) -> u32 {
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;
    id * 2
}

#[tokio::main]
async fn main() {
    let (a, b) = join!(fetch(1), fetch(2));
    println!("{} {}", a, b);
}

Конкуренция футур решается макросами join! и select!, причём select! по умолчанию опрашивает ветви в случайном порядке, а режим biased закрепляет порядок явно:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    tokio::select! {
        _ = sleep(Duration::from_secs(1)) => println!("долгий таймер"),
        _ = sleep(Duration::from_millis(300)) => println!("короткий сработал раньше"),
    }
}

Важная особенность модели: отмена задачи происходит простым сбросом футуры, и код между точками .await обязан оставаться в согласованном состоянии, иначе отмена найдёт мусор. Стандартные каналы из std::sync::mpsc внутри async кода ставить нельзя, они блокируют рабочий поток.

Async fn в трейтах со стабилизацией в Rust 1.75

Долгие годы асинхронность упиралась в типажи. Rust 1.75 в декабре 2023 года стабилизировал async fn в трейтах, и теперь метод может возвращать анонимный Future безо всяких крейтов:

trait Fetcher {
    async fn fetch(&self, key: &str) -> String;
}

struct Db;

impl Fetcher for Db {
    async fn fetch(&self, key: &str) -> String {
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        key.to_string()
    }
}

Ограничение осталось рядом: динамическая диспетчеризация через dyn Trait с async методом напрямую не работает, ведь размер возвращаемой машины состояний неизвестен. Крейт async-trait решает это перезаписью в Box<dyn Future>, ценой лишней аллокации. Вторая боль это границы Send: хранение футуры в структуре или возврат её из метода иногда требует явных сигнатур со сроками жизни, и сообщения компилятора тут честно сложны. Экосистема привычно берёт навыком: точки .await в горячем пути, структуры без длинных футур, async методы в трейтах только там, где без них нельзя.

Практический выбор runtime под разные типы проектов

Рабочая лестница решений коротка:

  1. сетевой сервис на сервере под Linux и macOS, многопоточный Tokio с фичами rt-multi-thread, macros, net, time, sync;
  2. однопоточная утилита или библиотека без параллелизма, текущий поток через new_current_thread или легковесный smol;
  3. экстремальный сетевой ввод-вывод на Linux с io_uring и чёткой привязкой задач к ядрам, glommio или monoio;
  4. микроконтроллер без операционной системы и без аллокатора, embassy поверх no_std;
  5. чистые вычисления на процессоре, обычные потоки или Rayon, асинхронность только на границах ожидания сети.

Правило разумного дефолта работает в обе стороны: брать Tokio по умолчанию разумно, потому что экосистема собрана вокруг него. Но менять исполнителя потом это переписывание точки входа, а не архитектуры, если код не тащил внутренности runtime в прикладную логику.

Стандартная библиотека Rust дала языку форму асинхронности, а экосистема дала ей ноги. За годы после стабилизации async и await ответ на вопрос о runtime перестал быть открытым: для сервисов это Tokio, для микроконтроллеров embassy, для экзотики нишевые исполнители. Незавершённость в трейтах и сложности с Send остаются, но они перекрываются зрелостью инструментов и предсказуемостью самих механизмов. Выбор исполнителя это первое архитектурное решение асинхронного проекта, и теперь для него есть понятная карта.