Разделяемое состояние: Arc, Mutex и RwLock
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Разделяемое состояние: Arc, Mutex и RwLock
Прошлый урок показал две дороги для данных между потоками. Через
Arcможно поделиться неизменяемыми данными: все читают, никто не пишет. Через канал можно передать значение из рук в руки, и тогда оно у нового хозяина целиком. Но есть третий случай, который ни тот, ни другой не закрывают: несколько потоков должны менять одно и то же. Счётчик скачиваний, общий кэш, живая сводка статистики. Здесь нужна разделяемая изменяемая память, а её Rust по умолчанию запрещает: правило эксклюзивности не пускает два&mutк одному значению. Сегодня разберём, как это правило обойти честно: замок впускает к данным строго по одному потоку за раз, аArcраздаёт ручку к замку всем желающим.
Идея
Вспомни главное правило заимствования: либо много &T, либо один &mut T, третьего не дано. Оно и спасает от гонок данных, и мешает: два потока физически не могут одновременно держать &mut к общему счётчику. Прямого пути нет.
Выход в том, чтобы проверку эксклюзивности перенести из времени компиляции в рантайм. Тип сам впускает к своим внутренностям по одному, отдавая наружу безобидный &self, а реальную эксклюзивность обеспечивая в момент обращения. Это внутренняя мутабельность, которую через RefCell мы уже видели для одного потока. Но RefCell не Sync, через границу потока он не проходит, и компилятор это ловит. Многопоточный аналог это Mutex.
Рабочая связка почти всегда одна и та же: Arc<Mutex<T>>. Снаружи внутрь: Arc даёт разделяемое владение (несколько потоков держат ручку к одному значению), Mutex даёт безопасную мутацию (к данным внутри пускают по одному). Каждый слой решает свою задачу, и сегодняшний урок про то, как они складываются и где подстерегают грабли.
Mutex: эксклюзивный доступ по требованию
Mutex в Rust устроен не как голый замок где-то сбоку, а как контейнер: данные лежат внутри него, и иначе как через замок к ним не подойти. Метод lock возвращает MutexGuard, через который и идёт работа с данными:
use std::sync::Mutex;
fn main() {
let counter = Mutex::new(0);
{
let mut guard = counter.lock().unwrap();
*guard += 1; // меняем данные через guard (DerefMut)
} // guard роняется здесь -> замок отпущен автоматически
println!("{}", *counter.lock().unwrap()); // 1
}
Два момента стоит заметить сразу. Первое: counter объявлен без mut, а мы его меняем. Это и есть внутренняя мутабельность: lock принимает &self, а эксклюзивность обеспечивает замок. Второе: замок отпускается сам, когда guard выходит из области видимости. Никакого ручного unlock, забыть его невозможно по построению. Это тот же RAII, что освобождает память и закрывает файлы: освобождение ресурса привязано к концу жизни значения.
Отсюда практический навык: следи за тем, как долго живёт guard. Пока он жив, замок держится, и все остальные потоки стоят в очереди. Поэтому область, в которой guard захвачен, держат как можно короче, а тяжёлую работу выносят за её пределы.
Arc плюс Mutex: делим и меняем
Mutex сам по себе живёт в одном месте. Чтобы несколько потоков могли его трогать, его нужно разделить, а разделяемое владение между потоками это Arc. Складываем:
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 n = counter.lock().unwrap();
*n += 1;
}));
}
for h in handles {
h.join().unwrap();
}
println!("итог: {}", *counter.lock().unwrap()); // ровно 10
}
Прочитай связку по слоям. Arc<Mutex<i32>>: внешний Arc каждый поток клонирует и уносит к себе через move, все клоны указывают на один и тот же Mutex. Внутренний Mutex следит, чтобы прибавления не наложились друг на друга: десять потоков делают += 1, и итог ровно 10, а не “как повезёт”. Без замка это была бы классическая гонка на счётчике, с замком прибавления выстраиваются в очередь.
Обрати внимание, что метод, меняющий счётчик, берёт &self, а не &mut self. Поэтому общий счётчик удобно завернуть в свой тип с дешёвым Clone, который копирует только Arc внутри, а не данные. Ровно это ты соберёшь в первой задаче.
Poisoning: что если поток паниковал под замком
Почему lock() возвращает Result, а мы всё время пишем .unwrap()? Из-за отравления. Если поток запаниковал, держа guard, данные внутри могли остаться наполовину изменёнными. Rust помечает такой Mutex отравленным, и все следующие lock() возвращают Err, чтобы остальные потоки не продолжили молча работать с возможно битым состоянием.
use std::sync::Mutex;
let data = Mutex::new(vec![1, 2, 3]);
match data.lock() {
Ok(guard) => {
// обычный путь: замок чист
println!("{} элементов", guard.len());
}
Err(poisoned) => {
// поток-предшественник паниковал под замком;
// мы осознанно решаем, что данные всё же пригодны
let guard = poisoned.into_inner();
println!("после паники: {} элементов", guard.len());
}
}
В обычном коде .unwrap() на lock() это разумная позиция по умолчанию: если кто-то паниковал под замком, состояние под подозрением, и упасть честнее, чем продолжать вслепую. Восстановление через into_inner() берут осознанно, когда точно знаешь, что данные переживут чужую панику.
RwLock: много читателей или один писатель
Mutex пускает строго по одному, даже если все пришли только почитать. Для данных, которые читают часто, а меняют редко (настройки, кэш, словарь), это расточительно. RwLock различает два режима доступа:
use std::collections::HashMap;
use std::sync::RwLock;
fn main() {
let config: RwLock<HashMap<String, String>> = RwLock::new(HashMap::new());
// писатель заходит один и эксклюзивно
config
.write()
.unwrap()
.insert("host".to_string(), "localhost".to_string());
// читателей внутри может быть сколько угодно одновременно
let guard = config.read().unwrap();
println!("{:?}", guard.get("host"));
}
read() пускает много потоков сразу: раз никто не пишет, общие ссылки безопасны. write() пускает ровно одного и без читателей рядом. Это буквально правило заимствования “много &T или один &mut T”, только проверяется в рантайме, а не компилятором. Берёшь RwLock там, где чтений сильно больше записей; если запись частая, выигрыша над Mutex не будет, а накладные расходы выше.
Дедлоки: замок не спасает от логики
В прошлом уроке была важная граница: Rust исключает гонку данных, но не гонку состояний. Mutex это та же история. Он гарантирует, что байты не побьются, но не мешает тебе устроить дедлок, и компилятор слова против этого не скажет.
Самый частый рецепт дедлока это два замка, захваченные в разном порядке:
// поток 1 поток 2
let a = lock_a.lock().unwrap(); let b = lock_b.lock().unwrap();
let b = lock_b.lock().unwrap(); let a = lock_a.lock().unwrap();
// если оба успели взять свой первый замок, каждый вечно ждёт второй
Лекарство простое и его стоит запомнить как правило: все потоки захватывают замки в одном и том же порядке. Тогда кругового ожидания не возникает. Есть и коварный частный случай, дедлок самим с собой:
let m = Mutex::new(0);
let g1 = m.lock().unwrap();
let g2 = m.lock().unwrap(); // тот же поток ждёт замок, который сам же держит -> навсегда
std::Mutex не реентрантный: повторный lock() из потока, который уже держит этот замок, повиснет. Чаще всего это случается случайно, когда lock() зовут внутри функции, которую вызвали уже под этим же замком. И отдельно: никогда не держи guard через .await. Футура с захваченным замком может уехать на другой поток или уснуть надолго, и замок зависнет вместе с ней.
Собери дедлок руками. Дай потокам разный порядок захвата, дай каждому взять свой первый замок, потом потянуться за вторым, и увидишь круговое ожидание. Потом переключи на единый порядок и убедись, что так зависнуть нельзя, сколько ни сталкивай.
Когда блокировка становится узким местом
Замок это точка, где параллельность схлопывается в очередь. Если много потоков дерутся за один Mutex, они большую часть времени стоят, а не работают. Это называется contention, и борются с ней так: дробят один большой замок на несколько мелких (отдельный замок на каждую корзину словаря), сокращают критическую секцию до минимума, а для совсем горячих счётчиков уходят на атомики, к которым перейдём в следующем уроке.
Полезно знать и про альтернативу из экосистемы: крейт parking_lot даёт Mutex и RwLock, которые меньше по размеру, быстрее в неконтендуемом случае и без отравления (его API не возвращает Result у lock). В реальных проектах его берут часто; в стандартной библиотеке остаётся то, что мы разобрали.
Наконец, разделяемое состояние не единственный путь. Если данные движутся в одну сторону и у состояния есть естественный хозяин, канал из прошлого урока чище: один поток-владелец копит результат, остальные шлют ему сообщения, замков нет вовсе. А для очереди с потолком (backpressure) Mutex объединяют с Condvar: получатель спит на пустом буфере, отправитель спит на полном, и никто не крутит холостой цикл “а готово ли уже”. Это последняя задача урока.
Практика
Четыре задачи, лесенкой от простого счётчика до очереди с потолком. Везде только std.
Сначала общий счётчик: Arc<Mutex<T>> и дешёвый Clone, который копирует ручку, а не данные.
Теперь живая сводка статистики под замком: выбор между каналом и разделяемым состоянием и сборка второго.
Хранилище настроек, где читателей много, а писатель редкий: тот самый профиль под RwLock.
И крайний случай: ограниченная очередь с backpressure на Mutex и двух Condvar.
Что унести из урока
Разделяемая изменяемая память в Rust это перенос проверки эксклюзивности из компиляции в рантайм, и держится он на трёх вещах. Первое: рабочая связка Arc<Mutex<T>>, где Arc даёт разделяемое владение, а Mutex безопасную мутацию через &self; guard отпускает замок сам по RAII, поэтому критическую секцию держат короткой. Второе: lock() возвращает Result из-за отравления, RwLock экономит на доступе, когда чтений много, а записей мало. Третье и главное: замок защищает байты, но не логику; дедлок, contention и держание guard через .await это твоя ответственность, и лечатся они единым порядком захвата, дроблением замков и выбором канала там, где у состояния есть хозяин.
Дальше спускаемся на уровень ниже самого замка. Счётчик из этого урока крутил Mutex ради одного += 1, и это дорого. В следующем уроке тот же счётчик станет атомарным, без всякого замка, и мы разберём, какой ценой и под какие гарантии порядка памяти это работает.