Привет, Хабр! Сегодня я бы хотел обратить ваше внимание на важную тему работы с общим состоянием при параллельном выполнении кода на Rust. В этой статье я не буду распыляться на базовые определения параллельности, потоков, так как если вы уже оказались здесь, значит у вас есть хотя бы примерное понимание этого.
Почему нет?
Прежде чем раскрыть саму тему статьи, необходимо указать явные причины, почему не рекомендуется использовать одну и ту же область памяти между потоками:
При использовании памяти между потоками велик риск состояния гонки (race conditions), когда несколько потоков одновременно читают и изменяют одни и те же данные, что приводит к непредсказуемым последствиям.
Потоки будут блокировать друг друга при попытках получить доступ к определённому участку памяти, такое возможно даже при синхронизации (например, через мьютексы).
Самой очевидной проблемой является отладка подобного кода. Rust делает параллельный код безопасным, но сложным.
Почему всё же стоит обратить внимание?
Исходя из приведённых выше недостатков такого подхода у многих читателей может сложиться впечатление, что использовать подобный подход крайне невыгодно, но это лишь "темная сторона", которую можно найти почти в любой технологии.Если вы пишете высоконагруженный сервис, который требует постоянный доступ к одним и тем же данным из разных потоков (примером такого сервиса может являться счётчики и системы кэширования), то такой подход именно для вас, но, как уже было сказано выше, использовать его нужно с осторожностью.
Правильная передача данных между потоками
Для передачи данных между несколькими потоками, нужно использовать умные указатели из Rust, а именно Arc<T> и Mutex<T>. Arc<T> - умный указатель, который позволяет передавать данные между потоками (чего не скажешь об Rc<T>, но об этом позже), а Mutex<T> необходим для блокировки потока, пока другой не освободит мьютекс.
Для начала это может прозвучать достаточно странно, но на деле всё гораздо проще. Arc<T> - обёртка для передачи в другой поток, а Mutex<T> можно сравнить со светофором, который включает "красный свет", чтобы данные не столкнулись друг с другом.
Для демонстрации работы такого подхода, хотелось бы представить абстрактный пример кода "счётчик".
use std::thread; use std::time::Duration; use std::sync::{Arc, Mutex}; struct Counter { value: i64 } fn main() { //Создаём экземпляр структуры с конкретным значением: let counter = Arc::new(Mutex::new(Counter {value: 0})); //Создаём поток для инкрементирования значения: let increment = Arc::clone(&counter); let handler1 = thread::spawn(move || { for _ in 0..10 { let mut guard = increment.lock().unwrap(); guard.value += 1; println!("inc: {}", guard.value); thread::sleep(Duration::from_millis(100)); } }); //Создаём поток для чтения значений: let reader = Arc::clone(&counter); let handler2 = thread::spawn(move || { for _ in 0..15 { let guard = reader.lock().unwrap(); println!("read: {}", guard.value); thread::sleep(Duration::from_millis(180)); } }); handler1.join().unwrap(); handler2.join().unwrap(); //Выводим общий результат: let final_guard = counter.lock().unwrap(); println!("final: {}", final_guard.value); }
Как видно в примере выше, использовать Arc<T> и Mutex<T> достаточно просто, необходимо лишь создать новый мьютекс и обернуть его в Arc. Дальше, как и при обычном использовании данных в потоке, передаём их с помощью move, вот только внутри обязательно необходимо указать .lock() на объекте блокировки, в данном случае - это guard. Так как lock() возвращает LockResult, здесь используетсяunwrap() (хотя я настоятельно рекомендую использовать иные способы обработки ошибок).
Вывод, после запуска такого кода будет следующим:
inc: 1 inc: 2 inc: 3 inc: 4 inc: 5 inc: 6 inc: 7 inc: 8 inc: 9 inc: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 read: 10 final: 10
Почему именно Arc<T>?
В Rust существует всего 9 основных умных указателей, у некоторых пользователей может появиться вопрос, почему оборачиваем именно в Arc<T>? Такое решение сделано не случайно, сама суть Arc<T> заключается в том, что это единственный умный указатель, из std, который реализует Send + Sync и поддерживает совместное владение, что и позволяет безопасно передавать между потоками данные и использовать несколько потоков одновременно. Сейчас мне бы хотелось более подробно рассмотреть каждый из умных указателей, чтобы у читателей статьи было большее понимание причины выбора Arc<T>:
Box<T>- указатель на данные в куче, используется, когда размер типа не известен на этапе компиляции, а также, когда нужно передать владение большим объёмом данных без копирования, но у этого умного указателя одиночное владение.Rc<T>- означает подсчёт ссылок для множественного владения в одном потоке, используется, когда данные имеют нескольких владельцев, но он не потокобезопасен, так как не реализуетSend + Sync.Arc<T>- атомарный подсчёт ссылок для множественного владения между потоками, используется в сценариях, когда данные разделяются между потоками (потокобезопасен, так как реализуетSend + Sync), но необходимо иметь ввиду, что имеет небольшое снижение производительности из-за атомарных операций.RefCell<T>- внутренняя изменяемость с проверкой заимствования во время выполнения. Используется, в случае, если нужно изменять данные, имея неизменяемую ссылку, а также для обхода правил заимствования компилятора.Cell<T>- ячейка для внутренней изменяемости для Copy-типов. Можно использовать для простых типов, реализующих Copy, когда не нужны ссылки, а только чтение/запись значений. Является более легковесной заменойRefCell. Также этот умный указатель, в отличие отRefCell<T>не вызывает паники.Mutex<T>- взаимное исключение для потокобезопасного доступа. Используется для синхронизации доступа к данным между потоками, а также, когда нужна изменяемость в многопоточной среде, может привести к deadlock при неправильном использовании!RwLock<T>- блокировка чтения-записи. Используется, когда "читателей много, а писателей мало", для оптимизации производительности в многопоточных приложениях.Weak<T>- Слабая ссылка дляRc/Arcдля предотвращения циклов. Используется в структурах с циклическими зависимостями, примерами которых могут быть деревья с родительскими ссылками. К особенностям можно отнести то, что этот умный указатель не продлевает время жизни данных, а также то, что для доступа к данным нужно вызыватьupgrade(), который возвращаетOption<Rc<T>>.ManuallyDrop<T>- отключает автоматический вызов drop. Используется для автоматического управления деструкторами, при работе с FFI (Foreign Function Interface), а также для реализации собственных умных указателей. К особенностям можно отнести то, что требует явного вызоваManuallyDrop::drop()илиstd::mem::drop(), а также то, что является небезопасным, при неправильном использовании.
Заключение
Я искренне надеюсь, что моя статья стала для вас полезна и дала большее понимание параллельных вычислений в Rust. Отвечать на вопрос рентабельности использования такого подхода в своих проектах предстоит именно вам, так как тема это достаточно нишивая, единственное, что хотелось бы во второй раз предостеречь вас от лишнего использования такого подхода. В случае, если ваш проект делает простейшие запросы в базу данных, делает простые вычисления, с которыми бы справился и последовательный подход, то лучше всего не придаваться оверинжинирингу, не внедрять по желанию "что-то внедрить".Спасибо за ваше внимание!
