Привет! Сегодня я бы хотел обратить ваше внимание на важную тему работы с общим состоянием при параллельном выполнении кода на 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. Отвечать на вопрос рентабельности использования такого подхода в своих проектах предстоит именно вам, так как тема это достаточно нишивая, единственное, что хотелось бы во второй раз предостеречь вас от лишнего использования такого подхода. В случае, если ваш проект делает простейшие запросы в базу данных, делает простые вычисления, с которыми бы справился и последовательный подход, то лучше всего не придаваться оверинжинирингу, не внедрять по желанию "что-то внедрить".Спасибо за ваше внимание!
Автор: MaksUnweb
