Сделайте свой код Rust более деревенским, нарушив некоторые правила
7 марта 2022 г.Мы говорили [в прошлом] (https://blog.warp.dev/how-warp-works/) о том, почему мы решили создать Warp на Rust. С момента принятия этого решения выделяется одна вещь: насколько мы продуктивны как команда и при этом пожинаем плоды производительности языка системного уровня. Большой причиной такой производительности является средство проверки заимствований.
Применяя свои правила во время компиляции, Rust [проверка заимствования] (https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html) может гарантировать безопасность памяти. нашего приложения, что позволяет нам сосредоточить больше усилий на построении будущего терминала и меньше на поиске ошибок использования после освобождения.
В то же время средство проверки заимствования может быть источником разочарования. Иногда вам нужно сделать что-то, что нарушает правила, но не хочет выбрасывать безопасность в окно.
К счастью, стандартная библиотека Rust предоставляет полезные типы, которые позволяют вам сделать это: нарушить правила!
- [Совместное владение] (https://blog.warp.dev/rules-are-made-to-be-broken/#shared-ownership)
- [Подсчет ссылок (Rc и Arc)] (https://blog.warp.dev/rules-are-made-to-be-broken/#reference-counting-to-the-rescue)
- [RefCell] (https://blog.warp.dev/rules-are-made-to-be-broken/#refcell)
Совместная собственность
Первое правило проверки заимствования, которое мы собираемся нарушить, — это единое владение: по умолчанию в Rust у каждого фрагмента данных есть один владелец, и когда этот владелец выходит за рамки, данные очищаются. Это отлично подходит для эргономики, так как вам не нужно беспокоиться о ручном выделении и освобождении памяти!
Однако иногда вы не хотите, чтобы у данных был только один владелец. Возможно, вы хотите, чтобы данные проходили через разные части вашей программы, каждая из которых будет выполняться в течение разной длины, и нет никакого способа узнать, как долго в целом данные должны существовать. Может быть, вы создаете прототип и еще не хотите более конкретно решать вопросы владения и срока службы. Какой бы ни была причина, вы действительно хотели бы иметь несколько владельцев для некоторых данных, чтобы они существовали до тех пор, пока все они вместе взятые.
Подсчет ссылок на помощь!
В стандартную библиотеку Rust встроены два типа, которые обеспечивают совместное владение базовыми данными: Rc
и Arc
(сокращение от «Подсчет ссылок» и «Атомарный подсчет ссылок» соответственно).
Оба этих типа обеспечивают совместное владение содержащимися данными, отслеживая количество ссылок и гарантируя, что данные будут храниться до тех пор, пока есть какие-либо активные ссылки. Каждый из них реализует Clone и Drop: клонирование увеличивает счетчик ссылок, а удаление одного из них уменьшает его. Данные сохраняются до тех пор, пока есть ссылки, и очищаются только после того, как все клоны вышли из области действия. Например, в этом фрагменте используется Rc
, чтобы разделить владение двумя домашними объектами между двумя владельцами:
ржавчина
[получить(отладить)]
структура питомца {
имя: Строка,
внедрить домашнее животное {
fn новый (имя: строка) -> Self {
Я {имя}
структура человека {
питомцы: Vec
главная функция () {
// Создаем двух питомцев с общим владением
let cat = Rc::new(Pet::new("Tigger".into()));
let dog = Rc::new(Pet::new("Chase".into()));
// Создаем одного человека, которому принадлежат оба питомца
пусть брат = человек {
домашние животные: vec![cat.clone(), dog.clone()],
// Создаем другого человека, которому также принадлежат оба питомца
пусть сестра = человек {
домашние животные: vec![кошка, собака],
// Даже если один человек отказывается от права собственности, другой человек по-прежнему имеет общую собственность,
// так что питомцев держат рядом (ура!)
падение (сестра);
println!("Домашние животные: {:?}", Brother.pets)
Оба типа несут небольшие накладные расходы во время выполнения для поддержания счетчика ссылок. Основное различие между ними заключается в том, что Arc является потокобезопасным, а Rc — нет. Arc
использует атомарные операции для управления счетчиком ссылок, что увеличивает затраты времени выполнения, но делает его безопасным для совместного использования между потоками. Если вы работаете только в одном потоке, то Rc
будет более быстрой альтернативой.
Последний важный факт о Rc
и Arc
заключается в том, что они позволяют вам получать неизменяемые ссылки только на базовые данные.[2] Поскольку они в основном представляют общие данные, разрешение изменяемых ссылок нарушило бы гарантии безопасности Rust, допуская гонки данных и ошибки использования после освобождения.
Уникальные займы
«Но подождите!» — скажете вы, — «А что, если я захочу обмениваться изменяемыми данными между частями своего приложения?» К счастью, следующее правило проверки заимствований, которое мы собираемся нарушить, — это уникальные заимствования: чтобы что-то изменить, вам нужна уникальная (также называемая изменяемой) ссылка на данные. Средство проверки заимствования требует, чтобы вы могли иметь только одну изменяемую ссылку или любое количество неизменяемых ссылок, но никогда не сочетали их, поэтому вы никогда не сможете изменить данные, которые другая часть программы пытается прочитать в то же время.
Это ограничение отлично работает в большинстве случаев, но мы здесь, чтобы нарушать правила! Два случая, когда вы можете захотеть изменить данные без изменяемой ссылки:
- Кэширование неизменного вычисления (например, запоминание)
- Вышеупомянутый случай совместного владения с Rc или Arc
Чтобы обойти правила заимствования во время компиляции, компилятор предоставляет несколько полезных типов, каждый со своим набором недостатков. Все они позволяют вам безопасно изменять данные за неизменяемой ссылкой, используя различные подходы, чтобы убедиться, что ваша программа по-прежнему безопасна.[3]
RefCell
Во-первых, это RefCell
, который перемещает принудительное выполнение уникальных заимствований из времени компиляции во время выполнения. Подобно Rc
, RefCell
использует подсчет ссылок, чтобы отслеживать, сколько заимствований активно во время работы программы. Если вы когда-нибудь попытаетесь получить изменяемую ссылку и другую ссылку одновременно, RefCell
немедленно запаникует! Поэтому при использовании RefCell
вы должны убедиться, что ваша программа не пытается читать и записывать одни и те же данные одновременно.
Вот пример использования RefCell
для кэширования промежуточных результатов для чего-то, что в противном случае было бы неизменным:
ржавчина
структура калькулятора Фибоначчи {
кеш: RefCell
внедрить калькулятор Фибоначчи {
/// Вычислить N-е число Фибоначчи, кэшируя результат для предотвращения пересчета
/// Обратите внимание, что здесь используется &self
, а не &mut self
!
fn вычислить(&self, n: использовать размер) -> использовать размер {
// Базовый вариант
если п <= 2 {
вернуть 1;
// Проверяем кеш
если позволить Some(value) = self.cache.borrow().get(&n) {
вернуть *значение;
// Вычислить и кэшировать значение
пусть результат = self.calculate (n - 1) + self.calculate (n - 2);
self.cache.borrow_mut().insert(n, результат);
результат
Кроме того, подсчет ссылок в RefCell
не является потокобезопасным, поэтому невозможно совместно использовать данные RefCell
между потоками. Для изменения общих данных между потоками нам нужно обратиться к нашему следующему инструменту.
Типы блокировки
Следующие два типа — «Mutex» и «RwLock», оба из которых предоставляют способы доступа к изменяемым ссылкам из неизменяемой ссылки потокобезопасным способом. Они делают это, полностью блокируя поток до тех пор, пока доступ к данным не станет безопасным. Это обеспечивает надежную гарантию безопасности доступа, но также имеет серьезную ловушку: взаимоблокировки. Взаимоблокировки возникают, когда два потока блокируются в ожидании доступа к данным, которые удерживает другой поток. Подобно RefCell
, вы должны убедиться, что логика вашей программы не удерживает одни данные, ожидая доступа к другим данным, что приводит к взаимоблокировке.
Например, следующий фрагмент кода использует мьютекс для параллельного увеличения счетчика в двух новых потоках, а затем считывает окончательный результат из исходного потока:
ржавчина
// Создаем общий изменяемый счетчик
пусть counter = Arc::new(Mutex::new(0));
// Создаем один поток, увеличивающий счетчик
пусть counter1 = counter.clone();
пусть handle1 = thread::spawn(move || {
для _ в 0..10 {
*counter1.lock().unwrap() += 1;
// Запустить другой поток, увеличивающий счетчик
пусть counter2 = counter.clone();
пусть handle2 = thread::spawn(move || {
для _ в 0..10 {
*counter2.lock().unwrap() += 1;
// Ждем завершения потоков
handle1.join().unwrap();
handle2.join().unwrap();
// Напишет "Value: 20", так как каждый поток увеличивал счетчик 10 раз.
println!("Значение: {}", *counter.lock().unwrap());
Основное различие между этими двумя типами заключается в том, как они обрабатывают разные виды доступа. «Мьютекс» не заботится о том, пытаетесь ли вы читать или записывать данные, только один поток может иметь доступ одновременно. Все остальные потоки должны ждать, пока текущий потребитель не откажется от доступа.
Напротив, RwLock следует правилам Rust: любое количество потоков может иметь доступ только для чтения к базовым данным, или один поток может иметь доступ для записи, но не комбинацию. Попытка получить доступ для записи будет блокироваться до тех пор, пока не останется активных читателей, и наоборот.
Потокобезопасный характер этих типов блокировки делает их одними из самых мощных способов совместного использования изменяемых данных, однако это приводит к потенциально значительным потерям производительности: блокировка потока, чтобы никакая другая работа не могла быть выполнена, пока данные не будут доступны. Если наши данные достаточно просты, последний тип, который мы собираемся рассмотреть, может обеспечить общий доступ между потоками без необходимости блокировки.
Атомы
Атомарные типы доступны для целочисленных и логических примитивов. Все эти типы предоставляют методы для изменения или чтения данных как одной операции, так что между ними ничего не может произойти, и нет возможности для гонок данных.
Например, если вы хотите увеличить значение счетчика, вместо того, чтобы читать значение, добавлять его, а затем записывать значение, вы должны использовать метод fetch_add
, который делает все это в одном блоке.
Atomics часто используются в качестве строительных блоков для более сложного безопасного совместного использования потоков или одноразовой инициализации. Как упоминалось выше, Arc использует внутренний атомарный счетчик для управления подсчетом ссылок потокобезопасным способом.
Прелесть всех этих типов в том, что они дают возможность нарушить правила проверки заимствования, сохраняя при этом гарантии безопасности Rust. Понимание того, какие правила нарушает каждый тип, является ключом к пониманию того, какой инструмент вам нужен. Если вы ищете совместное владение без копирования, вам нужны типы с подсчетом ссылок. Если вы ищете изменчивость без строгих изменяемых ссылок Rust, используйте внутреннюю изменчивость. И если вам нужно общее изменяемое состояние, нередко используется их комбинация: Rc<RefCell<T>>
или Arc<Mutex<T>>
— это комбинации для одно- и многопоточного общего изменяемого владения соответственно.
Если вы заинтересованы в том, чтобы нарушать больше правил Rust и продвигать терминал в будущее, мы нанимаем в Warp!
- Или, если вы просто хотите проверить наш терминал на основе Rust, запросите ранний доступ ниже: *
Запросить ранний доступ к Warp
- Впервые опубликовано [здесь] (https://blog.warp.dev/rules-are-made-to-be-broken/)*
Оригинал