← Назад к списку тем

13. Конкурентность: основы

std::thread, message passing через channels, Mutex/RwLock, Arc>, Send/Sync, deadlocks.

std::thread — системные потоки

В отличие от Go, где горутина — лёгкая (несколько КБ стека, планируется рантаймом), std::thread::spawn в Rust создаёт настоящий поток ОС (по умолчанию 2-8 МБ стека, планируется ядром). Это делает Rust-потоки "тяжелее" горутин, но убирает необходимость в собственном планировщике для синхронного кода.

use std::thread;

let handle = thread::spawn(|| {
    for i in 1..5 {
        println!("поток: {i}");
    }
});

// join() блокирует текущий поток до завершения дочернего
let result = handle.join();  // Result<(), Box<dyn Any + Send>>
if result.is_err() {
    println!("дочерний поток запаниковал");
}

Замыкание, переданное в spawn, обязано быть 'static — оно не может заимствовать данные из внешнего стека, потому что порождающий поток может завершиться раньше дочернего. Это заставляет использовать move для передачи владения:

let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
    println!("{:?}", data);   // data перемещена в поток
});
handle.join().unwrap();
// data здесь больше недоступна
🔑 Ключевое: компилятор не даст скомпилировать поток, который заимствует данные с недостаточным временем жизни — в отличие от Go, где горутина, захватившая указатель на локальную переменную, скомпилируется и создаст потенциальный data race, обнаружимый только в runtime (или race detector'ом).

Send и Sync — фундамент безопасности

Многопоточная безопасность Rust держится на двух маркерных трейтах, которые компилятор проверяет автоматически. Они не содержат методов — это просто "метки" в системе типов.

  • Send — тип можно безопасно переместить в другой поток (владение передаётся)
  • Sync — тип можно безопасно разделять между потоками через &T (эквивалентно &T: Send)
// Оба трейта — auto traits: выводятся автоматически из полей типа
// Rc<T> НЕ Send и НЕ Sync — счётчик не атомарный
// Arc<T> — Send + Sync, если T: Send + Sync
// RefCell<T> — Send, но НЕ Sync (borrow-flag не атомарный)
// Mutex<T> — Send + Sync, если T: Send (даже если T не Sync!)

fn assert_send<T: Send>() {}
assert_send::<std::sync::Arc<i32>>();  // компилируется
// assert_send::<std::rc::Rc<i32>>();     // ошибка компиляции!
✅ Главное отличие от Go: data race по указателям в Rust — ошибка компиляции, а не runtime-баг. Компилятор статически отслеживает, какие типы безопасно пересекают границы потоков, через систему Send/Sync, встроенную в сигнатуры thread::spawn.

Message passing — каналы

Идиома Rust (унаследованная от Go/CSP): "не общайтесь через разделяемую память — разделяйте память через общение". std::sync::mpsc (multi-producer, single-consumer) даёт канал для передачи владения данными между потоками.

use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();

let tx2 = tx.clone();  // multi-producer — клонируем Sender

thread::spawn(move || {
    tx.send("из потока 1").unwrap();
});
thread::spawn(move || {
    tx2.send("из потока 2").unwrap();
});

for received in rx {   // rx как итератор — блокируется, пока все tx не задропаны
    println!("получено: {received}");
}

Канал закрывается автоматически, когда все Sender выходят из области видимости (Drop) — rx.recv() тогда вернёт Err. mpsc::sync_channel(n) создаёт канал с ограниченной ёмкостью буфера — send() блокируется, если буфер заполнен (backpressure).

Mutex<T> — данные, а не блокировка

Принципиальное API-отличие от большинства языков: в Rust Mutex<T> — это обёртка над данными, а не отдельный примитив рядом с данными. Доступ к содержимому возможен только через lock(), который возвращает MutexGuard — тип, реализующий Deref/DerefMut и автоматически освобождающий блокировку через Drop.

use std::sync::Mutex;

let m = Mutex::new(5);
{
    let mut guard = m.lock().unwrap();  // unwrap — poisoning, см. ниже
    *guard += 1;
}   // Drop у guard освобождает блокировку — забыть Unlock() невозможно
println!("{:?}", m);

Компилятор физически не даст прочитать данные под Mutex без блокировки — в отличие от Go, где mu.Lock() и защищаемые данные существуют раздельно, и ничто не мешает случайно обратиться к полю структуры, забыв про lock.

⚠️ Lock poisoning: если поток паникует, удерживая MutexGuard, мьютекс помечается как "отравленный" (poisoned). Последующие lock() будут возвращать Err — по умолчанию нужно явно решать, восстанавливать данные (into_inner()) или паниковать дальше.

Arc<Mutex<T>> — общее состояние между потоками

Стандартная комбинация для расшаривания изменяемых данных между потоками: Arc даёт совместное владение с потокобезопасным счётчиком, Mutex — эксклюзивный доступ к содержимому.

use std::sync::{Arc, Mutex};
use std::thread;

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

RwLock<T> — читатели и писатель

Как и sync.RWMutex в Go, RwLock допускает много одновременных читателей ИЛИ одного писателя. Выгоден в read-heavy сценариях, где блокировка на чтение не должна становиться узким местом.

use std::sync::RwLock;

let lock = RwLock::new(vec![1, 2, 3]);

{
    let r1 = lock.read().unwrap();
    let r2 = lock.read().unwrap();  // несколько read-guard'ов одновременно — ОК
    println!("{:?} {:?}", r1, r2);
}
{
    let mut w = lock.write().unwrap();  // эксклюзивный доступ
    w.push(4);
}
Mutex<T>RwLock<T>
Читатели одновременнонет, эксклюзив всегдада, много читателей
Оверхедминимальныйвыше (учёт читателей/писателей)
Когда выгоденчастые записи, простые данныечтение >> запись
Риск starvation писателянетда, при потоке читателей

Deadlock — компилятор не спасёт

Send/Sync гарантируют отсутствие data races, но не отсутствие взаимных блокировок — deadlock остаётся логической ошибкой, которую компилятор не ловит.

use std::sync::Mutex;

let m = Mutex::new(5);
let _g1 = m.lock().unwrap();
let _g2 = m.lock().unwrap();  // deadlock: Mutex не реентерабелен,
                                     // _g1 ещё жив, поток блокируется навсегда

// Классический deadlock из двух потоков с обратным порядком блокировок:
// поток A: lock(mutex1) → lock(mutex2)
// поток B: lock(mutex2) → lock(mutex1)
// → циклическое ожидание
🚫 Правило избежания deadlock: всегда захватывайте несколько блокировок в едином глобальном порядке во всём приложении. Держите время удержания guard'а минимальным — не вызывайте код, который может заблокироваться, пока guard жив.

Практические рекомендации

  • Ограничивайте область видимости MutexGuard явными блоками { } — чем короче удержание, тем меньше contention
  • Не держите guard через .await в асинхронном коде (см. тему 15) — используйте tokio::sync::Mutex, если блокировка нужна внутри async-функции
  • Для счётчиков без сложной логики — атомики (std::sync::atomic) быстрее, чем Mutex<i64> (подробнее — тема 14)
  • Каналы предпочтительнее общей памяти, когда данные естественно "передаются" от одного этапа обработки к другому