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