Tokio vs goroutines: как мысленно переключаться между concurrency-подходами
Если параллельно пишешь на Go и Rust, рано или поздно ловишь себя на том, что путаешься в подходах к concurrency. В Go запустить горутину дешевле некуда: go func() — и забыл. В Rust на tokio запуск задачи выглядит похоже (tokio::spawn), но семантика и подводные камни сильно отличаются.
Поделюсь мыслительными переключателями, которые помогают не наломать дров, переходя из одного языка в другой в течение одного спринта.
Главное отличие: cooperative vs preemptive
В Go runtime сам решает, когда отнять CPU у горутины и переключить на другую. Это называется preemptive scheduling: даже если ты в цикле без явных suspend-точек, рантайм всё равно тебя прервёт через какое-то время.
В Rust tokio — кооперативный планировщик. Async-задача отдаёт управление только в точках .await. Если ты внутри async-функции крутишь долгий CPU-цикл без await, ты держишь поток у себя — соседние задачи не выполняются.
async fn bad() {
for _ in 0..1_000_000_000 {
let _ = some_heavy_computation();
}
} // блокирует runtime
На Go аналогичный код просто работает медленно, но не ломает соседей. На Rust — ломает. Это первое, во что упираются новички.
Лекарство: для CPU-bound кусков использовать tokio::task::spawn_blocking, который отправляет работу в отдельный пул потоков:
let result = tokio::task::spawn_blocking(|| {
heavy_computation()
}).await?;
Размер задачи и стоимость спавна
В Go горутина стартует со стеком 2 КБ, который растёт по мере необходимости. Запустить миллион горутин — нормально. Их даже специально запускают много, потому что они дешёвые.
В Rust tokio task — это state machine, размер фиксирован при компиляции, обычно сотни байт. Тоже дёшево, но семантика другая. Тебе нельзя просто «спавнить горутину для каждого Item», потому что часто это лишний слой абстракции — Rust async позволяет crунчить тысячи операций в одной задаче через futures::stream.
use futures::stream::{self, StreamExt};
let results: Vec<_> = stream::iter(items)
.map(|item| async move { process(item).await })
.buffer_unordered(50)
.collect()
.await;
Эквивалент Go — errgroup.WithContext + SetLimit(50). По сути одно и то же, но в Rust обычно ты не делаешь spawn на каждый элемент, а тащишь futures.
Send и Sync vs «всё пробрасываемое»
В Go ты просто кидаешь данные в горутину через замыкание или через канал. Тип значения runtime не интересует.
В Rust любая tokio::spawn требует, чтобы Future был Send + 'static. Это значит:
- Все данные внутри future должны быть
Send(можно передать между потоками). - Никаких ссылок на стек вызывающей функции — только owned-данные или
Arc.
let data = Arc::new(load_data());
for _ in 0..10 {
let data = Arc::clone(&data);
tokio::spawn(async move {
use_data(&data).await;
});
}
В Go ты бы написал просто go use_data(data). В Rust компилятор требует подтвердить, что данные переживут задачу. Это раздражает первое время, но защищает от целого класса бесшумных багов.
Каналы: похожи, но не одинаковы
Каналы Go (chan T) встроены в язык. У них один тип на все случаи: буферизированный, небуферизированный, можно closing.
В tokio каналы разные:
tokio::sync::mpsc— multi-producer, single-consumer.tokio::sync::oneshot— один send, один receive (для возврата результата).tokio::sync::broadcast— fan-out на нескольких подписчиков.tokio::sync::watch— последнее значение для всех читающих.
Каждый под свой кейс. Это полезно: в Go ты часто городишь паттерны (например, fan-out через select от нескольких каналов), а в tokio есть готовый тип. Минус — нужно знать, какой брать.
Эквивалент:
// Go: пайплайн через chan T
jobs := make(chan Job, 100)
results := make(chan Result, 100)
// Rust: tokio::mpsc
let (job_tx, mut job_rx) = tokio::sync::mpsc::channel::<Job>(100);
let (res_tx, mut res_rx) = tokio::sync::mpsc::channel::<Result>(100);
Семантика похожа, но в Rust канал владеет данными, и закрывается автоматически когда уходит последний sender (через Drop).
Cancel и timeout
В Go отмена контекста — это явный ctx.Done(), на который ты подписываешься в select. В tokio есть tokio::select!, который похож:
tokio::select! {
res = some_async_op() => { /* обработка */ }
_ = tokio::time::sleep(Duration::from_secs(5)) => { /* таймаут */ }
}
Но дополнительно tokio даёт tokio::time::timeout, который оборачивает любой Future:
match tokio::time::timeout(Duration::from_secs(5), some_async_op()).await {
Ok(res) => { /* успех */ },
Err(_) => { /* таймаут */ },
}
В Go аналог — context.WithTimeout и проброс ctx внутрь функции. В Rust ctx как явная сущность отсутствует, отмена идёт по тому, что Future дроппается. Если ты в середине some_async_op — она просто прекращает выполнение, любые .awaits больше не выполнятся.
Обработка panics
В Go панику в горутине обычно ловят через recover в defer. В tokio задача может запаниковать, и это не уронит весь процесс, но JoinHandle при .await вернёт Err(JoinError):
let handle = tokio::spawn(async {
panic!("oops");
});
match handle.await {
Err(e) if e.is_panic() => { /* fallback */ },
_ => {}
}
Идиома похожая, но синтаксис другой. На Go люди иногда забывают recover — горутина падает и роняет процесс. В Rust такого не случится, но если ты не .await handle, ты можешь не узнать о панике.
select: общий и разный
В Go select ловит ровно один canал, остальные пропускаются. В tokio tokio::select! делает то же самое, но с любыми futures, не только каналами.
tokio::select! {
msg = chan.recv() => { /* пришло сообщение */ }
res = http_call() => { /* http ответил */ }
_ = signal::ctrl_c() => { /* отмена */ }
}
Тонкость: tokio::select! при выборе одной ветки drop'ает все остальные futures. Если ты делал http_call — он отменяется, ничего не дочитается. В Go операции в неактивных кейсах продолжаются «как живут», select просто не получает их результат. Это разная семантика, и о ней легко забыть.
Когда какой подход лучше
Я не верю в догмы «всегда Rust» или «всегда Go». Что использую сам:
- Сервис с десятками I/O-bound интеграций, средний RPS, важна скорость разработки — Go.
- CPU-bound пайплайн, где важна пропускная способность и предсказуемая латентность — Rust.
- CLI-утилиты, embed в другой системе, статическая линковка — Rust.
- Прототипы, быстрая итерация, простые HTTP-сервисы — Go.
Полезные мнемоники для переключения
- В Rust async всегда явный (.await), в Go — невидимый.
- tokio::spawn ≈ go, но с требованиями Send + 'static.
- tokio::sync::mpsc ≈ chan, но с явным типом и автоматическим закрытием по Drop.
- tokio::select! похож на Go-select, но cancel'ит другие futures.
- В Rust для CPU-кода — spawn_blocking, иначе ломаешь runtime.
Главное, что я понял за этот год: не надо бороться с одним языком, применяя идиомы другого. Сначала пишешь идиоматично для конкретного языка, потом, когда хорошо знаешь оба, начинаешь видеть, где какой подход естественнее.