Внутренняя изменчивость в Rust, часть 2: безопасность потоков

Перевод | Автор оригинала: Ricardo Martins

Ключевые выводы

Эта статья является частью серии о внутренней изменчивости в Rust. Вы можете прочитать часть 1 здесь и часть 3 здесь.

Введение

В предыдущей статье мы рассмотрели Cell и RefCell как способ достижения внутренней изменчивости - способности изменять определенные поля в структуре независимо от ее внешней (явной или объявленной) изменчивости.

Однако ни Cell, ни RefCell не подходят для обмена данными между потоками. Поскольку Cell не имеет механизмов проверки заимствования и ее операции не атомарны, возможны состояния гонки, при которых мы считываем устаревшие значения и теряем обновления. RefCell, с другой стороны, имеет проверку заимствования во время выполнения, но будет паниковать, когда есть конфликтующие заимствования, такие как заимствование изменяемого заимствованного значения. Кроме того, RefCell использует Cell для отслеживания состояния заимствования. Это означает, что даже если вы будете осторожны и проверите его перед заимствованием, вы рискуете взаимно заимствовать значение одновременно в нескольких потоках, потому что его проверки заимствования во время выполнения также не являются атомарными.

Из-за этих проблем и Cell, и RefCell помечены! Sync, что означает, что их небезопасно использовать более чем в одном потоке1.

Кроме того, нам нужно обмениваться ссылками на ячейку между потоками, но тип счетчика ссылок, который мы исследовали ранее, Rc, также не подходит для использования в этом сценарии. Поля счетчика ссылок внутри Rc заключены в оболочку Cell, поэтому рано или поздно они получат ошибочные значения в многопоточной программе. Наличие полей! Sync «заразительно». Поскольку Rc содержит два поля Cell, их маркеры! Sync распространяются на всю структуру Rc. Rc также отмечен! Отправить - небезопасно отправлять (перемещать) в другие потоки. Точно так же! Send так же «заразителен», как! Sync.

Мы могли бы сами реализовать обе эти трэйты в наших типах, но, поскольку эти трэйты довольно важны, они помечены как небезопасные. Итак, нам нужно сообщить компилятору Rust, что мы знаем, что мы делаем, добавив к их объявлениям impl ключевое слово unsafe. Например, если мы хотим сделать узлы в примере из предыдущей статьи Send and Sync:

unsafe impl<T> Send for Node<T> {}
unsafe impl<T> Sync for Node<T> {}

Это заставляет компилятор замолчать и позволяет нам продолжить работу, но что это означает?

Отправка и синхронизация автоматически производятся компилятором для большинства типов. Если тип содержит поле! Send или! Sync, «зараза» всегда распространяется на родительские типы. Явно реализуя эти трэйты, мы неявно сообщаем пользователю нашего API, что наши типы поточно-ориентированы (Sync) и что их можно безопасно перемещать между потоками (Send).

Когда мы говорим, что нашими типами являются отправка и синхронизация, фактически не вводя механизмы синхронизации, мы не уважаем «контракты», связанные с этими характеристиками, и вводим пользователя в заблуждение. У них может возникнуть паника во время выполнения или плохие результаты из-за условий гонки. Отнюдь не самая удачная ситуация.

Поточно-безопасная внутренняя изменчивость

К счастью, Rust дает нам хорошие инструменты для достижения внутренней изменяемости поточно-безопасным способом без особых усилий. Более того, он делает это таким образом, что проверяющий будет большую часть времени прикрыт нам спиной, поэтому мы не сможем прострелить себе ноги.

Одним из действительно хороших аспектов Rust является то, что как только вы освоите систему заимствования, вы можете использовать те же рассуждения с внутренней изменчивостью в одном потоке (Cell и RefCell) и в параллельных программах. Для копирования значений

Для значений копирования (например, целых чисел) вместо Cell у нас есть атомарные типы (std::sync::atomic::*), которые полагаются на инструкции сборки для предотвращения гонки данных:

Несмотря на то, что существует всего четыре типа, вы можете использовать AtomicPtr для реализации дополнительных. В качестве альтернативы вы можете использовать крэйти, такие как атомный крэйт, чтобы сделать это с помощью простого API. Краткое заявление об отказе от ответственности: я не пробовал Atom, но пример в его README выглядит хорошо, и быстрый взгляд на исходный код выглядит именно так, как я ожидал.

Использование атомарного типа немного сложнее, чем то же самое с Cell. Взяв снова наивный пример счетчика ссылок из предыдущей статьи, используя AtomicUsize, мы получим:

use std::sync::atomic::{AtomicUsize, Ordering};

struct NaiveRc<T> {
    reference_count: AtomicUsize,
    inner_value: T,
}

impl<T> Clone for NaiveRc<T> {
    fn clone(&self) -> Self {
        self.reference_count.fetch_add(1, Ordering::Relaxed);
        // ...
    }
}

Как видите, вместо того, чтобы просто назначить новое значение для reference_count, мы вызвали fetch_add для его атомарного увеличения. Первый параметр - это размер приращения, а второй - новый. Порядок сообщает компилятору (и ЦП), сколько свободы он имеет для изменения порядка инструкций. Я не буду углубляться в это, так как официальная документация объясняет это достаточно подробно.

Для значений без копирования

Для значений, не являющихся копиями, std::sync::RwLock является эквивалентом RefCell. Подобно RefCell, RwLock имеет семантику, очень похожую на семантику нашего старого друга, систему заимствования, и допускает использование нескольких «читателей» или одного «писателя», но не обоих одновременно, или нескольких «писателей».

Однако, в отличие от RefCell, RwLock не паникует, когда есть несовместимые заимствования: если потоку требуется изменяемая ссылка, ему просто нужно будет дождаться, пока другие потоки не снимут блокировку (т.е. прекратят использовать заимствованные значения).

Мы можем получить общие ссылки только для чтения с помощью чтения (эквивалентно заимствованию в RefCell) или исключительные изменяемые ссылки с помощью записи (заимствовать_мута, соответственно) 2.

Преобразование примера графа из предыдущей статьи для использования RwLock вместо RefCell несложно: замените объявления RefCell на RwLock, измените заимствование на чтение и заимствование_mut на запись. Нам также нужно заменить Rc на Arc, чтобы иметь возможность перемещать ссылки на другие потоки, но я опишу это позже.

use std::thread;
use std::sync::{Arc, RwLock};

// Represents a reference to a node.
// This makes the code less repetitive to write and easier to read.
type NodeRef<T> = Arc<RwLock<_Node<T>>>;

// The private representation of a node.
struct _Node<T> {
    inner_value: T,
    adjacent: Vec<NodeRef<T>>,
}

// The public representation of a node, with some syntactic sugar.
struct Node<T>(NodeRef<T>);

impl<T> Node<T> {
    // Creates a new node with no edges.
    fn new(inner: T) -> Node<T> {
        let node = _Node { inner_value: inner, adjacent: vec![] };
        Node(Arc::new(RwLock::new(node)))
    }

    // Adds a directed edge from this node to other node.
    fn add_adjacent(&self, other: &Node<T>) {
        self.0
            .write()
            .expect("Failed to acquire a write lock on node")
            .adjacent.push(other.0.clone());
    }
}

struct Graph<T> {
    nodes: Vec<Node<T>>,
}

impl<T> Graph<T> {
    fn with_nodes(nodes: Vec<Node<T>>) -> Self {
        Graph { nodes: nodes }
    }
}

fn main() {
    // Create some nodes
    let node_1 = Node::new(1);
    let node_2 = Node::new(2);
    let node_3 = Node::new(3);

    // Connect some of the nodes (with directed edges)
    node_1.add_adjacent(&node_2);
    node_1.add_adjacent(&node_3);
    node_2.add_adjacent(&node_1);
    node_3.add_adjacent(&node_1);

    // Add nodes to graph
    let graph = Arc::new(Graph::with_nodes(vec![node_1, node_2, node_3]));

    // Spawn a new thread that will print information about every node in the
    // graph.
    // The new scope makes this block more obviously different from the code
    // surrounding it and lets us group variables that will be moved into the
    // new thread, such as "graph".
    let guard = {
        let graph = graph.clone();
        let message = "Failed to acquire a read lock";
        thread::spawn(move || {
            for _ in 0..10 {
                // Show every node in the graph and list their neighbors
                for node in &graph.nodes {
                    let node = node.0.read().expect(&message);
                    let value = node.inner_value;
                    let neighbours = node.adjacent
                        .iter()
                        .map(|n| n.read().expect(&message).inner_value)
                        .collect::<Vec<_>>();
                    println!("node ({}) is connected to: {:?}", value, neighbours);
                }
                println!("-------------");
                // Give the main thread a chance to run
                thread::yield_now();
            }
        })
    };

    for _ in 0..10 {
        // Update the value of every node in the graph
        for node in &graph.nodes {
            let mut node = node.0.write().expect("Failed to acquire a write lock");
            node.inner_value += 10;
        }
        // Give the other thread a chance to run
        thread::yield_now();
    }

    // Wait for the other thread to end
    guard.join().expect("Error joining thread");
}

Вы можете увидеть различия между однопоточной и многопоточной версиями в этом разделе.

Помимо создания нового потока, который печатает информацию о графике, и нового цикла в основном потоке, который обновляет значения узла, единственными важными отличиями являются:

Я рекомендую вам внести эти изменения самостоятельно, начиная с кода предыдущей статьи. Внесение небольших инкрементальных изменений и компиляция промежуточных версий обычно помогает мне интуитивно понять, как работает API.

Обратите внимание, что, в отличие от заимствования и заимствования_mut, чтение и запись возвращают LockResult, который является псевдонимом типа для Result<Guard, PoisonError > и требует от нас сопоставления, разворачивания или ожидания его. Guard автоматически превращается в ссылку, поэтому мы можем практически игнорировать ее.

По моему опыту, вам редко нужно иметь дело с случаем ошибки для чтения или записи, поскольку это происходит только в том случае, если другой поток с изменяемой ссылкой (то есть успешной записью) паникует. В этом случае у вас есть более серьезная ошибка в другом месте, о которой вам нужно позаботиться.

И чтение, и запись будут блокировать поток до тех пор, пока он не получит запрошенную блокировку. Поскольку вы, возможно, не захотите ждать бесконечно, вы также можете вместо этого вызвать try_read и try_write, которые не будут блокировать и возвращать ошибку, если они не могут получить блокировку.

Помимо RwLock, существует также std::sync::Mutex, имя которого происходит от «взаимного исключения», поскольку он гарантирует взаимоисключающий доступ к обернутому значению, то есть только один поток может получить к нему доступ одновременно. Из-за этого всегда безопасно изменять значение после получения к нему доступа.

Мы можем смотреть на Mutex, как если бы это был RwLock без чтения, способный только давать вызывающему изменяемые ссылки. В этом смысле она более ограничивающая, чем обычная система заимствования или RwLock, которые допускают одновременное использование нескольких считывателей (с неизменяемыми ссылками) или только одного писателя (изменяемая ссылка). Даже если нам нужна невинная неизменяемая ссылка, мы должны получить полное разрешение на внутреннее значение.

Поскольку существует только один вид заимствования для значений Mutex, чтение и запись заменяются одним методом блокировки, который блокирует поток до тех пор, пока текущий владелец блокировки не освободит его (т. Е. Пока не закончится другое заимствование). Как и в случае с RwLock, если вы не хотите блокировать поток, когда значение недоступно, вы можете вместо этого вызвать try_lock, который либо выдаст вам ссылку на блокировку / изменяемость, либо ошибку (Err) .3

Подсчет ссылок

Как я упоминал ранее, std::rc::Rc не контролирует синхронизацию, что делает его небезопасным для использования несколькими потоками. Его поточно-ориентированным аналогом является Arc, который живет в std::sync вместе с RwLock и Mutex.

Arc очень похож на Rc, но полагается на AtomicUsize для счетчика ссылок, что делает его безопасным для обновления более чем одним потоком, в отличие от Rc, который использует Cell .

pub struct Rc<T: ?Sized> {
    ptr: Shared<RcBox<T>>,
}

struct RcBox<T: ?Sized> {
    strong: Cell<usize>,
    weak: Cell<usize>,
    value: T,
}

pub struct Arc<T: ?Sized> {
    ptr: Shared<ArcInner<T>>,
}

struct ArcInner<T: ?Sized> {
    strong: atomic::AtomicUsize,
    weak: atomic::AtomicUsize,
    data: T,
}

API Arc идентичен API Rc, что делает замену одного другим простым делом поиска и замены имени и исправления импорта.

Заключительные мысли

В предыдущей статье мы узнали, что внутренняя изменчивость в Rust может быть достигнута с помощью Cell и RefCell в однопоточных программах, поддерживаемых Rc там, где это необходимо. В этой статье мы увидели, что то же самое можно безопасно сделать в параллельных программах с атомарными типами и RwLock с помощью Arc.

Single threadMultiple threads
Копировать значениеCellAtomic*
Значение без копированияRefCellRwLock, Mutex
Счетчик ссылокRcArc

В приведенной выше таблице перечислены типы, которые будут использоваться в однопоточных и многопоточных сценариях.

Type of accessBorrow checkerRefCellRwLockMutex
shared / read-only&Tborrowread-
exclusive / writable&mut Tborrow_mutwritelock

Эта вторая таблица подчеркивает сходство между программой проверки заимствований RefCell, RwLock и, в меньшей степени, Mutex.

Вы можете спросить: «Зачем выбирать RefCell и Rc, если RwLock и Arc имеют идентичную семантику и очень похожие API?»

К сожалению, типы, которые мы исследовали в этой статье (атомарные типы, RwLock, Mutex и Arc), зависят от примитивов синхронизации с более высокими затратами времени выполнения, чем их наивные аналоги, и мы по возможности стараемся избегать их.

У нас может быть обычная комбинация Rc и RefCell для переменных, которые не используются другими потоками, и их синхронизированные версии для битов, которые вы хотите распараллелить. Поскольку API и семантика в обоих случаях схожи, у нас не будет больших когнитивных издержек при использовании обоих.

Еще один важный момент, на который мы также должны обратить внимание, - это семантика упаковки. Например, Arc<Vec<RwLock>> отличается от Arc<RwLock<Vec>>. С первым мы не можем одновременно изменять сам вектор, но мы можем изменять его сохраненные значения. Это следствие того, что Arc реализует Deref, но не DerefMut, что означает, что мы можем получить только неизменяемые ссылки на вектор (который содержит блокируемые элементы). Во второй форме мы получаем неизменяемую ссылку на RwLock, но поскольку она может давать нам оба вида ссылок посредством чтения и записи, мы можем изменять вектор, добавляя или удаляя элементы. Однако мы теряем возможность одновременно изменять значения: как только поток получает изменяемую ссылку на вектор, другим придется ждать, в то время как первая форма позволяет нам иметь один поток, изменяющий каждый элемент параллельно.

Короче говоря, Arc<Vec<RwLock>> позволяет нам изменять все элементы вектора параллельно, если мы хотим это сделать, в то время как Arc<RwLock<Vec>> позволяет только одному потоку изменять вектор. (и его значения), оставляя другие потоки ожидающими блокировки.

Если T сам обернут Arc, мы вообще не сможем изменить сохраненные значения (потому что Arc приводит только к неизменяемым ссылкам). Нам понадобится чудовище вроде Arc<RwLock<Vec<Arc<RwLock>>>>, чтобы иметь возможность одновременно изменять вектор и его элементы, но мы должны воспользоваться этим намеком, чтобы переосмыслить, как вы хотите распараллелить свой код.

Изучение внутренней изменчивости в параллельной среде заставило меня понять, что Rust отличается от других языков в отношении блокировок. В то время как в других языках мы используем блокировки для защиты фрагментов кода, в Rust мы используем их для защиты доступа к данным.

Вдобавок блокировки настолько похожи в использовании на механизмы заимствования в Rust, что кажутся поточно-ориентированным обобщением этих механизмов. Лично я считаю, что этот способ намного проще рассуждать, чем классический способ C.

Наконец, я знаю, что это заманчиво, но не стоит бросать повсюду внутреннюю изменчивость только для того, чтобы программа проверки заемных средств заткнулась. Тщательно подумайте, действительно ли ситуация требует внутренней изменчивости или рефакторинга ваших структур данных. Это вдвойне справедливо для параллельных программ, не только потому, что RwLock, Mutex и Arc несут дополнительные затраты времени выполнения, но также потому, что синхронизацию легко испортить и оставить вас в условиях гонки. К счастью, программа проверки займов в Rust дает нам ценные рекомендации и снижает их вероятность. Условия гонки неприятны для отладки, будьте особенно осторожны, чтобы как можно скорее сбросить блокировки.

Вот и все. Теперь вы знаете достаточно, чтобы эффективно использовать внутреннюю изменчивость в своих программах, будь то однопоточные или многопоточные. Отличная работа! 🎉

Выражаю благодарность /u/Manishearth, /u/Steel_Neuron и /u/diwic за их отзывы.

Надеюсь, вы нашли эту статью полезной и/или интересной. Как всегда, если вы обнаружили ошибку или у вас есть какие-либо вопросы, напишите мне в Twitter (@meqif) или отправьте мне электронное письмо (words@ricardomartins.cc). Вы также можете присоединиться к обсуждению на Reddit.

  1. Мы можем реализовать маркерную трэйту или ее отрицание для типа. В настоящее время это работает только с характеристиками маркера, такими как Sync и Send, которые не имеют связанных методов, но предоставляют полезную информацию о типе. Ведется работа по обобщению этой особенности, называемой отрицательными трэйтами, на любую трэйту (rust-lang / rust # 13231).

  2. Фактически, чтение и запись вернут Result или Result соответственно. RwLockReadGuard реализует Deref, а RwLockWriteGuard реализует как Deref, так и DerefMut, которые прозрачно переводятся в изменяемые (&T) и неизменяемые (&mut T) ссылки соответственно.

  3. Подобно RwLock, вызов блокировки на Mutex вернет Result, который реализует как Deref, так и DerefMut, и может быть принудительно преобразован в &T или &mut T соответственно.