← Назад к списку тем

15. Асинхронное программирование

async/await, Future trait, executors, tokio runtime, pinning, async в трейтах, select!, cancellation.

Future — это просто трейт

В основе async в Rust лежит не магия языка, а обычный трейт из стандартной библиотеки. async fn — это синтаксический сахар, компилирующийся в структуру, реализующую Future, а тело функции превращается в конечный автомат (state machine) с состоянием на каждой точке .await.

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
    Ready(T),
    Pending,
}

Никакого рантайма внутри самого трейта нет. poll() либо возвращает готовый результат (Ready), либо сообщает "ещё не готово, разбуди меня позже" (Pending) — и в этом случае future обязан сохранить Waker из Context, чтобы кто-то потом вызвал poll снова.

🔑 Ключевое отличие от Go: горутина в Go стартует и выполняется сама по себе, как только вызван go f() — планировщик рантайма гарантированно её продвигает. Future в Rust — пассивный объект: он не выполняет ничего, пока кто-то явно не вызывает poll(). Async функция без исполнителя (executor) — это просто описание вычисления, лежащее в памяти мёртвым грузом.

Почему async без executor'а ничего не делает

Вызов async fn не запускает никакого кода — он создаёт значение типа impl Future, ленивое, как итератор. Пока никто не вызовет poll() (напрямую или через .await внутри уже запущенного future), тело функции не выполнится вообще, даже println! в начале.

async fn say_hello() {
    println!("Hello!");
}

fn main() {
    let future = say_hello();   // НИЧЕГО не напечаталось — future просто создан
    // future здесь и умирает, ничего не произошло
    // компилятор даже выдаст warning: unused `impl Future` that must be used
}

Executor (в tokio — Runtime) — это код, который берёт на себя вызов poll() в цикле, управление очередью готовых к пробуждению future, интеграцию с ОС (epoll/kqueue/IOCP) для сетевого и файлового I/O. Без него async fn — синтаксис без семантики выполнения.

#[tokio::main]
async fn main() {
    say_hello().await;   // теперь напечатает — tokio создал executor,
                        // который вызывает poll() до Ready
}
// #[tokio::main] раскрывается примерно в:
// fn main() {
//     tokio::runtime::Runtime::new().unwrap().block_on(async { say_hello().await; })
// }
Go goroutineRust Future
Запускgo f() стартует сразусоздание future ничего не запускает
Планировщиквстроен в рантайм языкавнешний крейт (tokio/async-std/smol)
Без рантайманевозможно — рантайм всегда естьasync-код компилируется и работает без него, просто не выполняется

tokio::spawn vs await

Внутри async-функции есть два принципиально разных способа "выполнить" другой future, и разница между ними — источник частых ошибок конкурентности.

#[tokio::main]
async fn main() {
    // await — последовательное выполнение внутри ТЕКУЩЕЙ задачи (task).
    // Пока fetch_a не завершится, fetch_b даже не начнётся.
    let a = fetch_a().await;
    let b = fetch_b().await;

    // tokio::spawn — создаёт НОВУЮ задачу, которую executor планирует
    // независимо (возможно на другом потоке пула). Похоже на go f() в Go.
    let handle_a = tokio::spawn(fetch_a());
    let handle_b = tokio::spawn(fetch_b());
    // Обе задачи выполняются параллельно, начиная с этой точки
    let (a, b) = (handle_a.await.unwrap(), handle_b.await.unwrap());
}
.awaittokio::spawn
Выполнениевнутри текущей task, последовательноновая независимая task
Параллелизмнет (конкурентность только через select!/join!)да, может выполняться на другом потоке
Требование 'staticнет, можно заимствоватьда — future должен быть 'static + Send
При паникепаникует вызывающая задачапаника изолирована в JoinHandle::Err

Для параллельного (не просто конкурентного) выполнения нескольких future в одной задаче без отдельного spawn используют tokio::join! — он опрашивает все future по очереди на каждом polling-цикле, не блокируясь на первом:

let (a, b) = tokio::join!(fetch_a(), fetch_b());
// Оба future продвигаются конкурентно в рамках одной task —
// не требует Send/'static, в отличие от spawn

Pinning — зачем нужен Pin<T>

Конечный автомат, в который компилятор превращает async fn, может содержать самоссылки — например, если одна локальная переменная хранит ссылку на другую локальную переменную того же future через границу .await. Такие структуры небезопасно перемещать в памяти: перемещение "оборвёт" внутреннюю ссылку. Pin<P> — обёртка над указателем, гарантирующая компилятору, что значение не будет перемещено после того, как оно закреплено (pinned).

async fn self_referential() {
    let data = String::from("hello");
    let data_ref = &data;      // ссылка на локальную переменную того же future
    some_async_call().await;   // точка приостановки — data_ref должен пережить её
    println!("{data_ref}");
}
// Компилятор генерирует state machine, где data и data_ref — оба поля
// одной структуры, а data_ref физически указывает "сам на себя"

На практике большинство кода не работает с Pin напрямую — .await делает это за вас. Pin всплывает явно при реализации Future вручную или при работе с trait objects (Pin<Box<dyn Future>>).

use std::pin::Pin;
use std::future::Future;

// Типичная сигнатура для хранения разнородных future в одной коллекции
fn boxed_future() -> Pin<Box<dyn Future<Output = i32>>> {
    Box::pin(async { 42 })
}
⚠️ unpin по умолчанию: большинство типов реализуют авто-трейт Unpin (можно свободно перемещать) — Pin становится проблемой только для async state machines с самоссылками. Ошибка компиляции "cannot be unpinned" почти всегда означает, что нужен Box::pin или макрос tokio::pin!.

Async в трейтах

Долгое время async fn в трейтах не поддерживался напрямую — с Rust 1.75 это работает "из коробки", но с нюансами, важными для API-дизайна.

// Rust 1.75+ — async fn в трейтах работает нативно
trait Repository {
    async fn find_user(&self, id: u64) -> Option<User>;
}

// Но: async fn в трейте не объект-безопасен (не dyn-совместим) напрямую!
// dyn Repository — ошибка компиляции без дополнительных мер

// Решение для dyn-совместимости — crate async-trait (проверенный способ)
#[async_trait::async_trait]
trait Repository {
    async fn find_user(&self, id: u64) -> Option<User>;
}
// Макрос переписывает возврат в Pin<Box<dyn Future + Send>> под капотом,
// платя за это одной аллокацией на каждый вызов
let repos: Vec<Box<dyn Repository>> = vec![Box::new(PgRepository), Box::new(MockRepository)];

Причина отсутствия dyn-совместимости — размер возвращаемого impl Future у каждой реализации разный (свой конечный автомат под каждую версию метода), а dyn Trait требует единого размера vtable-совместимого типа.

select! — гонка нескольких future

tokio::select! опрашивает несколько future одновременно и продолжает выполнение с той веткой, которая завершилась первой — остальные отменяются (dropped).

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

#[tokio::main]
async fn main() {
    let (tx, mut rx) = tokio::sync::mpsc::channel::<i32>(1);

    tokio::select! {
        msg = rx.recv() => {
            println!("получено: {:?}", msg);
        }
        _ = sleep(Duration::from_secs(5)) => {
            println!("таймаут — не дождались сообщения");
        }
    }
    // Незавершившаяся ветка (recv или sleep) отменяется через Drop её future
}

Типичный паттерн — реализация timeout поверх произвольной async-операции, или graceful shutdown, ожидающий либо сигнал остановки, либо завершение работы:

tokio::select! {
    _ = shutdown_signal.recv() => {
        println!("получен сигнал остановки");
    }
    result = run_server() => {
        println!("сервер завершился: {:?}", result);
    }
}

Cancellation через Drop

В Rust отмена async-задачи не требует специального механизма вроде CancellationToken или исключения CancelledError (как в Python asyncio) — она реализована через обычный Drop. Когда future перестаёт опрашиваться (например, проиграл в select!, или JoinHandle::abort() вызван), он просто дропается, и вся структура state machine рекурсивно освобождается.

async fn with_cleanup() {
    let _guard = ScopeGuard;  // Drop освободит ресурс, даже если future отменят
    long_operation().await;
}

struct ScopeGuard;
impl Drop for ScopeGuard {
    fn drop(&mut self) {
        println!("ресурс освобождён — сработает и при отмене");
    }
}

Это делает cancellation "бесплатной" с точки зрения корректности ресурсов — но опасной, если операция была на середине изменения внешнего состояния без транзакционности:

async fn risky_transfer(from: &Account, to: &Account, amount: u64) {
    from.withdraw(amount).await;  // если задачу отменят ПОСЛЕ этой строки,
    to.deposit(amount).await;    // но ДО этой — деньги пропадут!
}
// Каждая точка .await — потенциальная точка отмены.
// Для атомарности нужны транзакции БД, а не просто последовательный await.
🚫 Cancellation safety: любая точка .await — потенциальная точка, где future может быть дропнут снаружи и не продолжить выполнение. Пишите async-код так, чтобы отмена в произвольной точке не оставляла данные в противоречивом состоянии — либо через идемпотентные операции, либо через tokio::spawn + отсоединённую задачу для критических секций, которые нельзя прерывать.

tokio runtime: устройство и настройка

Tokio предоставляет два основных типа планировщика — стоит осознанно выбирать между ними в зависимости от профиля нагрузки.

// current_thread — один поток, весь async-код выполняется на нём.
// Подходит для CLI-утилит, тестов, минимального оверхеда.
#[tokio::main(flavor = "current_thread")]
async fn main() { /* ... */ }

// multi_thread — пул воркеров с work-stealing (по умолчанию для #[tokio::main]).
// Подходит для серверов с высокой конкурентностью I/O.
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() { /* ... */ }

// Ручное создание Runtime без макроса
fn main() {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(4)
        .enable_all()
        .build()
        .unwrap();
    rt.block_on(async_main());
}
⚠️ Блокирующий код в async — тихий убийца пропускной способности: вызов синхронной блокирующей операции (файловый I/O, тяжёлые вычисления, std::thread::sleep) напрямую внутри async fn блокирует весь worker-поток executor'а, не давая ему опрашивать другие задачи. Используйте tokio::task::spawn_blocking для переноса такого кода в отдельный пул потоков.
// Плохо — блокирует worker-поток tokio целиком
async fn bad() {
    std::thread::sleep(std::time::Duration::from_secs(1)); // блокирует executor!
}

// Хорошо — асинхронный sleep уступает выполнение другим задачам
async fn good() {
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}

// Для CPU-bound или блокирующего кода — выделенный пул потоков
let result = tokio::task::spawn_blocking(|| {
    expensive_computation()
}).await.unwrap();