Каналы, Arc и Mutex

Rust предлагает два способа делить данные между потоками: передавать сообщения по каналу или разделять память под замком — и второй сделан так, что «забыть взять лок» невозможно.

Mutex в Rust — это контейнер, который владеет защищаемыми данными, поэтому доступ к ним физически невозможен без захвата лока.

Вопрос: «Как в Rust устроены каналы и чем передача сообщений отличается от shared memory?»

Что на самом деле проверяет интервьюер

Знаете ли вы, что у Rust есть оба подхода, и умеете ли выбирать. Девиз из книги — «не общайтесь, разделяя память; разделяйте память, общаясь» — но senior обязан добавить: канал не бесплатный, он копирует/перемещает данные и добавляет синхронизацию, поэтому для счётчика на десять потоков Mutex проще и быстрее, а для конвейера задач канал понятнее.

mpsc: много отправителей, один получатель

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

fn main() {
    let (tx, rx) = mpsc::channel();

    for id in 0..3 {
        let tx = tx.clone();
        thread::spawn(move || {
            tx.send(format!("рабочий {id} закончил")).unwrap();
        });
    }

    drop(tx); // иначе итератор никогда не завершится

    for msg in rx {
        println!("{msg}");
    }
    println!("канал закрыт");
}

Вывод:

рабочий 0 закончил
рабочий 2 закончил
рабочий 1 закончил
канал закрыт

Порядок первых трёх строк недетерминирован. Что важно проговорить: Sender клонируется (это и есть «mpsc» — multi-producer, single-consumer), Receiver — нет. recv() блокирует поток до сообщения и возвращает Err, когда все Sender уничтожены; Receiver реализует Iterator, и цикл for завершается ровно в этот момент. Отсюда обязательный drop(tx) — оригинальный отправитель живёт в main и держал бы канал открытым вечно. Это классический «висящий» баг, который любят спрашивать.

Вопрос: «Чем Arc отличается от Rc и почему нельзя просто Arc<T> без Mutex?»

Что на самом деле проверяет интервьюер

Arc — тот же Rc, но со счётчиком ссылок на атомарных операциях, поэтому он Send + Sync (если T: Send + Sync). Атомарность стоит денег: клонирование Arc заметно дороже клонирования Rc, поэтому Rc из стандартной библиотеки не выкинули. И главное: Arc<T> даёт при разыменовании только &T. Изменять через него нечего — нужна внутренняя изменяемость, потокобезопасная версия которой это Mutex или RwLock.

Код, который не компилируется

use std::sync::Arc;
use std::thread;

fn main() {
    let counter = Arc::new(0);
    let c = Arc::clone(&counter);

    thread::spawn(move || {
        *c += 1;
    })
    .join()
    .unwrap();
}

Ошибка компиляции:

error[E0594]: cannot assign to data in an `Arc<i32>`
 --> src/main.rs:9:9
  |
9 |         *c += 1;
  |         ^^^^^^^ cannot assign
  |
  = help: trait `DerefMut` is required to modify through a dereference, but it is not implemented for `Arc<i32>`

Компилятор буквально объясняет причину: у Arc нет DerefMut, потому что разделяемое владение и эксклюзивная ссылка несовместимы.

Рабочий вариант: Arc<Mutex<T>>

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

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut guard = counter.lock().unwrap();
            *guard += 1;
        })); // guard освобождается здесь, по Drop
    }

    for h in handles {
        h.join().unwrap();
    }

    println!("итог = {}", *counter.lock().unwrap());
}

Вывод:

итог = 10

Здесь результат детерминирован — в этом и смысл лока. Разбор ролей: Arc отвечает за совместное владение (кто освободит память), Mutex — за эксклюзивный доступ (кто сейчас пишет). Это два разных вопроса, поэтому и два слоя; типичная сигнатура состояния сервиса выглядит как Arc<Mutex<Vec<i32>>>.

lock() возвращает Result из-за poisoning: если поток запаниковал, удерживая лок, данные могли остаться в несогласованном состоянии, и все последующие lock() возвращают Err(PoisonError). Внутри ошибки лежит into_inner(), если вы готовы взять ответственность. Привычный .unwrap() в примерах означает «паника одного потока роняет и остальных» — на собеседовании этот нюанс стоит проговорить, а не молча анврапить.

Освобождение лока происходит по Drop у MutexGuard: нет ни unlock(), ни риска забыть его вызвать при раннем return или панике. Если нужно отпустить лок раньше конца области видимости — drop(guard) явно.

Памятка: что когда брать

ТипВладениеИзменяемостьПотоки
Rc<T>совместное, счётчик неатомарныйнет, только &Tне Send, не Sync
Arc<T>совместное, счётчик атомарныйнет, только &TSend + Sync, если T: Send + Sync
RefCell<T>единоличноеда, проверка в рантайме, паника при нарушенииSend, но не Sync
Mutex<T>единоличноеда, блокирующий эксклюзивный локSend + Sync
RwLock<T>единоличноемного читателей или один писательSend + Sync

Читается по диагонали: однопоточная пара — Rc<RefCell<T>>, многопоточная — Arc<Mutex<T>>. RwLock берут, когда читателей заметно больше, чем писателей; при равномерной нагрузке он проигрывает Mutex из-за более дорогой синхронизации и риска голодания писателя.

Что Rust всё-таки не ловит: deadlock

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

fn main() {
    let a = Arc::new(Mutex::new(1));
    let b = Arc::new(Mutex::new(2));

    let (a1, b1) = (Arc::clone(&a), Arc::clone(&b));
    let t1 = thread::spawn(move || {
        let _x = a1.lock().unwrap();
        thread::yield_now();
        let _y = b1.lock().unwrap(); // ждёт b
    });

    let t2 = thread::spawn(move || {
        let _y = b.lock().unwrap();
        thread::yield_now();
        let _x = a.lock().unwrap(); // ждёт a
    });

    t1.join().unwrap();
    t2.join().unwrap();
}

Программа компилируется без единого предупреждения и с большой вероятностью зависает навсегда: два лока берутся в разном порядке. Лекарство — дисциплина, а не компилятор: единый глобальный порядок захвата, минимальные критические секции, а лучше вообще один лок вместо двух.

Типичные ошибки кандидатов

  • Забыть drop(tx) и удивляться, что цикл по Receiver не завершается.
  • Говорить, что Arc «потокобезопасная замена Rc» и на этом остановиться: без Mutex он даёт только чтение.
  • Считать, что Mutex «лежит рядом с данными», как в C. В Rust он ими владеет, и это главное отличие: доступ без лока не выражается синтаксически.
  • Не знать про poisoning и объяснять Result у lock() как «на всякий случай».
  • Держать MutexGuard живым дольше нужного — например через длинное выражение или временное значение в match — и получать неожиданные простои и deadlock.
  • Обещать, что «Rust защищает от deadlock». Не защищает.
  • Брать RwLock по умолчанию «потому что он быстрее». Быстрее он только при явном перекосе в чтение.

Как ответить кратко

«Есть два пути. Каналы mpsc: Sender клонируется, Receiver один, recv блокируется, канал закрывается, когда уничтожен последний отправитель — поэтому оригинальный tx нужно дропнуть. Разделяемая память: Arc даёт совместное владение с атомарным счётчиком, но только &T, поэтому для изменения нужен Mutex или RwLock. В Rust мьютекс оборачивает данные, а не лежит рядом, так что забыть взять лок невозможно by design; lock() возвращает Result из-за poisoning, а сам лок освобождается по Drop у MutexGuard. Типичная форма состояния — Arc<Mutex<Vec<i32>>>. При этом от deadlock компилятор не спасает: два лока в разном порядке — и программа висит.»

Проверьте себя
1. Почему в примере с mpsc нужен явный drop(tx) перед циклом по получателю?
AЧтобы освободить память под буфер канала
BЧтобы сообщения не приходили в случайном порядке
CПотому что канал закрывается только когда уничтожены все Sender, иначе итератор по Receiver не завершится
DПотому что Sender нельзя клонировать больше трёх раз
2. Почему одного Arc недостаточно, чтобы менять значение из нескольких потоков?
AArc даёт при разыменовании только неизменяемую ссылку и не реализует DerefMut, поэтому нужна внутренняя изменяемость через Mutex или RwLock
BArc работает только с типами, реализующими Copy
CArc не реализует Send, поэтому его вообще нельзя передать в поток
DArc можно менять, но только после вызова Arc::make_mut в unsafe-блоке
3. Что означает Result, который возвращает Mutex::lock()?
AОшибку возвращают при таймауте ожидания лока
BОшибку возвращают при обнаружении deadlock
CErr приходит при poisoning: поток запаниковал, удерживая лок, и данные могли остаться несогласованными
DErr приходит, если лок уже захвачен другим потоком