lenec ru

← все посты

Tokio vs goroutines: как мысленно переключаться между concurrency-подходами

19K

Если параллельно пишешь на 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.

Главное, что я понял за этот год: не надо бороться с одним языком, применяя идиомы другого. Сначала пишешь идиоматично для конкретного языка, потом, когда хорошо знаешь оба, начинаешь видеть, где какой подход естественнее.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.