Конкурентность в 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;