Rust  —  язык системного программирования с акцентом на безопасности, многопоточности, производительности. В этом руководстве рассмотрим два примитива многопоточности Rust: Arc и Mutex.

При написании многопоточного Rust рано или поздно встречаются типы Arc и Mutex. Mutex применяется во многих языках, а вот Arc вряд ли найдется где-то еще, кроме Rust. Нельзя полностью понять эти концепции, не связав их с моделью владения Rust. Эта статья  —  мой подход к пониманию Arc и Mutex в Rust.

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

Если выбрать для обмена данными совместное использование памяти, быстро обнаруживается, что без Arc и Mutex здесь мало что делается. Arc  —  умный указатель для совместного, безопасного использования потоками значения. Mutex  —  обертка над другим типом для безопасной изменяемости в потоках. Чтобы полностью понять эти концепции, рассмотрим модель владения.

Владение на Rust

Вот характеристики модели владения Rust:

  • у значения имеется только один владелец;
  • общих неизменяемых ссылок на значение может быть несколько;
  • изменяемая ссылка на значение может быть только одна.
use std::thread::spawn;

#[derive(Debug)]
struct User {
name: String
}

fn main() {
let user = User { name: "sam".to_string() };

spawn(move || {
println!("Hello from the first thread {}", user.name);
}).join().unwrap();
}

Пока все хорошо, программа компилируется с выводом сообщения. Добавим второй поток, также с доступом к экземпляру user:

fn main() {
let user = User { name: "sam".to_string() };

let t1 = spawn(move || {
println!("Hello from the first thread {}", user.name);
});

let t2 = spawn(move || {
println!("Hello from the second thread {}", user.name);
});

t1.join().unwrap();
t2.join().unwrap();
}

С этим кодом получаем такую ошибку:

error[E0382]: use of moved value: `user.name`
--> src/main.rs:15:20
|
11 | let t1 = spawn(move || {
| ------- value moved into closure here
12 | println!("Hello from the first thread {}", user.name);
| --------- variable moved due to use in closure
...
15 | let t2 = spawn(move || {
| ^^^^^^^ value used here after move
16 | println!("Hello from the second thread {}", user.name);
| --------- use occurs due to use in closure
|
= note: move occurs because `user.name` has type `String`, which does not implement the `Copy` trait

Что нужно компилятору? Ошибка здесь такая: use of moved value («Использование перемещенного значения user.name»). Компилятором даже указываются конкретные места, где возникает проблема. Сначала перемещаем значение в первый поток, затем пытаемся во второй.

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

fn main() {
let user = User { name: "sam".to_string() };

let t1 = spawn(|| {
println!("Hello from the first thread {}", &user.name);
});

let t2 = spawn(|| {
println!("Hello from the second thread {}", &user.name);
});

t1.join().unwrap();
t2.join().unwrap();
}

Здесь в замыканиях потоков удалили ключевое слово move, потоками неизменяемо заимствуется значение user. То есть получается общая ссылка, представленная амперсандом. С этим кодом получаем:

error[E0373]: closure may outlive the current function, but it borrows `user.name`, which is owned by the current function
--> src/main.rs:15:20
|
15 | let t2 = spawn(|| {
| ^^ may outlive borrowed value `user.name`
16 | println!("Hello from the first thread {}", &user.name);
| --------- `user.name` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:15:14
|
15 | let t2 = spawn(|| {
| ______________^
16 | | println!("Hello from the second thread {}", &user.name);
17 | | });
| |______^
help: to force the closure to take ownership of `user.name` (and any other referenced variables), use the `move` keyword
|
15 | let t2 = spawn(move || {
| ++++

Теперь ошибкой указывается на то, что замыкание способно «пережить» функцию. Иными словами, компилятором Rust не гарантируется, что замыкание в потоке завершится до функции main().

Структура user заимствуется потоками, но владение ею остается за функцией main. В этом сценарии, если функция main завершается, структура user выходит из области видимости, и память удаляется. Поэтому, если таким образом делиться значением с потоками, поток попытается считать освобожденную память. Это неопределенное поведение, и оно, конечно, нежелательно.

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

Потоки области видимости

Потоки области видимости  —  это функционал, доступный из отличного крейта crossbeam или как экспериментальная ночная функция на Rust. Воспользуемся crossbeam, но API обеих версий очень похожи.

Добавив crossbeam = "0.8" в зависимости Cargo.toml, получаем беспроблемный рабочий код:

use crossbeam::scope;

#[derive(Debug)]
struct User {
name: String,
}

fn main() {
let user = User {
name: "sam".to_string(),
};

scope(|s| {
s.spawn(|_| {
println!("Hello from the first thread {}", &user.name);
});

s.spawn(|_| {
println!("Hello from the second thread {}", &user.name);
});
})
.unwrap();
}

Все потоки, созданные в области видимости, гарантированно завершаются до завершения замыкания scope. То есть, прежде чем замыкание выйдет из области видимости, потоки объединяются в ожидании завершения. Благодаря этому компилятор «знает», что ни одно из заимствований не «переживет» владельца.

Интересно, что для человека обе эти программы допустимы: в версии, отвергаемой Rust, мы объединяем оба потока до завершения функции main(), поэтому делиться значением user с потоками на самом деле безопасно. Такое в Rust случается. Невозможно написать компилятор для приема всех допустимых программ, альтернатива  —  суперстрогий компилятор, которым отклоняются все недопустимые. Потоки в области видимости созданы специально для этого  —  писать код, принимаемый компилятором.

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

Arc в помощь

Arc  —  это умный указатель для обмена данными между потоками, расшифровывается как atomic reference counter, то есть атомарный подсчет ссылок.

Фактически задача Arc  —  обернуть значение, которым мы пытаемся поделиться, и быть указателем на него. Этим Arc отслеживаются все копии указателя, и по выходе последнего указателя из области видимости безопасно освобождается память.

Вот как Arc решается описанная выше проблема:

use std::thread::spawn;
use std::sync::Arc;

#[derive(Debug)]
struct User {
name: String
}

fn main() {
let user_original = Arc::new(User { name: "sam".to_string() });

let user = user_original.clone();
let t1 = spawn(move || {
println!("Hello from the first thread {}", user.name);
});

let user = user_original.clone();
let t2 = spawn(move || {
println!("Hello from the first thread {}", user.name);
});

t1.join().unwrap();
t2.join().unwrap();
}

Рассмотрим подробнее.

Сначала создаем значение user и оборачиваем его в Arc. Теперь оно сохраняется в памяти, а Arc всего лишь указатель.

При каждом клонировании клонируется не значение user, а только ссылка. Клонируя Arc, мы перемещаем в каждый из потоков копию указателя. Благодаря Arc данные обмениваются независимо от времен жизни.

В этом примере создается три указателя на значение user: один при создании Arc, второй клонированием перед запуском первого потока, в который он и перемещается, а третий клонированием перед запуском второго потока  —  тоже перемещается в первый поток.

Пока хоть один указатель активен, память в Rust не освободится. Когда же завершаются потоки и функция main, все указатели Arc выходят из области видимости и удаляются. С последним из них удаляется и значение user.

Send и Sync

Копнем глубже. Согласно документации, типажи Send и Sync реализуются в Arc, только если реализуются и оборачиваемым типом. Чтобы разобраться, начнем с определения Send и Sync.

В Rustonomicon Send и Sync определяются так:

  • Если тип безопасно отправляется в другой поток, это Send.
  • Если тип безопасно обменивается между потоками, это Sync; T является Sync, только когда &T является Send.

Подробнее об этих типажах  —  в Rustonomicon, но попробуем разобраться самостоятельно. Send и Sync  —  типажи-маркеры без реализованных методов, им ничего не требуется реализовывать. Компилятор уведомляется ими о возможности обмениваться типом или отправлять тип между потоками.

Начнем с Send, он попроще. Нельзя отправить в другой поток тип !Send, то есть не Send: нельзя отправить его по каналу или переместить в поток. Например, этот код не скомпилируется:

#![feature(negative_impls)]

#[derive(Debug)]
struct Foo {}
impl !Send for Foo {}

fn main() {
let foo = Foo {};
spawn(move || {
dbg!(foo);
});
}

Send и Sync выводятся автоматически. Например, если все атрибуты типа являются Send, этот тип будет тоже Send. В коде экспериментальным функционалом negative_impls компилятору сообщается о намерении явно обозначить этот тип как !Send.

В итоге появляется ошибка:

`Foo` cannot be sent between threads safely

То же происходит при создании канала для отправки foo в поток. С Arc таким же образом вылетает та же ошибка. И то же справедливо для типа !Sync, поскольку Arc нужны оба типажа:

#![feature(negative_impls)]

#[derive(Debug)]
struct Foo {}
impl !Send for Foo {}

fn main() {
let foo = Arc::new(Foo {});
spawn(move || {
dbg!(foo);
});
}

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

Мы знаем теперь, что благодаря Arc потоки независимо от времен жизни обмениваются ссылками на типы, которые являются Send + Sync. Ведь это не обычная ссылка, а умный указатель.

Изменение данных с помощью Mutex

Мьютексы во многих языках рассматриваются как семафоры. Создавая мьютексный объект, мы защищаем с помощью mutex конкретную часть или части кода. Так что защищаемое место единовременно доступно только одному потоку.

В Rust Mutex  —  это скорее обертка. Доступ к базовому значению предоставляется ею только после блокировки мьютекса. Обмен значения между потоками упрощается этим Mutex с помощью Arc.

Вот пример:

use std::time::Duration;
use std::{thread, thread::sleep};
use std::sync::{Arc, Mutex};

struct User {
name: String
}

fn main() {
let user_original = Arc::new(Mutex::new(User { name: String::from("sam") }));

let user = user_original.clone();
let t1 = thread::spawn(move || {
let mut locked_user = user.lock().unwrap();
locked_user.name = String::from("sam");
// После того как «locked_user» выйдет из области видимости, мьютекс снова разблокируется.
// Чтобы разблокировать его явно, применяется
// «drop(locked_user)».
});

let user = user_original.clone();
let t2 = thread::spawn(move || {
sleep(Duration::from_millis(10));

// Выведется «Hello sam»
println!("Hello {}", user.lock().unwrap().name);
});

t1.join().unwrap();
t2.join().unwrap();
}

Разберем этот код. В первой строке функции main() создается экземпляр структуры User, оборачиваемый в Mutex и Arc. С Arc указатель легко клонируется, поэтому мьютекс задействуется потоками совместно. После того как мьютекс блокируется, базовое значение используется исключительно этим потоком. В следующей строке это значение меняется. Мьютекс разблокируется, как только защищенная, блокированная часть кода выходит из области видимости, или удаляется вручную с помощью drop(locked_user).

Во втором потоке через 10 мс ожидания выводится название, обновленное в первом потоке. На этот раз блокировка выполняется в одной строке, поэтому мьютекс удаляется в том же операторе.

Необходимо упомянуть также о методе unwrap(), вызываемом после lock(). В Mutex стандартной библиотеки заложено понятие об отравлении. Если поток «паникует» при заблокированном мьютексе, нельзя определить, остается ли значение внутри Mutex валидным. Поэтому поведение по умолчанию  —  возвращение ошибки, а не защищенной части кода. Причем этим Mutex возвращается вариант Ok() с обернутым значением в качестве аргумента либо ошибка. Подробнее об этом  —  в документации.

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

Интересно и вот что: пока тип внутри Mutex является Send, мьютекс будет также и Sync. Ведь мьютексом обеспечивается доступ к базовому значению только для одного потока, поэтому совместное использование Mutex безопасно для потоков.

Mutex: добавление Sync к типу Send

Напомним: чтобы Arc стал Send + Sync, ему нужен базовый тип Send + Sync. А вот, чтобы Mutex стал Send, требуется только базовый тип Send. То есть с Mutex тип !Sync становится Sync, обменивается между потоками, а также изменяется.

Mutex без Arc

Что, если использовать Mutex без Arc? Подумайте, что означает то, что Mutex  —  это Send + Sync для типов Send?

Очень похоже на то, что это означает для типа Arc. Если применять что-то вроде потоков области видимости, Mutex обходится без Arc:

use crossbeam::scope;
use std::{sync::Mutex, thread::sleep, time::Duration};

#[derive(Debug)]
struct User {
name: String,
}

fn main() {
let user = Mutex::new(User {
name: "sam".to_string(),
});

scope(|s| {
s.spawn(|_| {
user.lock().unwrap().name = String::from("psaa");
});

s.spawn(|_| {
sleep(Duration::from_millis(10));

// выводится «Hello psaa»
println!("Hello {}", user.lock().unwrap().name);
});
})
.unwrap();
}

В этой программе достигается та же цель: доступ к значению позади мьютекса получается в двух отдельных потоках, но мьютексы используются ими совместно по ссылке, и без Arc. Опять же, это не всегда возможно, например, в асинхронном коде, поэтому Mutex очень часто применяется вместе с Arc.

Заключение

Мы изучили типы Arc и Mutex в Rust. Arc, как правило, используется при невозможности обмена данными между потоками с помощью обычных ссылок. Для изменения данных, которыми обмениваются потоки, применяется Mutex. Если же при этом мьютекс не разделяется посредством ссылок, используется Arc<Mutex<...>>.

Бонус: почему Arc нужен тип Sync?

Вернемся к вопросу о том, почему Arc нужно, чтобы базовый тип являлся и Send, и Sync, помечался как Send и Sync. Концовку статьи можете пропустить, поскольку Arc и Mutex в коде не особо востребованы. Но для понимания типажей-маркеров она придется кстати.

Возьмем в качестве примера Cell, которым обертывается другой тип и обеспечивается внутренняя изменяемость, то есть возможность изменять значение внутри неизменяемой структуры. Тип Cell  —  Send, но это !Sync.

Вот пример:

use std::cell::Cell;

struct User {
age: Cell<usize>
}

fn main() {
let user = User { age: Cell::new(30) };

user.age.set(36);

// выведется «Age: 36»
println!("Age: {}", user.age.get());
}

Cell полезен в некоторых ситуациях, но не потокобезопасен, то есть это !Sync. Если значение, обернутое в cell, каким-то образом обменивается между потоками, то же место в памяти изменяется из двух потоков:

// этот пример не скомпилируется, «Cell» — это «!Sync», поэтому
// «Arc» будет «!Sync» и «!Send»
use std::cell::Cell;

struct User {
age: Cell<usize>
}

fn main() {
let user_original = Arc::new(User { age: Cell::new(30) });

let user = user_original.clone();
std::thread::spawn(move || {
user.age.set(2);
});

let user = user_original.clone();
std::thread::spawn(move || {
user.age.set(3);
});
}

Такой код чреват неопределенным поведением. Поэтому Arc не рабочий с любыми типами, кроме Send и Sync. Но Cell  —  это Send, поэтому отправляется между потоками. Дело в том, что отправкой или перемещением значение не делается доступным более чем из одного потока, такой поток всегда только один. Как только значение перемещается в другой, предыдущему потоку оно уже не принадлежит. Учитывая это, мы всегда можем изменить Cell локально.

Бонус: зачем Arc нужен тип

А нет ли у Arc типажа Send и для !Send? Rc  —  один из типов Rust, который является !Send. В отличие от Arc, он не атомарный Rc, расширяется лишь до счетчика ссылок и при практически той же роли Arc используется только в одном потоке. Он не обменивается и даже не перемещается между потоками, посмотрим почему:

// этот код не скомпилируется, Rc является «!Send» и «!Sync»
use std::rc::Rc;

fn main() {
let foo = Rc::new(1);

let foo_clone = foo.clone();
std::thread::spawn(move || {
dbg!(foo_clone);
});

let foo_clone = foo.clone();
std::thread::spawn(move || {
dbg!(foo_clone);
});
}

Этот пример не компилируется, потому что Rc  —  !Sync + !Send. Его внутренний счетчик не атомарный, поэтому обмен им между потоками чреват неточным подсчетом ссылок.

Если же в Arc типы !Send сделаются Send:

use std::rc::Rc;
use std::sync::Arc;

#[derive(Debug)]
struct User {
name: Rc<String>,
}
unsafe impl Send for User {}
unsafe impl Sync for User {}

fn main() {
let foo = Arc::new(User {
name: Rc::new(String::from("drogus")),
});

let foo_clone = foo.clone();
std::thread::spawn(move || {
let name = foo_clone.name.clone();
});

let foo_clone = foo.clone();
std::thread::spawn(move || {
let name = foo_clone.name.clone();
});
}

Теперь пример компилируется, только не делайте так в коде. Здесь определяется структура User с Rc внутри. Поскольку Send и Sync выводятся автоматически, а Rc  —  !Send + !Sync, структура User тоже !Send + !Sync, но компилятору явно указывается обозначить ее по-другому, в данном случае Send + Sync с синтаксисом unsafe impl.

Теперь видно, что пойдет не так, если разрешить в Arc перемещение типов !Send между потоками. В примере клоны Arc перемещаются в отдельные потоки, после чего ничто не мешает клонировать тип Rc. А поскольку тип Rc не потокобезопасный, это чревато неточным подсчетом ссылок. Следовательно, память освободится слишком рано либо не освободится вовсе, хотя и должна.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи nollan fabianski: Understanding and Implementing Rust’s Arc and Mutex

Предыдущая статьяНастройка компонентов и тем Material UI: современный подход
Следующая статьяШпаргалка по хукам в React