Раздел 23 · Rust

Разделяемое состояние: Arc, Mutex и RwLock

senior~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Разделяемое состояние: 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, и это дорого. В следующем уроке тот же счётчик станет атомарным, без всякого замка, и мы разберём, какой ценой и под какие гарантии порядка памяти это работает.

Домашка