Каналы, 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> | совместное, счётчик атомарный | нет, только &T | Send + 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 компилятор не спасает: два лока в разном порядке — и программа висит.»