Конкурентность и многопоточность в Rust: пишем безопасный и быстрый код
Современные программы редко работают в однопоточном режиме. Обработка запросов, работа с файлами, сетевые операции — всё это требует параллельного выполнения задач. Rust предлагает уникальный подход к конкурентности: он гарантирует безопасность памяти на этапе компиляции, предотвращая гонки данных. В этой статье мы разберём, как работать с потоками, каналами, мьютексами и асинхронностью в Rust.
1. Основы многопоточности: создание и управление потоками
В Rust потоки создаются через стандартную библиотеку std::thread. Каждый поток выполняется независимо, но компилятор строго следит за временем жизни переменных.
Создание потока
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(100));
}
});
for i in 1..5 {
println!("Главный поток: {}", i);
thread::sleep(Duration::from_millis(100));
}
handle.join().unwrap(); // Ожидаем завершения потока
}
Захват владения в замыканиях
Если поток использует переменные из внешней области, нужно передать владение через move:
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Данные: {:?}", data);
});
handle.join().unwrap();
// println!("{:?}", data); // Ошибка! data перемещена
2. Обмен данными: мьютексы и блокировки
Для безопасного доступа к общим данным из нескольких потоков Rust использует Mutex (мьютекс) в связке с Arc (атомарный счётчик ссылок).
Пример с Mutex и Arc
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Результат: {}", *counter.lock().unwrap()); // 10
}
Важно: Mutex автоматически разблокируется при выходе из области видимости. Это предотвращает deadlock (взаимную блокировку) на уровне компиляции.
RwLock — для частого чтения
Если данные редко изменяются, но часто читаются, используйте RwLock:
use std::sync::RwLock;
let lock = RwLock::new(5);
{
let r1 = lock.read().unwrap();
let r2 = lock.read().unwrap(); // Можно одновременно читать
println!("{} {}", r1, r2);
}
{
let mut w = lock.write().unwrap();
*w += 1; // Запись блокирует чтение
}
3. Каналы: передача сообщений между потоками
Rust поддерживает модель акторов через каналы (std::sync::mpsc). Это позволяет потокам обмениваться данными без разделяемой памяти.
Пример с каналом
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let vals = vec!["hello", "from", "the", "thread"];
for val in vals {
tx.send(val).unwrap();
thread::sleep(std::time::Duration::from_secs(1));
}
});
for received in rx {
println!("Получено: {}", received);
}
}
Множество отправителей
С помощью mpsc::Sender можно клонировать отправителя для нескольких потоков:
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
thread::spawn(move || { tx1.send("Поток 1").unwrap(); });
thread::spawn(move || { tx.send("Поток 2").unwrap(); });
for msg in rx {
println!("{}", msg);
}
4. Асинхронное программирование: async/await и Tokio
Для задач ввода-вывода (сеть, файлы) эффективнее использовать асинхронность. Rust поддерживает async/await на уровне языка, а популярный фреймворк Tokio предоставляет runtime.
Базовый пример с Tokio
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let task1 = async {
sleep(Duration::from_secs(2)).await;
println!("Задача 1 завершена");
};
let task2 = async {
sleep(Duration::from_secs(1)).await;
println!("Задача 2 завершена");
};
tokio::join!(task1, task2); // Обе выполняются параллельно
}
Асинхронные каналы
Tokio предоставляет собственные каналы для асинхронных потоков:
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(32);
tokio::spawn(async move {
tx.send("Асинхронное сообщение").await.unwrap();
});
if let Some(msg) = rx.recv().await {
println!("{}", msg);
}
}
Когда использовать async, а когда потоки?
- Потоки — для CPU-интенсивных задач (расчёты, рендеринг).
- Async — для I/O-операций (HTTP-запросы, чтение файлов).
- Можно комбинировать:
tokio::task::spawn_blockingдля тяжёлых вычислений в асинхронном коде.
Заключение
Rust предлагает мощные и безопасные инструменты для параллельной работы:
- Потоки — для классической многопоточности с контролем владения.
- Mutex и RwLock — для разделяемого доступа без гонок данных.
- Каналы — для передачи сообщений между