Конкурентность и многопоточность в 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(1));
}
});
for i in 1..5 {
println!("Главный поток: число {}", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap(); // Ожидаем завершения потока
}
Ключевые моменты:
thread::spawnпринимает замыкание, которое выполняется в новом потоке.- Метод
joinблокирует текущий поток до завершения указанного потока. - Без
joinглавный поток может завершиться раньше дочернего.
Для передачи данных между потоками используйте замыкания с move:
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
let handle = thread::spawn(move || {
println!("Данные из потока: {:?}", data);
});
handle.join().unwrap();
// println!("{:?}", data); // Ошибка! data уже перемещён
}
Ключевое слово move передаёт владение данными в замыкание, предотвращая гонки данных на этапе компиляции.
2. Безопасный обмен данными: мьютексы и блокировки
Когда несколько потоков обращаются к общим данным, возникает риск гонки данных. Rust решает эту проблему с помощью мьютексов (Mutex) и атомарных типов.
Пример использования 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
}
Важные детали:
Arc<T>позволяет нескольким потокам владеть одними данными (атомарное клонирование).Mutex::lock()возвращает блокировку; если блокировка уже занята, поток ждёт.- Блокировка автоматически снимается при выходе из области видимости.
Для более тонкого контроля используйте 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;
println!("Записали: {}", w);
}
3. Каналы для передачи сообщений
Альтернатива общей памяти — модель акторов с использованием каналов. Rust предоставляет многопроизводительный, однопотребительный канал (mpsc).
Базовый пример:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("Привет из потока!");
tx.send(val).unwrap();
// println!("{}", val); // Ошибка! val отправлен
});
let received = rx.recv().unwrap();
println!("Получено: {}", received);
}
Возможности каналов:
sendпередаёт владение данными; после отправки исходная переменная недоступна.recvблокирует поток до получения сообщения.try_recvне блокирует, возвращаетResult.
Множественные отправители (клон Sender):
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
thread::spawn(move || {
tx.send("Отправитель 1").unwrap();
});
thread::spawn(move || {
tx1.send("Отправитель 2").unwrap();
});
for received in rx {
println!("Получено: {}", received);
}
Каналы идеально подходят для архитектуры "производитель-потребитель" и снижают риск гонок данных.
4. Асинхронность: когда потоки не нужны
Для задач с большим количеством операций ввода-вывода (сетевые запросы, файлы) потоки могут быть избыточны. Rust поддерживает асинхронное программирование через трейты Future и экосистему async/await.
Пример с использованием tokio:
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let task1 = async_task("Задача 1");
let task2 = async_task("Задача 2");
// Параллельное выполнение
tokio::join!(task1, task2);
}
async fn async_task(name: &str) {
println!("{} началась", name);
sleep(Duration::from_secs(1)).await;
println!("{} завершена", name);
}
Отличия от потоков:
- Асинхронные задачи выполняются в одном потоке (или пуле потоков).
- Переключение контекста происходит только на точках
.await. - Меньше накладных расходов