Mutex, RwLock и Arc в Rust
Практическая часть: как защитить общее состояние. Спрашивают про связку Arc<Mutex<T>>, момент снятия блокировки, отравление и выбор между Mutex и RwLock. Плюс любимая ловушка с временным guard.
Стержень: lock возвращает RAII-guard, и блокировка живёт ровно столько, сколько живёт этот guard.
// Формулировки: «зачем Arc<Mutex<T>>?», «когда снимется блокировка?», «что такое отравление мьютекса?»
Два вопроса - две обёртки
Arc отвечает на вопрос «кому принадлежит значение, если потоков несколько» - атомарным подсчётом ссылок. Mutex отвечает на вопрос «кто именно сейчас его меняет» - выдачей эксклюзивного доступа. По отдельности не работает: Arc<i32> не даст записи, а Mutex без Arc не переживёт передачу в потоки.
lock() возвращает MutexGuard - обёртку, через которую видно данные. Пока guard жив, блокировка держится; его деструктор её снимает. Метода unlock нет намеренно: он позволил бы обратиться к данным после освобождения.
// Чтобы сузить критическую секцию, guard кладут в отдельный блок или роняют явным drop(guard) - особенно важно, если дальше идёт долгая работа.
let c = Arc::new(Mutex::new(0));
{
let mut g = c.lock().unwrap();
*g += 1;
} // блокировка снята здесь- MutexGuard
- RAII-обёртка (resource acquisition is initialization): пока жива, блокировка держится
- критическая секция
- участок кода под блокировкой
Ловушка временного guard
Классическое зависание в проде: if let Some(v) = m.lock().unwrap().pop() { ... }. Guard здесь - временное значение внутри условия, и живёт оно до конца всей конструкции, а не до конца строки. Любой lock из того же потока внутри ветки встаёт намертво: Mutex нерекурсивный.
Лечится выносом значения в переменную до конструкции: let item = m.lock().unwrap().pop(); - тут guard умирает в конце строки, и дальше замок свободен. То же правило касается match по выражению с lock внутри.
// Проверено на компиляторе: внутри ветки if let try_lock честно возвращает false, а после выхода из блока - снова true.
// плохо: замок держится в теле ветки
if let Some(v) = m.lock().unwrap().pop() { .. }
// хорошо:
let v = m.lock().unwrap().pop();- временное значение
- guard, созданный внутри выражения-условия
- нерекурсивный мьютекс
- повторный захват из того же потока - самоблокировка
Отравление и выбор RwLock
Если поток паникует, удерживая мьютекс, данные могли остаться в нецелостном состоянии. Стандартный Mutex это фиксирует: следующий lock() возвращает Err - мьютекс отравлен. Данные всё же доступны через into_inner у ошибки, но решение «инвариант цел, работаем дальше» принимает человек, а не unwrap по привычке.
RwLock пускает читателей параллельно и выигрывает на перекошенной нагрузке - много чтений, редкие записи. Плата: более сложная синхронизация и риск голодания писателя. Если секция короткая или запись частая, обычный Mutex обычно быстрее и предсказуемее.
- отравление
- пометка мьютекса после паники под блокировкой
- голодание писателя
- поток записи долго не получает доступ из-за потока читателей
Как отвечать: «Когда снимается блокировка мьютекса?»
Когда уничтожается guard, который вернул lock: освобождение происходит в его деструкторе. Метода unlock нет специально, иначе можно было бы обратиться к данным после освобождения. Отсюда практика: если после работы с данными идёт что-то долгое, guard роняют явно через drop или кладут в отдельный блок, чтобы сузить критическую секцию. И есть частая ловушка: guard, созданный прямо в условии if let или match, живёт до конца всей конструкции, поэтому повторный lock внутри ветки приводит к самоблокировке - Mutex нерекурсивный.
Ты объясняешь механику через RAII и сразу называешь ловушку, на которой в реальности виснут сервисы. Это ответ уровня «отлаживал такое».
На чём валятся
- −Ищут метод unlock и не понимают, что блокировку снимает деструктор.
- −Держат guard во временном выражении и получают самоблокировку.
- −Глушат отравление unwrap, не подумав о целостности данных.
- −Берут RwLock по умолчанию, хотя запись частая - получают сложность без выигрыша.
- −Держат блокировку на всё тело функции вместо короткой секции.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- Обработчик держит состояние как Arc<Mutex<Vec<i32>>>. Почему внутри этой ветки второй lock зависает?
if let Some(v) = state.lock() .unwrap().pop() { let g = state.lock().unwrap(); }A)if let создаёт второй поток исполнения для веткиB)pop оставляет мьютекс заблокированным до следующей записиC)Mutex в Rust нерекурсивный только в release-сборкеD)Временный guard из условия живёт до конца всей конструкциипоказать ответ и разбор
+D)Временный guard из условия живёт до конца всей конструкции// разбор: Guard, созданный в условии, — временное значение, и оно живёт до конца всего if let, а не до конца строки. Второй lock из того же потока встаёт на самоблокировку: Mutex нерекурсивный. Лечится вынесением значения в переменную до конструкции — let x = state.lock().unwrap().pop(); — и это одна из самых частых причин зависаний в проде. Если мьютекс лежит в локальной переменной той же функции, борьба за порядок уничтожения вылезет раньше ошибкой заимствования.
- Поток запаниковал, удерживая мьютекс. Что увидят остальные?A)Мьютекс останется заблокированным навсегда, потоки повиснутB)lock() вернёт данные как обычно: паника на мьютекс не влияетC)lock() вернёт Err: мьютекс отравлен, данные под вопросомD)Мьютекс сбросит данные в значение по умолчанию и разблокируется
показать ответ и разбор
+C)lock() вернёт Err: мьютекс отравлен, данные под вопросом// разбор: Это отравление (poisoning): раз поток упал посреди изменения, данные могли остаться в нецелостном состоянии, и следующий lock честно возвращает Err. Внутрь можно попасть через into_inner у ошибки — если знаешь, что инвариант цел. Правило простое: обрабатывать этот Err осмысленно, а не глушить unwrap.
- Когда RwLock выгоднее Mutex?A)Когда критическая секция длинная: RwLock не блокирует писателейB)Когда чтений намного больше записей и они держатся недолгоC)Когда потоков больше, чем ядер: RwLock не усыпляет ихD)Когда нужно защитить сразу несколько независимых полей структуры
показать ответ и разбор
+B)Когда чтений намного больше записей и они держатся недолго// разбор: RwLock пускает читателей параллельно, но платит более сложной синхронизацией и риском голодания писателя. Выигрыш появляется на перекошенной нагрузке — много одновременных чтений при редких записях. Если секция короткая или запись частая, обычный Mutex обычно быстрее и предсказуемее.
- Что напечатает код?
let n = Arc::new(Mutex::new(0)); let hs: Vec<_> = (0..4).map(|_| { let n = Arc::clone(&n); thread::spawn(move || { for _ in 0..1000 { *n.lock().unwrap() += 1; } }) }).collect(); for h in hs { h.join().unwrap(); } println!("{}", *n.lock().unwrap());A)Число меньше 4000: часть инкрементов теряетсяB)Результат недетерминирован — от 1000 до 4000C)Паника: второй поток встретит отравленный мьютексD)Ровно 4000: каждый инкремент идёт под блокировкойпоказать ответ и разбор
+D)Ровно 4000: каждый инкремент идёт под блокировкой// разбор: Гонки тут нет по построению: каждый инкремент выполняется под захваченным замком, а join гарантирует, что главный поток читает итог после всех. Потеря инкрементов возможна только там, где счётчик общий и без синхронизации, — но такой код Rust просто не соберёт. Цена известна: 4000 захватов замка, поэтому на горячем счётчике вместо Mutex берут AtomicUsize с fetch_add.
- Почему Mutex в Rust хранит данные внутри себя, а не защищает их снаружи, как в C?A)Так работает быстрее: данные и блокировка лежат рядом и попадают в одну кэш-линиюB)Так требует стандартная библиотека, чтобы у мьютекса был известный размер при компиляцииC)Так мьютекс может сам восстановить данные, если поток с блокировкой запаниковалD)Так доступ к данным невозможен без захвата блокировки — это гарантирует система типов
показать ответ и разбор
+D)Так доступ к данным невозможен без захвата блокировки — это гарантирует система типов// разбор: Mutex<T> владеет значением и выдаёт его только через guard, который получается из lock. Забыть взять блокировку физически нельзя: без guard до данных не добраться. В C мьютекс и данные связаны лишь соглашением, и любой участок кода может обратиться к переменной напрямую — отсюда классические гонки, которые тут исключены на уровне типов.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.