Конкурентность и многопоточность в Rust: от А до Я

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

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

Начать курс

Конкурентность и многопоточность в 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.
  • Меньше накладных расходов

Блоги

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