Когда использовать Mutex, а когда RwLock в Rust для многопоточности?
Ключевое различие: семантика доступа
Mutex и RwLock решают одну задачу — защиту данных от гонок, но с разной стратегией. Mutex даёт эксклюзивный доступ: только один поток может владеть блокировкой, независимо от того, читает он или пишет. RwLock различает читателей и писателей: множество читателей могут работать одновременно, но писатель блокирует всех.
Когда выбирать Mutex
Mutex — ваш выбор, если:
- Операции записи часты (>20-30% всех операций). При частых записях
RwLockбудет работать хуже, так как писатель блокирует даже чтение, а переключение между режимами (read→write) добавляет накладные расходы. - Критическая секция короткая (микросекунды). Если вы просто увеличиваете счётчик или обновляете одно поле, оверхед
RwLock(дополнительная логика для подсчёта читателей) может превысить выгоду от параллельного чтения. - Простота и предсказуемость.
Mutexпроще в использовании и отладке. Он не страдает от "голодания" писателей (в стандартной реализацииstd::sync::Mutexиспользуется справедливая очередь).
Пример, где Mutex оправдан — защита счётчика обращений:
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
for _ in 0..100 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
let mut num = c.lock().unwrap();
*num += 1; // короткая запись
}
}));
}
// Результат: 100_000
Когда выбирать RwLock
RwLock выигрывает при:
- Доминирующем чтении (>80-90% операций). Пример: кэш конфигурации, который редко обновляется, но часто читается десятками потоков.
- Длительных операциях чтения. Если чтение занимает миллисекунды (например, парсинг большого JSON), параллельный доступ даёт существенный прирост производительности.
- Необходимости атомарного обновления.
RwLockпозволяет выполнить "обновление через чтение" (upgradeable read lock) — вы читаете, а потом, если нужно, повышаете блокировку до записи без отпускания (только в некоторых реализациях, напримерparking_lot::RwLock).
Ваш пример с кэшем — идеальный кандидат для RwLock:
use std::sync::{Arc, RwLock};
use std::thread;
use std::collections::HashMap;
fn main() {
let cache = Arc::new(RwLock::new(HashMap::new()));
let mut handles = vec![];
// Потоки-читатели (90%)
for i in 0..9 {
let cache = Arc::clone(&cache);
handles.push(thread::spawn(move || {
let data = cache.read().unwrap();
println!("Reader {} sees: {:?}", i, data.get(&0));
// Множество читателей могут работать одновременно
}));
}
// Поток-писатель (10%)
let cache_w = Arc::clone(&cache);
handles.push(thread::spawn(move || {
let mut data = cache_w.write().unwrap();
data.insert(0, "initial_value".to_string());
println!("Writer updated cache");
}));
for h in handles {
h.join().unwrap();
}
}
Важные предостережения
- Голодание писателей — реальная проблема в
std::sync::RwLock(и вparking_lotпо умолчанию). Если потоки-читатели непрерывно удерживают блокировку, писатель может ждать очень долго. Решение: используйтеRwLock::write()с таймаутом или переключайтесь наMutexпри обнаружении "голодания". - Производительность. Измеряйте! В реальных бенчмарках
RwLockможет быть медленнееMutexпри 4-8 потоках и 50% записи. Всегда тестируйте на вашем сценарии. - Библиотеки. Рассмотрите
parking_lot— егоRwLockбыстрее стандартного и имеет опциюFairдля предотвращения голодания.
Итоговое правило
Используйте Mutex по умолчанию — это безопасный выбор. Переходите на RwLock только если профилирование показало, что Mutex стал узким местом, и вы уверены, что чтение доминирует (минимум 80% операций). Для кэшей, конфигураций и редко обновляемых данных RwLock — отличный инструмент.
Альтернативный взгляд: не забывайте про атомики и каналы
Часто выбор между Mutex и RwLock — это симптом неправильного архитектурного решения. В Rust есть более лёгкие инструменты синхронизации, которые стоит рассмотреть в первую очередь:
- Atomic-типы (
AtomicBool,AtomicUsize,AtomicPtr). Если вы защищаете одно целочисленное значение или флаг, используйте атомики — они работают без блокировок и значительно быстрее. Например, счётчик запросов или флаг "кэш обновлён". - Каналы (
std::sync::mpscилиcrossbeam_channel). Если у вас есть один писатель и много читателей, передавайте обновления через канал, а читатели пусть хранят локальную копию данных. Это полностью исключает блокировки на чтение. - Sharded структуры. Разбейте данные на независимые разделы (shards) и защищайте каждый своим
Mutex. Это уменьшит конкуренцию и даст лучшую масштабируемость, чем одинRwLock.
Пример с каналом для кэша:
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
let mut local_cache = HashMap::new();
// Поток-писатель отправляет обновления
thread::spawn(move || {
tx.send(("key".to_string(), "value".to_string())).unwrap();
});
// Читатель получает обновления и работает с локальной копией
while let Ok((k, v)) = rx.recv() {
local_cache.insert(k, v);
// Ч