В основе 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 f() — планировщик рантайма гарантированно её продвигает. Future в Rust — пассивный объект: он не выполняет ничего, пока кто-то явно не вызывает poll(). 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 goroutine | Rust Future | |
|---|---|---|
| Запуск | go f() стартует сразу | создание future ничего не запускает |
| Планировщик | встроен в рантайм языка | внешний крейт (tokio/async-std/smol) |
| Без рантайма | невозможно — рантайм всегда есть | async-код компилируется и работает без него, просто не выполняется |
Внутри 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());
}
| .await | tokio::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
Конечный автомат, в который компилятор превращает 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 (можно свободно перемещать) — Pin становится проблемой только для async state machines с самоссылками. Ошибка компиляции "cannot be unpinned" почти всегда означает, что нужен Box::pin или макрос tokio::pin!.
Долгое время 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-совместимого типа.
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);
}
}
В 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.
.await — потенциальная точка, где future может быть дропнут снаружи и не продолжить выполнение. Пишите async-код так, чтобы отмена в произвольной точке не оставляла данные в противоречивом состоянии — либо через идемпотентные операции, либо через tokio::spawn + отсоединённую задачу для критических секций, которые нельзя прерывать.
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());
}
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();