1. Почему async в Rust устроен иначе 🟢
Что вы узнаете:
- Почему в Rust нет встроенного асинхронного рантайма (и что это значит для вас)
- Три ключевых свойства: ленивое выполнение, отсутствие рантайма, абстракции с нулевой стоимостью
- Когда async — правильный инструмент (а когда он медленнее)
- Как модель Rust соотносится с C#, Go, Python и JavaScript
Фундаментальное отличие
Большинство языков с async/await скрывают внутренний механизм. В C# есть пул потоков CLR. В JavaScript есть цикл событий (event loop). В Go есть горутины и планировщик, встроенный в рантайм. В Python есть asyncio.
В Rust ничего этого нет.
Нет встроенного рантайма, нет пула потоков, нет цикла событий. Ключевое слово async — это стратегия компиляции с нулевой стоимостью: она превращает вашу функцию в конечный автомат, реализующий трейт Future. Кто-то другой (исполнитель, executor) должен продвигать этот автомат вперёд.
Три ключевых свойства async в Rust
graph LR
subgraph "C# / JS / Go"
EAGER["Жадное выполнение<br/>Задача стартует сразу"]
BUILTIN["Встроенный рантайм<br/>Пул потоков в комплекте"]
GC["Управляется GC<br/>Никаких забот о временах жизни"]
end
subgraph "Rust (и Python*)"
LAZY["Ленивое выполнение<br/>Ничего не происходит, пока не вызван poll/await"]
BYOB["Свой рантайм<br/>Исполнитель выбираете вы"]
OWNED["Действует владение<br/>Важны времена жизни, Send, Sync"]
end
EAGER -. "противоположность" .-> LAZY
BUILTIN -. "противоположность" .-> BYOB
GC -. "противоположность" .-> OWNED
style LAZY fill:#e8f5e8,color:#000
style BYOB fill:#e8f5e8,color:#000
style OWNED fill:#e8f5e8,color:#000
style EAGER fill:#e3f2fd,color:#000
style BUILTIN fill:#e3f2fd,color:#000
style GC fill:#e3f2fd,color:#000
* Корутины Python, как и фьючи Rust, ленивые — они не выполняются, пока их не
await-ят или не запланируют. Однако Python по-прежнему использует GC и не знает о владении и временах жизни.
Нет встроенного рантайма
// Это компилируется, но НИЧЕГО не делает:
async fn fetch_data() -> String {
"hello".to_string()
}
fn main() {
let future = fetch_data(); // Создаёт Future, но не выполняет его
// future — это просто структура на стеке
// Никакого вывода, никаких побочных эффектов, ничего не происходит
drop(future); // Молча уничтожена — работа так и не начиналась
}
Сравните с C#, где Task стартует сразу:
// C# — это немедленно начинает выполняться:
async Task<string> FetchData() => "hello";
var task = FetchData(); // Уже выполняется!
var result = await task; // Только ждём завершения
Ленивые фьючи против жадных задач
Это самая важная смена мышления:
| C# / JavaScript | Python | Go | Rust | |
|---|---|---|---|---|
| Создание | Task начинает выполняться сразу | Корутина ленивая — возвращает объект, не выполняется, пока её не await-ят или не запланируют | Горутина стартует сразу | Future ничего не делает, пока его не опросили (polled) |
| Уничтожение | Отсоединённая задача продолжает работать | Невызванная корутина собирается GC (с предупреждением) | Горутина работает до возврата | Уничтожение Future отменяет его |
| Рантайм | Встроен в язык / VM | Цикл событий asyncio (нужно запускать явно) | Встроен в бинарник (планировщик M:N) | Выбираете вы (tokio, smol и др.) |
| Планирование | Автоматическое (пул потоков) | Цикл событий + await или create_task() | Автоматическое (планировщик GMP) | Явное (spawn, block_on) |
| Отмена | CancellationToken (кооперативная) | Task.cancel() (кооперативная, выбрасывает CancelledError) | context.Context (кооперативная) | Уничтожить фьючу (мгновенно) |
// Чтобы РЕАЛЬНО выполнить фьючу, нужен исполнитель:
#[tokio::main]
async fn main() {
let result = fetch_data().await; // ТЕПЕРЬ оно выполняется
println!("{result}");
}
Когда использовать async (и когда не стоит)
graph TD
START["Какой характер работы?"]
IO["Ограничено вводом-выводом?<br/>(сеть, файлы, БД)"]
CPU["Ограничено процессором?<br/>(вычисления, парсинг)"]
MANY["Много одновременных соединений?<br/>(100+)"]
FEW["Мало одновременных задач?<br/>(<10)"]
USE_ASYNC["✅ Используйте async/await"]
USE_THREADS["✅ Используйте std::thread или rayon"]
USE_SPAWN_BLOCKING["✅ Используйте spawn_blocking()"]
MAYBE_SYNC["Рассмотрите синхронный код<br/>(проще, меньше накладных расходов)"]
START -->|Сеть, файлы, БД| IO
START -->|Вычисления| CPU
IO -->|Да, много| MANY
IO -->|Всего несколько| FEW
MANY --> USE_ASYNC
FEW --> MAYBE_SYNC
CPU -->|Распараллелить| USE_THREADS
CPU -->|Внутри async-контекста| USE_SPAWN_BLOCKING
style USE_ASYNC fill:#c8e6c9,color:#000
style USE_THREADS fill:#c8e6c9,color:#000
style USE_SPAWN_BLOCKING fill:#c8e6c9,color:#000
style MAYBE_SYNC fill:#fff3e0,color:#000
Практическое правило: async нужен для конкурентности ввода-вывода (делать много дел одновременно, пока ждём), а не для параллелизма вычислений (ускорение одной операции). Если у вас 10 000 сетевых соединений, async проявляет себя во всей красе. Если вы считаете числа, используйте rayon или потоки ОС.
Когда async может быть медленнее
Async не бесплатен. При низкой конкурентности синхронный код может работать быстрее async:
| Издержка | Почему |
|---|---|
| Накладные расходы конечного автомата | Каждый .await добавляет вариант enum; глубоко вложенные фьючи порождают большие и сложные автоматы |
| Динамическая диспетчеризация | Box<dyn Future> добавляет косвенность и мешает инлайнингу |
| Переключение контекста | Кооперативное планирование тоже стоит ресурсов — исполнителю нужно управлять очередью задач, wakers и регистрациями I/O |
| Время компиляции | Async-код порождает более сложные типы и замедляет компиляцию |
| Отладка | Трассировки стека через конечные автоматы труднее читать (см. гл. 12) |
Рекомендация по бенчмаркам: если одновременных операций ввода-вывода меньше ~10, сначала профилируйте, и только потом переходите на async. Простой std::thread::spawn на соединение хорошо масштабируется до сотен потоков на современном Linux.
Упражнение: когда использовать async?
🏋️ Упражнение (нажмите, чтобы раскрыть)
Для каждого сценария решите, уместен ли async, и объясните почему:
- Веб-сервер, обрабатывающий 10 000 одновременных WebSocket-соединений
- CLI-утилита, которая сжимает один большой файл
- Сервис, который опрашивает 5 разных баз данных и объединяет результаты
- Игровой движок, который выполняет физическую симуляцию с 60 FPS
🔑 Решение
- Async — ограничено вводом-выводом, огромная конкурентность. Каждое соединение большую часть времени ждёт данных. Для потоков понадобилось бы 10 000 стеков.
- Синхронно / потоки — ограничено процессором, одна задача. Async добавляет накладные расходы без пользы. Для параллельного сжатия используйте
rayon. - Async — пять одновременных ожиданий I/O.
tokio::join!выполняет все пять запросов одновременно. - Синхронно / потоки — ограничено процессором, чувствительно к задержкам. Кооперативное планирование async может вызвать джиттер кадров.
Ключевые выводы — почему async устроен иначе
- Фьючи в Rust ленивые — они ничего не делают, пока исполнитель их не опросит
- Встроенного рантайма нет — вы выбираете (или создаёте) свой
- Async — это стратегия компиляции с нулевой стоимостью, которая порождает конечные автоматы
- Async проявляет себя в конкурентности ввода-вывода; для вычислений используйте потоки или rayon
См. также: Гл. 2 — Трейт Future — трейт, на котором всё это держится, Гл. 7 — Исполнители и рантаймы — как выбрать рантайм