TokioMiddleTechnical
В чём разница между tokio::spawn и обычным синхронным потоком?
tokio::spawn создаёт лёгкую async-задачу (~килобайт памяти, кооперативное планирование), а std::thread::spawn — OS-поток (~8 МБ стека, вытесняющее планирование). Тысячи spawn-задач мультиплексируются на фиксированный пул рабочих потоков.
OS-поток vs Tokio task: фундаментальные различия
| Характеристика | std::thread::spawn | tokio::spawn |
|---|---|---|
| Планировщик | ОС (вытесняющее) | Tokio executor (кооперативное) |
| Стек | ~8 МБ (настраивается) | нет отдельного стека; future хранит состояние в куче |
| Переключение контекста | syscall + сохранение регистров | обычный вызов функции poll() |
| Стоимость создания | ~10–100 мкс, syscall | ~наносекунды, аллокация в куче |
| Масштабирование | тысячи потоков — проблема | миллионы задач — норма |
| Блокировка | блокирует только свой поток | блокирует весь рабочий поток (критический баг) |
| Возврат результата | JoinHandle<T> | JoinHandle<T> (такой же интерфейс) |
Как работает tokio::spawn
Вызов tokio::spawn(future):
- Аллоцирует
Taskв куче — содержит future, Waker и счётчик ссылок. - Помещает задачу в run-queue одного из рабочих потоков (обычно того, на котором вызван spawn).
- Возвращает
JoinHandle<T>— futures, через которую можно дождаться результата.
Рабочий поток в цикле вызывает task.poll(). Если future возвращает Pending, поток переходит к следующей задаче — без блокировки ОС.
Практический пример
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// 10 000 async-задач на фиксированном пуле потоков
let handles: Vec<_> = (0..10_000)
.map(|i| {
tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
i * 2
})
})
.collect();
for h in handles {
let _ = h.await.unwrap();
}
println!("10 000 tasks done");
}
Этот код работает на 4–8 рабочих потоках. Аналог с std::thread::spawn создал бы 10 000 OS-потоков (~80 ГБ стека суммарно) и упал бы.
Когда использовать std::thread::spawn
- CPU-интенсивные вычисления (шифрование, сжатие, парсинг большого JSON) — не должны блокировать рабочий поток.
- Вызов синхронных blocking API (старые библиотеки без async-поддержки).
- Для таких случаев в Tokio есть
tokio::task::spawn_blocking— запускает closure на выделенном blocking thread pool, не занимая async workers.
// Правильно: тяжёлая синхронная работа
let result = tokio::task::spawn_blocking(|| {
heavy_cpu_computation()
}).await.unwrap();
// Неправильно: блокирует рабочий поток Tokio
let result = std::fs::read_to_string("big_file.txt").unwrap(); // никогда так в async-коде
Подводные камни
- Блокировка в async-коде. Любой блокирующий вызов внутри
tokio::spawn(sync I/O,std::thread::sleep, тяжёлые вычисления) блокирует рабочий поток целиком. При 4 workers и 4 блокирующих задачах весь runtime зависает. - Потеря паник. Если task паникует, паника не всплывает в вызывающий код.
JoinHandle::awaitвернётErr(JoinError). Дропнутый handle «проглатывает» панику молча. - Задачи не привязаны к потоку. В отличие от OS-потоков, Tokio task может переезжать между рабочими потоками между вызовами poll. Не используйте thread-local storage внутри async задач.
- Отмена — это drop. Дропнутый JoinHandle не останавливает задачу. Для отмены используйте
handle.abort()илиCancellationTokenиз tokio-util. - Нет вытесняющего планирования. Задача, которая не вызывает
await(бесконечный цикл), монополизирует поток. В критичных случаях вставляйтеtokio::task::yield_now().await. - Размер future при spawn. Future должна быть
Send + 'static. Захват не-Send типов (Rc, RefCell) или локальных ссылок приведёт к ошибке компиляции. Планируйте lifetime заранее.
Common mistakes
- Отвечать определением без production-сценария.
- Не называть runtime boundary, security boundary или failure mode.
- Игнорировать версию API, observability и тестовую проверку.
What the interviewer is testing
- Объясняет механизм своими словами и без выдуманных API.
- Называет реальные риски, диагностику и критерий корректности.
- Связывает ответ с текущей документацией и миграционными ограничениями.