Конкурентность в Rust: потоки, горутины, async/await — полный разбор

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Конкурентность в Rust: от мьютексов до async/await

Когда речь заходит о высокопроизводительных системах, Rust мгновенно оказывается в центре внимания. Но что делает его по-настоящему уникальным? Это конкурентность в Rust — подход, при котором вы получаете скорость C и безопасность памяти, не жертвуя читаемостью кода. В этой статье мы разберём три главных инструмента: классические потоки, лёгкие горутины (в контексте Rust — асинхронные задачи) и современный async/await. Поехали!

Почему Rust не боится конкурентности?

В отличие от многих языков, Rust решает проблему гонок данных на уровне компилятора. Система владения (ownership) и заимствования (borrowing) гарантирует, что вы не сможете случайно изменить данные из двух потоков одновременно. Это не волшебство — это строгая статическая проверка. Именно поэтому многопоточность в Rust считается одной из самых безопасных.

1. Потоки (std::thread)

Классические потоки ОС — это основа. В Rust они реализованы через модуль std::thread. Каждый поток — это отдельный легковесный процесс, который может выполняться параллельно на разных ядрах CPU.

Пример: запуск потоков

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("Поток: {}", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("Главный поток: {}", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

Здесь thread::spawn создаёт новый поток, а join дожидается его завершения. Просто, но есть нюанс: потоки «тяжёлые». Каждый поток потребляет как минимум 2-8 МБ стековой памяти. Для тысяч одновременных задач это не подходит.

Когда использовать потоки?

  • Нужен настоящий параллелизм (CPU-bound задачи).
  • Количество потоков не превышает количество ядер * 2.
  • Вы работаете с блокирующими операциями (чтение файлов, синхронные вызовы).

2. Горутины в Rust: миф или реальность?

Строго говоря, в Rust нет горутин в том смысле, как в Go. Но есть асинхронные задачи (tasks), которые ведут себя почти так же: они легковесны, переключаются кооперативно (без участия ОС) и могут быть запущены тысячами на одном потоке. Для этого используется рантайм Tokio или async-std.

Пример: асинхронная задача (горутина) через Tokio

use tokio::task;

#[tokio::main]
async fn main() {
    let handle = task::spawn(async {
        for i in 1..10 {
            println!("Горутина: {}", i);
            tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
        }
    });

    for i in 1..5 {
        println!("Главный: {}", i);
        tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
    }

    handle.await.unwrap();
}

Обратите внимание: task::spawn создаёт лёгкую задачу, которая выполняется в рамках пула потоков Tokio. Затраты памяти — около 2-3 КБ на задачу. Это позволяет запускать 100 000 «горутин» без проблем. В отличие от Go, здесь нет встроенного планировщика — вы выбираете рантайм сами.

Чем горутины Rust отличаются от Go?

  • В Rust — задачи кооперативные: они сами отдают управление через .await.
  • В Go — горутины вытесняющие: планировщик сам прерывает их в точках ввода-вывода.
  • Rust даёт больше контроля: вы можете выбрать однопоточный или многопоточный рантайм.

3. Async/await: сердце асинхронного Rust

Ключевые слова async и .await превращают синхронный код в асинхронный без создания потоков. Функция, помеченная async, возвращает Future — ленивое значение, которое выполняется только при ожидании (.await).

Пример: async-функция

use tokio::time::{sleep, Duration};

async fn fetch_data(id: u32) -> String {
    sleep(Duration::from_millis(100)).await;
    format!("Данные для ID {}", id)
}

#[tokio::main]
async fn main() {
    let result = fetch_data(42).await;
    println!("{}", result);
}

Здесь fetch_data не блокирует поток — во время sleep рантайм переключится на другую задачу. Это и есть суть асинхронного программирования в Rust.

Комбинируем: несколько задач

use tokio::join;

async fn task1() -> &'static str { "Привет" }
async fn task2() -> &'static str { "Мир" }

#[tokio::main]
async fn main() {
    let (a, b) = join!(task1(), task2());
    println!("{} {}", a, b);
}

Макрос join! запускает обе задачи одновременно и ждёт обеих. Это похоже на Promise.all в JavaScript.

Сравнение подходов: потоки vs горутины vs async/await

Чтобы выбрать правильный инструмент, нужно понимать их сильные и слабые стороны. Сведём всё в таблицу (в текстовом формате):

  • Потоки (std::thread) — настоящий параллелизм, тяжёлые, подходят для CPU-bound. Оверхед: ~8 МБ на поток.
  • Горутины (async tasks) — кооперативная многозадачность, легковесные, до 100 000 на поток. Оверхед: ~2-3 КБ.
  • Async/await — синтаксический сахар для работы с Future. Не создаёт сущностей — только меняет способ выполнения.

На практике часто используют комбинацию: потоки для тяжёлых вычислений и async для I/O. Например, веб-сервер на Tokio принимает запросы асинхронно, а обработку изображений делегирует пулу потоков.

Опасные места: блокировка и Send

Самая частая ошибка новичков — вызов блокирующей функции внутри async кода. Например, std::thread::sleep заблокирует весь поток, остановив все задачи.

Правильно:

// Блокируем только текущую задачу
tokio::time::sleep(Duration::from_secs(1)).await;

Неправильно:

Блоги

Рекомендаций по книгам